PNRxA commited on
Commit
9052d8e
·
1 Parent(s): 4f091a3

fix: pills always render as a horizontal lozenge + a bit more top/bottom padding

Browse files

- A pill is a horizontal icon/label-left, value-right lozenge; vertical
stacked pills look wrong, so pill shape now forces horizontal layout for
every image type (logos/backdrops/episodes default to vertical style).
- Give horizontal pills a little extra height for top/bottom breathing room.

README.md CHANGED
@@ -128,7 +128,7 @@ GET /{api_key}/isValid
128
  - `?badge_style={h|v|d}`: badge layout — `h` (horizontal), `v` (vertical), `d` (default)
129
  - `?label_style={t|i|o}`: label rendering — `t` (text), `i` (icon), `o` (official provider logos)
130
  - `?badge_size={xs|s|m|l|xl}`: badge scale — extra-small, small, medium, large, extra-large
131
- - `?badge_shape={r|p}`: badge corner shape — `r` (rounded, default), `p` (pill, fully rounded ends)
132
  - `?badge_background={d|k|t|n}`: badge background — `d` (default: source-coloured label + dark value), `k` (dark: uniformly dark), `t` (transparent: semi-transparent so the artwork shows through), `n` (none: no background, label/value drawn directly on the image with a drop shadow)
133
  - `?image_source={t|f}`: image source — `t` (TMDB, default), `f` (Fanart.tv). Applies to all image types. The non-selected source is used as fallback
134
  - `?badge_direction={d|h|v}`: badge stacking direction — `d` (default), `h` (horizontal), `v` (vertical). Applies to poster, backdrop, and episode endpoints
 
128
  - `?badge_style={h|v|d}`: badge layout — `h` (horizontal), `v` (vertical), `d` (default)
129
  - `?label_style={t|i|o}`: label rendering — `t` (text), `i` (icon), `o` (official provider logos)
130
  - `?badge_size={xs|s|m|l|xl}`: badge scale — extra-small, small, medium, large, extra-large
131
+ - `?badge_shape={r|p}`: badge corner shape — `r` (rounded, default), `p` (pill, fully rounded ends). Pills always render as a horizontal icon/label-left, value-right lozenge, even on image types whose default style is vertical (logos, backdrops, episodes)
132
  - `?badge_background={d|k|t|n}`: badge background — `d` (default: source-coloured label + dark value), `k` (dark: uniformly dark), `t` (transparent: semi-transparent so the artwork shows through), `n` (none: no background, label/value drawn directly on the image with a drop shadow)
133
  - `?image_source={t|f}`: image source — `t` (TMDB, default), `f` (Fanart.tv). Applies to all image types. The non-selected source is used as fallback
134
  - `?badge_direction={d|h|v}`: badge stacking direction — `d` (default), `h` (horizontal), `v` (vertical). Applies to poster, backdrop, and episode endpoints
api/src/image/badge.rs CHANGED
@@ -105,6 +105,9 @@ const BASE_BADGE_RADIUS: u32 = 10;
105
  /// crowd the fully-rounded caps. Applied to the long-axis ends only (left/right
106
  /// for horizontal badges, top/bottom for vertical).
107
  const BASE_PILL_PADDING: u32 = 10;
 
 
 
108
  const BASE_FONT_SIZE: f32 = 34.0;
109
  const BASE_LABEL_FONT_SIZE: f32 = 26.0;
110
  const BASE_ICON_HEIGHT: u32 = 48;
@@ -164,6 +167,7 @@ struct ScaledDims {
164
  badge_value_padding_h: u32,
165
  badge_radius: u32,
166
  pill_padding: u32,
 
167
  icon_height: u32,
168
  }
169
 
@@ -176,6 +180,7 @@ impl ScaledDims {
176
  badge_value_padding_h: (BASE_BADGE_VALUE_PADDING_H as f32 * badge_scale).round() as u32,
177
  badge_radius: (BASE_BADGE_RADIUS as f32 * badge_scale).round() as u32,
178
  pill_padding: (BASE_PILL_PADDING as f32 * badge_scale).round() as u32,
 
179
  icon_height: (BASE_ICON_HEIGHT as f32 * badge_scale).round() as u32,
180
  }
181
  }
@@ -289,26 +294,28 @@ fn render_badge_inner(
289
  let value_width = uniform_value_width.unwrap_or_else(|| text_width(value, &fonts.scaled));
290
  let label_pad = if use_icon { dims.badge_padding_h } else { dims.text_label_padding_h };
291
  // Pills get extra padding at their rounded left/right ends so the content
292
- // doesn't crowd the fully-rounded caps. Rounded badges add nothing.
293
- let pill_pad = match appearance.shape {
294
- BadgeShape::Pill => dims.pill_padding,
295
- BadgeShape::Rounded => 0,
 
296
  };
 
297
 
298
  let value_x = pill_pad + label_width + label_pad * 2;
299
  let total_width = value_x + value_width + dims.badge_value_padding_h + dims.badge_value_padding_h / 2 + 2 + pill_pad;
300
- let mut img = RgbaImage::new(total_width, dims.badge_height);
301
 
302
  // Draw the label + value backgrounds (unless the badge has no background).
303
  // Both sections are filled as plain rects, then the four outer corners are
304
  // rounded — leaving the inner join square for a clean seam at any radius.
305
  if let Some((label_bg, value_bg)) = section_colors(appearance.background, label_style, &badge.source) {
306
- draw_filled_rect_mut(&mut img, Rect::at(0, 0).of_size(value_x, dims.badge_height), label_bg);
307
- draw_filled_rect_mut(&mut img, Rect::at(value_x as i32, 0).of_size(total_width - value_x, dims.badge_height), value_bg);
308
- round_corners(&mut img, corner_radius(appearance.shape, dims.badge_height, dims.badge_radius));
309
  }
310
 
311
- let shadow = shadow_offset(appearance.background, dims.badge_height);
312
 
313
  // Draw label (icon or text, centered within uniform label area)
314
  if use_icon {
@@ -319,12 +326,12 @@ fn render_badge_inner(
319
  imageops::resize(icon, icon_w, icon_h, imageops::FilterType::Lanczos3)
320
  };
321
  let ix = pill_pad + label_pad + (label_width.saturating_sub(icon_w)) / 2;
322
- let iy = (dims.badge_height.saturating_sub(icon_h)) / 2;
323
  overlay_icon_shadowed(&mut img, &scaled_icon, ix as i64, iy as i64, shadow);
324
  } else {
325
  let actual_label_width = text_width(label, &fonts.label_scaled);
326
  let label_x = pill_pad + label_pad + (label_width.saturating_sub(actual_label_width)) / 2;
327
- let label_y = (dims.badge_height as i32 - fonts.label_scale.x as i32) / 2;
328
  draw_text_shadowed(
329
  &mut img,
330
  Rgba([255, 255, 255, 255]),
@@ -340,7 +347,7 @@ fn render_badge_inner(
340
  // Draw value text (centered within uniform value area)
341
  let actual_value_width = text_width(value, &fonts.scaled);
342
  let value_text_x = value_x + dims.badge_value_padding_h + (value_width.saturating_sub(actual_value_width)) / 2;
343
- let value_y = (dims.badge_height as i32 - fonts.scale.x as i32) / 2;
344
  draw_text_shadowed(
345
  &mut img,
346
  Rgba([255, 255, 255, 255]),
@@ -687,13 +694,17 @@ mod tests {
687
  for shape in ALL_SHAPES {
688
  for background in ALL_BACKGROUNDS {
689
  let appearance = BadgeAppearance { shape, background };
690
- // Horizontal: appearance must not change the badge height.
 
 
 
 
691
  let h = render_badge_appearance(&badge, &font, LabelStyle::Text, appearance);
692
- assert_eq!(h.height(), BASE_BADGE_HEIGHT, "h height for {shape:?}/{background:?}");
693
  assert!(h.width() > 0, "h width for {shape:?}/{background:?}");
694
  // Icon labels exercise the icon/shadow path.
695
  let hi = render_badge_appearance(&badge, &font, LabelStyle::Official, appearance);
696
- assert_eq!(hi.height(), BASE_BADGE_HEIGHT, "h-icon height for {shape:?}/{background:?}");
697
  // Vertical: appearance must not change the badge width.
698
  let v = render_vertical_badge(&badge, &font, LabelStyle::Text, appearance, 1.0);
699
  assert_eq!(v.width(), BASE_VERT_BADGE_WIDTH, "v width for {shape:?}/{background:?}");
 
105
  /// crowd the fully-rounded caps. Applied to the long-axis ends only (left/right
106
  /// for horizontal badges, top/bottom for vertical).
107
  const BASE_PILL_PADDING: u32 = 10;
108
+ /// Extra height added to horizontal pill badges for a little more top/bottom
109
+ /// breathing room around the content.
110
+ const BASE_PILL_PADDING_V: u32 = 6;
111
  const BASE_FONT_SIZE: f32 = 34.0;
112
  const BASE_LABEL_FONT_SIZE: f32 = 26.0;
113
  const BASE_ICON_HEIGHT: u32 = 48;
 
167
  badge_value_padding_h: u32,
168
  badge_radius: u32,
169
  pill_padding: u32,
170
+ pill_padding_v: u32,
171
  icon_height: u32,
172
  }
173
 
 
180
  badge_value_padding_h: (BASE_BADGE_VALUE_PADDING_H as f32 * badge_scale).round() as u32,
181
  badge_radius: (BASE_BADGE_RADIUS as f32 * badge_scale).round() as u32,
182
  pill_padding: (BASE_PILL_PADDING as f32 * badge_scale).round() as u32,
183
+ pill_padding_v: (BASE_PILL_PADDING_V as f32 * badge_scale).round() as u32,
184
  icon_height: (BASE_ICON_HEIGHT as f32 * badge_scale).round() as u32,
185
  }
186
  }
 
294
  let value_width = uniform_value_width.unwrap_or_else(|| text_width(value, &fonts.scaled));
295
  let label_pad = if use_icon { dims.badge_padding_h } else { dims.text_label_padding_h };
296
  // Pills get extra padding at their rounded left/right ends so the content
297
+ // doesn't crowd the fully-rounded caps, plus a little extra height for
298
+ // top/bottom breathing room. Rounded badges add nothing.
299
+ let (pill_pad, pill_pad_v) = match appearance.shape {
300
+ BadgeShape::Pill => (dims.pill_padding, dims.pill_padding_v),
301
+ BadgeShape::Rounded => (0, 0),
302
  };
303
+ let badge_h = dims.badge_height + pill_pad_v;
304
 
305
  let value_x = pill_pad + label_width + label_pad * 2;
306
  let total_width = value_x + value_width + dims.badge_value_padding_h + dims.badge_value_padding_h / 2 + 2 + pill_pad;
307
+ let mut img = RgbaImage::new(total_width, badge_h);
308
 
309
  // Draw the label + value backgrounds (unless the badge has no background).
310
  // Both sections are filled as plain rects, then the four outer corners are
311
  // rounded — leaving the inner join square for a clean seam at any radius.
312
  if let Some((label_bg, value_bg)) = section_colors(appearance.background, label_style, &badge.source) {
313
+ draw_filled_rect_mut(&mut img, Rect::at(0, 0).of_size(value_x, badge_h), label_bg);
314
+ draw_filled_rect_mut(&mut img, Rect::at(value_x as i32, 0).of_size(total_width - value_x, badge_h), value_bg);
315
+ round_corners(&mut img, corner_radius(appearance.shape, badge_h, dims.badge_radius));
316
  }
317
 
318
+ let shadow = shadow_offset(appearance.background, badge_h);
319
 
320
  // Draw label (icon or text, centered within uniform label area)
321
  if use_icon {
 
326
  imageops::resize(icon, icon_w, icon_h, imageops::FilterType::Lanczos3)
327
  };
328
  let ix = pill_pad + label_pad + (label_width.saturating_sub(icon_w)) / 2;
329
+ let iy = (badge_h.saturating_sub(icon_h)) / 2;
330
  overlay_icon_shadowed(&mut img, &scaled_icon, ix as i64, iy as i64, shadow);
331
  } else {
332
  let actual_label_width = text_width(label, &fonts.label_scaled);
333
  let label_x = pill_pad + label_pad + (label_width.saturating_sub(actual_label_width)) / 2;
334
+ let label_y = (badge_h as i32 - fonts.label_scale.x as i32) / 2;
335
  draw_text_shadowed(
336
  &mut img,
337
  Rgba([255, 255, 255, 255]),
 
347
  // Draw value text (centered within uniform value area)
348
  let actual_value_width = text_width(value, &fonts.scaled);
349
  let value_text_x = value_x + dims.badge_value_padding_h + (value_width.saturating_sub(actual_value_width)) / 2;
350
+ let value_y = (badge_h as i32 - fonts.scale.x as i32) / 2;
351
  draw_text_shadowed(
352
  &mut img,
353
  Rgba([255, 255, 255, 255]),
 
694
  for shape in ALL_SHAPES {
695
  for background in ALL_BACKGROUNDS {
696
  let appearance = BadgeAppearance { shape, background };
697
+ // Horizontal badge height: base, plus the pill's extra vertical padding.
698
+ let expected_h = match shape {
699
+ BadgeShape::Pill => BASE_BADGE_HEIGHT + BASE_PILL_PADDING_V,
700
+ BadgeShape::Rounded => BASE_BADGE_HEIGHT,
701
+ };
702
  let h = render_badge_appearance(&badge, &font, LabelStyle::Text, appearance);
703
+ assert_eq!(h.height(), expected_h, "h height for {shape:?}/{background:?}");
704
  assert!(h.width() > 0, "h width for {shape:?}/{background:?}");
705
  // Icon labels exercise the icon/shadow path.
706
  let hi = render_badge_appearance(&badge, &font, LabelStyle::Official, appearance);
707
+ assert_eq!(hi.height(), expected_h, "h-icon height for {shape:?}/{background:?}");
708
  // Vertical: appearance must not change the badge width.
709
  let v = render_vertical_badge(&badge, &font, LabelStyle::Text, appearance, 1.0);
710
  assert_eq!(v.width(), BASE_VERT_BADGE_WIDTH, "v width for {shape:?}/{background:?}");
api/src/image/generate.rs CHANGED
@@ -278,6 +278,9 @@ pub fn render_poster_sync(
278
  badge_size: BadgeSize,
279
  poster_badge_split: bool,
280
  ) -> Result<Vec<u8>, AppError> {
 
 
 
281
  let base = load_image_with_limits(poster_bytes)?;
282
 
283
  let base = if base.width() != target_width {
@@ -364,6 +367,9 @@ pub fn render_logo_sync(
364
  target_width: u32,
365
  badge_scale: f32,
366
  ) -> Result<Vec<u8>, AppError> {
 
 
 
367
  let base = load_image_with_limits(logo_bytes)?;
368
 
369
  let base = if base.width() != target_width {
@@ -547,6 +553,9 @@ pub fn render_backdrop_sync(
547
  badge_scale: f32,
548
  _badge_size: BadgeSize,
549
  ) -> Result<Vec<u8>, AppError> {
 
 
 
550
  let base = load_image_with_limits(backdrop_bytes)?;
551
 
552
  let base = if base.width() != target_width {
@@ -631,6 +640,9 @@ pub fn render_episode_sync(
631
  badge_size: BadgeSize,
632
  blur: bool,
633
  ) -> Result<Vec<u8>, AppError> {
 
 
 
634
  let base = image::load_from_memory(image_bytes)
635
  .map_err(AppError::Image)?;
636
 
@@ -801,6 +813,32 @@ mod tests {
801
  assert_eq!(&result[..4], &[0x89, b'P', b'N', b'G']);
802
  }
803
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
804
  #[test]
805
  fn render_logo_downscales_wide_image() {
806
  let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
 
278
  badge_size: BadgeSize,
279
  poster_badge_split: bool,
280
  ) -> Result<Vec<u8>, AppError> {
281
+ // A pill is a horizontal lozenge (icon/label left, value right) — never a
282
+ // vertical stacked badge, even when the configured style is vertical.
283
+ let badge_style = if badge_appearance.is_pill() { BadgeStyle::Horizontal } else { badge_style };
284
  let base = load_image_with_limits(poster_bytes)?;
285
 
286
  let base = if base.width() != target_width {
 
367
  target_width: u32,
368
  badge_scale: f32,
369
  ) -> Result<Vec<u8>, AppError> {
370
+ // A pill is a horizontal lozenge (icon/label left, value right) — never a
371
+ // vertical stacked badge, even when the configured style is vertical.
372
+ let badge_style = if badge_appearance.is_pill() { BadgeStyle::Horizontal } else { badge_style };
373
  let base = load_image_with_limits(logo_bytes)?;
374
 
375
  let base = if base.width() != target_width {
 
553
  badge_scale: f32,
554
  _badge_size: BadgeSize,
555
  ) -> Result<Vec<u8>, AppError> {
556
+ // A pill is a horizontal lozenge (icon/label left, value right) — never a
557
+ // vertical stacked badge, even when the configured style is vertical.
558
+ let badge_style = if badge_appearance.is_pill() { BadgeStyle::Horizontal } else { badge_style };
559
  let base = load_image_with_limits(backdrop_bytes)?;
560
 
561
  let base = if base.width() != target_width {
 
640
  badge_size: BadgeSize,
641
  blur: bool,
642
  ) -> Result<Vec<u8>, AppError> {
643
+ // A pill is a horizontal lozenge (icon/label left, value right) — never a
644
+ // vertical stacked badge, even when the configured style is vertical.
645
+ let badge_style = if badge_appearance.is_pill() { BadgeStyle::Horizontal } else { badge_style };
646
  let base = image::load_from_memory(image_bytes)
647
  .map_err(AppError::Image)?;
648
 
 
813
  assert_eq!(&result[..4], &[0x89, b'P', b'N', b'G']);
814
  }
815
 
816
+ #[test]
817
+ fn pill_renders_horizontally_regardless_of_style() {
818
+ use crate::services::db::{BadgeBackground, BadgeShape};
819
+ use crate::services::ratings::{RatingBadge, RatingSource};
820
+
821
+ let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
822
+ let png = test_png(400, 100);
823
+ let badges = vec![
824
+ RatingBadge { source: RatingSource::Imdb, value: "8.5".to_string() },
825
+ RatingBadge { source: RatingSource::Tmdb, value: "85%".to_string() },
826
+ ];
827
+ let pill = BadgeAppearance { shape: BadgeShape::Pill, background: BadgeBackground::Default };
828
+ let rounded = BadgeAppearance::default();
829
+
830
+ // A pill ignores the vertical style and always renders horizontally, so
831
+ // vertical+pill and horizontal+pill produce identical output.
832
+ let v_pill = render_logo_sync(&png, &badges, &font, BadgeStyle::Vertical, LabelStyle::Official, pill, 500, 1.0).unwrap();
833
+ let h_pill = render_logo_sync(&png, &badges, &font, BadgeStyle::Horizontal, LabelStyle::Official, pill, 500, 1.0).unwrap();
834
+ assert_eq!(v_pill, h_pill, "pill should render horizontally even when the style is vertical");
835
+
836
+ // Rounded badges still honour the style, so the two layouts differ.
837
+ let v_round = render_logo_sync(&png, &badges, &font, BadgeStyle::Vertical, LabelStyle::Official, rounded, 500, 1.0).unwrap();
838
+ let h_round = render_logo_sync(&png, &badges, &font, BadgeStyle::Horizontal, LabelStyle::Official, rounded, 500, 1.0).unwrap();
839
+ assert_ne!(v_round, h_round, "rounded badges should still differ between vertical and horizontal styles");
840
+ }
841
+
842
  #[test]
843
  fn render_logo_downscales_wide_image() {
844
  let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
api/src/services/db.rs CHANGED
@@ -290,6 +290,15 @@ impl Default for BadgeAppearance {
290
  }
291
  }
292
 
 
 
 
 
 
 
 
 
 
293
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
294
  pub enum ImageSource {
295
  Tmdb,
 
290
  }
291
  }
292
 
293
+ impl BadgeAppearance {
294
+ /// A pill is a horizontal lozenge (icon/label left, value right). Vertical
295
+ /// stacked pills look wrong, so callers render pills horizontally regardless
296
+ /// of the configured badge style.
297
+ pub fn is_pill(self) -> bool {
298
+ matches!(self.shape, BadgeShape::Pill)
299
+ }
300
+ }
301
+
302
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
303
  pub enum ImageSource {
304
  Tmdb,