diff --git a/src/graphics/mod.rs b/src/graphics/mod.rs index c8fa678..20f3524 100644 --- a/src/graphics/mod.rs +++ b/src/graphics/mod.rs @@ -12,6 +12,12 @@ mod test_data; pub fn convert_svg_to_png( svg_bytes: Option>, badge_style: BadgeStyle, + pixel_ratio: u8, ) -> Result, SvgToPngConversionError> { - raster::convert_svg_to_png(svg_bytes, badge_style, LetterSpacingSvgProcessor::new()) + raster::convert_svg_to_png( + svg_bytes, + badge_style, + LetterSpacingSvgProcessor::new(), + pixel_ratio, + ) } diff --git a/src/graphics/raster.rs b/src/graphics/raster.rs index d3d331b..987a976 100644 --- a/src/graphics/raster.rs +++ b/src/graphics/raster.rs @@ -23,14 +23,14 @@ pub enum SvgToPngConversionError { } // Returns tuple with (width, height) -fn get_dimensions(renderer: &CairoRenderer) -> (f64, f64) { +fn get_dimensions(renderer: &CairoRenderer, pixel_ratio: u8) -> (f64, f64) { let IntrinsicDimensions { width: width_dim, height: height_dim, .. } = renderer.intrinsic_dimensions(); - let width = width_dim.map_or(0f64, |w| w.length); - let height = height_dim.map_or(0f64, |h| h.length); + let width = width_dim.map_or(0f64, |w| w.length * pixel_ratio as f64); + let height = height_dim.map_or(0f64, |h| h.length * pixel_ratio as f64); (width, height) } @@ -54,6 +54,7 @@ pub(super) fn convert_svg_to_png( svg_bytes: Option>, badge_style: BadgeStyle, svg_processor: S, + pixel_ratio: u8, ) -> Result, SvgToPngConversionError> { use SvgToPngConversionError::*; let stream = @@ -63,7 +64,7 @@ pub(super) fn convert_svg_to_png( .map_err(|_| SvgHandleCreationFailure)?; let renderer = CairoRenderer::new(&handle); - let (width, height) = get_dimensions(&renderer); + let (width, height) = get_dimensions(&renderer, pixel_ratio); let surface = ImageSurface::create(cairo::Format::ARgb32, width as i32, height as i32) .map_err(|_| ImageSurfaceCreationFailure)?; diff --git a/src/graphics/raster_test.rs b/src/graphics/raster_test.rs index 66e5047..55354f9 100644 --- a/src/graphics/raster_test.rs +++ b/src/graphics/raster_test.rs @@ -18,27 +18,27 @@ mod get_dimensions_tests { height: None, vbox: None, }); - let (width, height) = get_dimensions(&mock_renderer); + let (width, height) = get_dimensions(&mock_renderer, 1); assert_eq!(width, 0f64); assert_eq!(height, 0f64); } #[test] fn provides_correct_width() { - let exp_width = 70.33f64; + let input_width = 70.33f64; let mut mock_renderer = MockCairoRenderer::default(); mock_renderer .expect_intrinsic_dimensions() .return_const(IntrinsicDimensions { width: Some(Length { - length: exp_width, + length: input_width, unit: LengthUnit::Px, }), height: None, vbox: None, }); - let (width, height) = get_dimensions(&mock_renderer); - assert_eq!(width, exp_width); + let (width, height) = get_dimensions(&mock_renderer, 2); + assert_eq!(width, input_width * 2f64); assert_eq!(height, 0f64); } @@ -56,7 +56,7 @@ mod get_dimensions_tests { }), vbox: None, }); - let (width, height) = get_dimensions(&mock_renderer); + let (width, height) = get_dimensions(&mock_renderer, 1); assert_eq!(width, 0f64); assert_eq!(height, exp_height); } diff --git a/src/graphics/svg.rs b/src/graphics/svg.rs index fa1feb9..180d862 100644 --- a/src/graphics/svg.rs +++ b/src/graphics/svg.rs @@ -32,7 +32,7 @@ pub static INVALID_SVG: &[u8] = br##" lazy_static! { pub static ref INVALID_SVG_BADGE: Vec = { - match convert_svg_to_png(None, BadgeStyle::Unspecified) { + match convert_svg_to_png(None, BadgeStyle::Unspecified, 1) { Ok(png) => png, Err(_) => { // This happens during server initialization, so if it fails diff --git a/src/server.rs b/src/server.rs index f3b07d0..6541cf0 100644 --- a/src/server.rs +++ b/src/server.rs @@ -21,6 +21,9 @@ const FORWARDING_REQUEST_HEADERS: &[&str] = &["if-modified-since", "if-unmodified-since", "if-none-match"]; const FORWARDING_RESPONSE_HEADERS: &[&str] = &["date", "cache-control", "expires", "last-modified"]; +const MIN_PIXEL_RATIO: u8 = 2; +const MAX_PIXEL_RATIO: u8 = 4; + fn get_badge_style(req: &Request) -> BadgeStyle { req.uri().query().map_or(BadgeStyle::Unspecified, |q| { form_urlencoded::parse(q.as_bytes()) @@ -41,16 +44,47 @@ fn get_badge_style(req: &Request) -> BadgeStyle { }) } +fn extract_pixel_ratio(query: Option<&str>) -> (u8, Option) { + // If a valid `pixel_ratio` param is present, return it along with the query + // string with `pixel_ratio` removed (so it's not forwarded upstream). + // Otherwise return the default pixel ratio of 1 along with the remaining + // query string. The returned query is `None` when nothing is left to forward. + query.map_or((1, None), |q| { + let mut pixel_ratio = 1; + let mut serializer = form_urlencoded::Serializer::new(String::new()); + let mut has_other = false; + for (k, v) in form_urlencoded::parse(q.as_bytes()) { + if k == "pixel_ratio" { + if let Ok(pr) = v.parse::() { + if (MIN_PIXEL_RATIO..=MAX_PIXEL_RATIO).contains(&pr) { + pixel_ratio = pr; + } + } + } else { + serializer.append_pair(&k, &v); + has_other = true; + } + } + let new_query = if has_other { + Some(serializer.finish()) + } else { + None + }; + (pixel_ratio, new_query) + }) +} + async fn get_svg( mut req: Request, http_client: Client, svg_base_url: &'static str, -) -> Result<(HeaderMap, u16, Vec, BadgeStyle), ()> { - let svg_url = if let Some(path_and_query) = req.uri().path_and_query() { - let suffix = path_and_query.as_str().replace(".png", ".svg"); - Cow::Owned(format!("{}{}", svg_base_url, suffix)) - } else { - Cow::Borrowed(svg_base_url) +) -> Result<(HeaderMap, u16, Vec, BadgeStyle, u8), ()> { + let (pixel_ratio, forwarded_query) = extract_pixel_ratio(req.uri().query()); + + let svg_path = req.uri().path().replace(".png", ".svg"); + let svg_url = match forwarded_query { + Some(query) => format!("{}{}?{}", svg_base_url, svg_path, query), + None => format!("{}{}", svg_base_url, svg_path), }; let mut headers = HeaderMap::new(); @@ -83,7 +117,13 @@ async fn get_svg( return Err(()); } }; - Ok((headers, status, bytes.to_vec(), get_badge_style(&req))) + Ok(( + headers, + status, + bytes.to_vec(), + get_badge_style(&req), + pixel_ratio, + )) } Err(e) => { eprintln!("Failed to fetch SVG data. Details: {:?}", e); @@ -99,9 +139,11 @@ async fn rasterize( invalid_svg_badge: &'static [u8], include_body: bool, ) -> Result, hyper::http::Error> { - let (mut svg_res_headers, svg_status, svg_bytes, badge_style) = + let (mut svg_res_headers, svg_status, svg_bytes, badge_style, pixel_ratio) = match get_svg(req, http_client, svg_base_url).await { - Ok((headers, status, data, style)) => (headers, status, data, style), + Ok((headers, status, data, style, pixel_ratio)) => { + (headers, status, data, style, pixel_ratio) + } Err(_) => { return Response::builder() .status(502) @@ -124,13 +166,14 @@ async fn rasterize( return res.status(304).body(Body::empty()); } - let (png_stream, res_status) = match convert_svg_to_png(Some(svg_bytes), badge_style) { - Ok(png_stream) => (Cow::Owned(png_stream), 200), - Err(e) => { - eprintln!("Failed to convert SVG to PNG. Details: {:?}", e); - (Cow::Borrowed(invalid_svg_badge), 502) - } - }; + let (png_stream, res_status) = + match convert_svg_to_png(Some(svg_bytes), badge_style, pixel_ratio) { + Ok(png_stream) => (Cow::Owned(png_stream), 200), + Err(e) => { + eprintln!("Failed to convert SVG to PNG. Details: {:?}", e); + (Cow::Borrowed(invalid_svg_badge), 502) + } + }; let body = Body::from(png_stream); @@ -191,3 +234,7 @@ pub(crate) async fn start_server( .await?; Ok(()) } + +#[cfg(test)] +#[path = "server_test.rs"] +mod tests; diff --git a/src/server_test.rs b/src/server_test.rs new file mode 100644 index 0000000..d4e6960 --- /dev/null +++ b/src/server_test.rs @@ -0,0 +1,72 @@ +#[cfg(test)] +mod extract_pixel_ratio_tests { + use crate::server::extract_pixel_ratio; + + #[test] + fn none() { + assert_eq!(extract_pixel_ratio(None), (1, None)); + } + + #[test] + fn empty() { + assert_eq!(extract_pixel_ratio(Some("")), (1, None)); + } + + #[test] + fn different_param() { + assert_eq!( + extract_pixel_ratio(Some("foo=bar")), + (1, Some("foo=bar".to_string())) + ); + } + + #[test] + fn empty_param() { + assert_eq!(extract_pixel_ratio(Some("pixel_ratio=")), (1, None)); + } + + #[test] + fn not_an_int() { + assert_eq!(extract_pixel_ratio(Some("pixel_ratio=foo")), (1, None)); + } + + #[test] + fn not_a_whole_number() { + // A decimal parses neither as u8 nor to a valid ratio, so it falls back + // to 1 (and is still stripped from the forwarded query). + assert_eq!(extract_pixel_ratio(Some("pixel_ratio=2.5")), (1, None)); + } + + #[test] + fn normal_case() { + assert_eq!(extract_pixel_ratio(Some("pixel_ratio=2")), (2, None)); + } + + #[test] + fn over_max() { + // Out-of-range ratios fall back to 1 rather than clamping to MAX_PIXEL_RATIO. + assert_eq!(extract_pixel_ratio(Some("pixel_ratio=5")), (1, None)); + } + + #[test] + fn under_min() { + // Out-of-range ratios fall back to 1 rather than clamping to MIN_PIXEL_RATIO. + assert_eq!(extract_pixel_ratio(Some("pixel_ratio=0")), (1, None)); + } + + #[test] + fn strips_pixel_ratio_keeps_others() { + assert_eq!( + extract_pixel_ratio(Some("style=flat&pixel_ratio=3&label=hi")), + (3, Some("style=flat&label=hi".to_string())) + ); + } + + #[test] + fn invalid_pixel_ratio_is_still_stripped() { + assert_eq!( + extract_pixel_ratio(Some("pixel_ratio=99&style=flat")), + (1, Some("style=flat".to_string())) + ); + } +}