fix: fixed shield.io badges not rendering properly (#953)
* fix: fixed shield.io badges not rendering properly * changeset
This commit is contained in:
parent
61d24b688e
commit
c6361c7266
6 changed files with 752 additions and 28 deletions
5
.changeset/shield.io-badges-in-markdown.md
Normal file
5
.changeset/shield.io-badges-in-markdown.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
default: patch
|
||||
---
|
||||
|
||||
# Fixed shield.io badges not rendering properly in markdown viewers
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -4918,6 +4918,7 @@ version = "2.7.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"base64 0.23.1",
|
||||
"bytes 1.12.1",
|
||||
"cargo-packager-updater",
|
||||
"chrono",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use pulldown_cmark::HeadingLevel;
|
|||
mod image;
|
||||
mod parse;
|
||||
mod render;
|
||||
mod svg_compat;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct MarkdownStyle {
|
||||
|
|
|
|||
434
packages/oneclient_app/src/components/markdown/svg_compat.rs
Normal file
434
packages/oneclient_app/src/components/markdown/svg_compat.rs
Normal 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(_)));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue