fix: fixed shield.io badges not rendering properly (#953)

* fix: fixed shield.io badges not rendering properly

* changeset
This commit is contained in:
Maciek 2026-10-10 00:55:21 +02:00 • committed by GitHub
parent 61d24b688e
commit c6361c7266
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 752 additions and 28 deletions

View file

@ -0,0 +1,5 @@
---
default: patch
---
# Fixed shield.io badges not rendering properly in markdown viewers

1
Cargo.lock generated
View file

@ -4918,6 +4918,7 @@ version = "2.7.0"
dependencies = [
"anyhow",
"arboard",
"base64 0.23.1",
"bytes 1.12.1",
"cargo-packager-updater",
"chrono",

View file

@ -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

View file

@ -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<ImageContent> {
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"<svg")
|| (head.starts_with(b"<?xml")
|| head.starts_with(b"<!--")
|| head.starts_with(b"<!DOCTYPE"))
&& head.windows(4).any(|w| w == b"<svg")
}
fn svg_size(root: &svg::Svg) -> Option<Size2D> {
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<SvgPicture> {
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<ScaledImage> 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<dyn ElementExt>) -> bool {
let Some(other) = (other.as_ref() as &dyn Any).downcast_ref::<ScaledImageElement>() 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<dyn Any>)> {
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##"<svg xmlns="http://www.w3.org/2000/svg" width="88" height="20"><rect width="88" height="20" fill="#007ec6"/><text x="10" y="14" font-family="Verdana" font-size="11">v3.0.1</text></svg>"##;
#[test]
fn sniffs_svg_documents() {
assert!(looks_like_svg(BADGE.as_bytes()));
assert!(looks_like_svg(
b"\xEF\xBB\xBF <?xml version=\"1.0\"?>\n<svg width=\"1\" height=\"1\"/>"
));
assert!(!looks_like_svg(b"\x89PNG\r\n\x1a\n"));
assert!(!looks_like_svg(b"<html><body>not found</body></html>"));
}
#[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#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 10" width="100%"/>"#,
)
.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::<ScaledImageElement>()
.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##"<svg xmlns="http://www.w3.org/2000/svg" width="88" height="20"><rect width="39.5" height="20" fill="#555555"/><rect x="39.5" width="48.5" height="20" fill="#007ec6"/></svg>"##,
)
.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##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="#555555"/><image x="5" y="3" width="14" height="14" href="{href}"/></svg>"##
);
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##"<svg fill="#5865F2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h24v24H0z"/></svg>"##;
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##"<svg xmlns="http://www.w3.org/2000/svg" width="3200" height="100"><rect width="1600" height="100" fill="#ff0000"/><rect x="1600" width="1600" height="100" fill="#0000ff"/></svg>"##,
)
.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##"<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><defs><clipPath id="r"><rect width="1" height="1"/></clipPath></defs><rect width="24" height="24" fill="#00ff00" clip-path="url(#r)"/></svg>"##;
let badge = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="80" height="20"><clipPath id="r"><rect width="80" height="20" rx="3"/></clipPath><g clip-path="url(#r)"><rect width="80" height="20" fill="#555555"/></g><image x="5" y="3" width="14" height="14" href="{}"/></svg>"##,
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));
}
}

View file

@ -6,6 +6,7 @@ use pulldown_cmark::HeadingLevel;
mod image;
mod parse;
mod render;
mod svg_compat;
#[derive(Clone, PartialEq)]
pub struct MarkdownStyle {

View file

@ -0,0 +1,434 @@
//! Rewrites the parts of an svg Skia's svg module can't read.
//!
//! shields.io embeds badge logos as `<image href="data:...">`. Skia only reads
//! the SVG 1.1 `xlink:href`, and its `<image>` only decodes bitmaps, so svg logos
//! (`logo=discord`, `logo=modrinth`, ...) are inlined as nested `<svg>` 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 `<image>` attributes that position the picture, carried onto the inlined `<svg>`
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<String> {
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 `<image>` 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<String> {
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..];
// `<image ...></image>` has a closing tag the nested svg must swallow
if !tag.self_closing {
let trimmed = rest.trim_start();
*rest = trimmed.strip_prefix("</image>")?;
}
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!("<g transform=\"{transform}\">{out}</g>"),
None => out,
})
}
fn decode_svg_data_url(href: &str) -> Option<String> {
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 + "<svg".len(), &format!(" xmlns:xlink=\"{XLINK_NS}\""));
}
/// Byte offset of the next `<name` start tag, skipping longer names sharing the prefix
fn find_tag(text: &str, name: &str) -> Option<usize> {
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<Self> {
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#"<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></svg>"#;
assert!(matches!(prepare_svg(svg.as_bytes()), Cow::Borrowed(_)));
}
#[test]
fn renames_href_and_declares_xlink_once() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><image x="5" href="data:image/png;base64,AAAA"/><image href="b.png"/></svg>"#;
assert_eq!(
prepared(svg),
r#"<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><image x="5" xlink:href="data:image/png;base64,AAAA"/><image xlink:href="b.png"/></svg>"#
);
}
#[test]
fn keeps_an_existing_xlink_declaration() {
let svg = r#"<svg xmlns:xlink="http://www.w3.org/1999/xlink"><image href="a.png"/></svg>"#;
assert_eq!(
prepared(svg),
r#"<svg xmlns:xlink="http://www.w3.org/1999/xlink"><image xlink:href="a.png"/></svg>"#
);
}
#[test]
fn inlines_an_svg_logo_where_the_image_was() {
let logo = r##"<?xml version="1.0"?><svg fill="#5865F2" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h24v24H0z"/></svg>"##;
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg"><image x="5" y="3" width="14" height="14" href="{}"/><text>Discord</text></svg>"#,
svg_data_url(logo)
);
assert_eq!(
prepared(&svg),
r##"<svg xmlns="http://www.w3.org/2000/svg"><svg fill="#5865F2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" x="5" y="3" width="14" height="14"><path d="M0 0h24v24H0z"/></svg><text>Discord</text></svg>"##
);
}
#[test]
fn swallows_the_closing_tag_of_an_open_image() {
let logo = r#"<svg viewBox="0 0 1 1"/>"#;
let svg = format!(
r#"<svg><image width="2" href="{}"> </image></svg>"#,
svg_data_url(logo)
);
assert_eq!(
prepared(&svg),
r#"<svg><svg viewBox="0 0 1 1" width="2"/></svg>"#
);
}
#[test]
fn malformed_base64_falls_back_to_the_xlink_rename() {
let svg = r#"<svg><image href="data:image/svg+xml;base64,@@@"/></svg>"#;
assert_eq!(
prepared(svg),
r#"<svg xmlns:xlink="http://www.w3.org/1999/xlink"><image xlink:href="data:image/svg+xml;base64,@@@"/></svg>"#
);
}
#[test]
fn does_not_inline_a_logo_that_nests_another_svg() {
let inner = svg_data_url(r#"<svg viewBox="0 0 1 1"/>"#);
let logo = format!(r#"<svg viewBox="0 0 1 1"><image href="{inner}"/></svg>"#);
let svg = format!(r#"<svg><image href="{}"/></svg>"#, svg_data_url(&logo));
assert!(prepared(&svg).contains("<image xlink:href=\"data:image/svg+xml;base64,"));
}
#[test]
fn prefixes_the_ids_of_an_inlined_logo() {
let logo = r##"<svg viewBox="0 0 1 1"><clipPath id="r"><rect width="1" height="1"/></clipPath><linearGradient id='s'/><rect grid="r" clip-path="url(#r)" fill="url('#s')"/><use href="#r"/><use xlink:href="#s"/><rect fill="url(#rr)"/></svg>"##;
let svg = format!(r#"<svg><image href="{}"/></svg>"#, svg_data_url(logo));
assert_eq!(
prepared(&svg),
r##"<svg><svg viewBox="0 0 1 1"><clipPath id="logo0-r"><rect width="1" height="1"/></clipPath><linearGradient id='logo0-s'/><rect grid="r" clip-path="url(#logo0-r)" fill="url('#logo0-s')"/><use href="#logo0-r"/><use xlink:href="#logo0-s"/><rect fill="url(#rr)"/></svg></svg>"##
);
}
#[test]
fn each_inlined_logo_gets_its_own_prefix() {
let logo = svg_data_url(r#"<svg><g id="a"/></svg>"#);
let svg = format!(r#"<svg><image href="{logo}"/><image href="{logo}"/></svg>"#);
assert_eq!(
prepared(&svg),
r#"<svg><svg><g id="logo0-a"/></svg><svg><g id="logo1-a"/></svg></svg>"#
);
}
#[test]
fn ignores_tags_that_only_share_the_prefix() {
let svg = r#"<svg><imageset href="a"/></svg>"#;
assert!(matches!(prepare_svg(svg.as_bytes()), Cow::Borrowed(_)));
}
#[test]
fn unterminated_tag_is_left_alone() {
let svg = r#"<svg><image href="a.png"#;
assert!(matches!(prepare_svg(svg.as_bytes()), Cow::Borrowed(_)));
}
}