1use cranpose_ui_graphics::{
2 Brush, Color, CornerRadii, DrawPrimitive, DrawScope, DrawScopeDefault, DrawStyle, Path, Point,
3 Rect, Size, Stroke, Trapezoid,
4};
5
6use crate::{
7 graph::{
8 DrawPrimitiveNode, LayerNode, PrimitiveEntry, PrimitiveNode, PrimitivePhase,
9 ProjectiveTransform, RenderGraph, RenderNode,
10 },
11 image_compare::{
12 image_difference_stats, normalize_rgba_region, pixel_difference, sample_pixel,
13 },
14};
15
16mod draw_groups;
17mod draw_text_brush;
18mod text_fixtures;
19
20use text_fixtures::{
21 assert_clipped_text_frame, assert_multiline_text_frame, assert_translated_plain_text_frames,
22 assert_translated_text_decorations_frames, clipped_text_fixture, multiline_text_fixture,
23 translated_plain_text_fixture, translated_text_decorations_fixture,
24};
25
26const BACKGROUND_COLOR: Color = Color(18.0 / 255.0, 18.0 / 255.0, 24.0 / 255.0, 1.0);
27const FOREGROUND_COLOR: Color = Color::WHITE;
28const PIXEL_DIFFERENCE_TOLERANCE: u32 = 24;
29
30const TRANSLATED_SUBTREE_BUDGET: NormalizedDifferenceBudget = NormalizedDifferenceBudget {
31 max_differing_pixels: 270,
32 max_pixel_difference: 360,
33};
34
35#[derive(Clone)]
37pub struct RenderFixture {
38 pub width: u32,
40 pub height: u32,
42 pub graph: RenderGraph,
44 pub normalized_rect: Option<Rect>,
47}
48
49#[derive(Clone, Debug)]
51pub struct RenderedFrame {
52 pub width: u32,
54 pub height: u32,
56 pub pixels: Vec<u8>,
58 pub normalized_rect: Option<Rect>,
60}
61
62#[derive(Clone, Copy)]
63struct NormalizedDifferenceBudget {
64 max_differing_pixels: u32,
65 max_pixel_difference: u32,
66}
67
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub enum SharedRenderCase {
71 RoundedRect,
73 PrimitiveClip,
75 TranslatedSubtree,
77 TranslatedPlainText,
79 TranslatedTextDecorations,
81 MultilineText,
83 ClippedText,
85 StrokedRoundRect,
87 AnnularSector,
89 FilledPath,
91 DrawScopeGroups,
95 PaintedDrawGroups,
98 GradientDrawText,
100}
101
102pub const ALL_SHARED_RENDER_CASES: [SharedRenderCase; 13] = [
104 SharedRenderCase::RoundedRect,
105 SharedRenderCase::PrimitiveClip,
106 SharedRenderCase::TranslatedSubtree,
107 SharedRenderCase::TranslatedPlainText,
108 SharedRenderCase::TranslatedTextDecorations,
109 SharedRenderCase::MultilineText,
110 SharedRenderCase::ClippedText,
111 SharedRenderCase::StrokedRoundRect,
112 SharedRenderCase::AnnularSector,
113 SharedRenderCase::FilledPath,
114 SharedRenderCase::DrawScopeGroups,
115 SharedRenderCase::PaintedDrawGroups,
116 SharedRenderCase::GradientDrawText,
117];
118
119impl SharedRenderCase {
120 pub fn name(self) -> &'static str {
122 match self {
123 Self::RoundedRect => "rounded_rect",
124 Self::PrimitiveClip => "primitive_clip",
125 Self::TranslatedSubtree => "translated_subtree",
126 Self::TranslatedPlainText => "translated_plain_text",
127 Self::TranslatedTextDecorations => "translated_text_decorations",
128 Self::MultilineText => "multiline_text",
129 Self::ClippedText => "clipped_text",
130 Self::StrokedRoundRect => "stroked_round_rect",
131 Self::AnnularSector => "annular_sector",
132 Self::FilledPath => "filled_path",
133 Self::DrawScopeGroups => "draw_scope_groups",
134 Self::PaintedDrawGroups => "painted_draw_groups",
135 Self::GradientDrawText => "gradient_draw_text",
136 }
137 }
138
139 pub fn fixtures(self) -> Vec<RenderFixture> {
142 cranpose_ui::AppContext::new().enter(|| {
145 cranpose_ui::text::set_text_fonts(
146 crate::text_fonts::TextFonts::from_fonts_or_default(&[]).resolver(),
147 );
148 self.fixtures_in_app()
149 })
150 }
151
152 fn fixtures_in_app(self) -> Vec<RenderFixture> {
153 match self {
154 Self::RoundedRect => vec![rounded_rect_fixture()],
155 Self::PrimitiveClip => vec![primitive_clip_fixture()],
156 Self::TranslatedSubtree => vec![
157 translated_subtree_fixture(12.3, 14.7),
158 translated_subtree_fixture(32.6, 26.2),
159 ],
160 Self::TranslatedPlainText => vec![
161 translated_plain_text_fixture(14.3, 18.6),
162 translated_plain_text_fixture(36.4, 30.1),
163 ],
164 Self::TranslatedTextDecorations => vec![
165 translated_text_decorations_fixture(14.3, 18.6),
166 translated_text_decorations_fixture(36.4, 30.1),
167 ],
168 Self::MultilineText => vec![multiline_text_fixture()],
169 Self::ClippedText => vec![clipped_text_fixture()],
170 Self::StrokedRoundRect => vec![stroked_round_rect_fixture()],
171 Self::AnnularSector => vec![annular_sector_fixture()],
172 Self::FilledPath => vec![filled_path_fixture()],
173 Self::DrawScopeGroups => vec![draw_groups::draw_groups_fixture()],
174 Self::PaintedDrawGroups => vec![draw_groups::painted_groups_fixture()],
175 Self::GradientDrawText => vec![draw_text_brush::gradient_text_fixture()],
176 }
177 }
178
179 pub fn assert_frames(self, frames: &[RenderedFrame]) {
182 match self {
183 Self::RoundedRect => {
184 let [frame] = frames else {
185 panic!("rounded_rect expects exactly one rendered frame");
186 };
187 assert_rounded_rect_frame(&frame.pixels, frame.width, frame.height);
188 }
189 Self::PrimitiveClip => {
190 let [frame] = frames else {
191 panic!("primitive_clip expects exactly one rendered frame");
192 };
193 assert_primitive_clip_frame(&frame.pixels, frame.width, frame.height);
194 }
195 Self::TranslatedSubtree => {
196 assert_translated_subtree_frames(frames);
197 }
198 Self::TranslatedPlainText => {
199 assert_translated_plain_text_frames(frames);
200 }
201 Self::TranslatedTextDecorations => {
202 assert_translated_text_decorations_frames(frames);
203 }
204 Self::MultilineText => {
205 let [frame] = frames else {
206 panic!("multiline_text expects exactly one rendered frame");
207 };
208 assert_multiline_text_frame(&frame.pixels, frame.width, frame.height);
209 }
210 Self::StrokedRoundRect => {
211 let [frame] = frames else {
212 panic!("stroked_round_rect expects exactly one rendered frame");
213 };
214 assert_stroked_round_rect_frame(&frame.pixels, frame.width, frame.height);
215 }
216 Self::AnnularSector => {
217 let [frame] = frames else {
218 panic!("annular_sector expects exactly one rendered frame");
219 };
220 assert_annular_sector_frame(&frame.pixels, frame.width, frame.height);
221 }
222 Self::ClippedText => {
223 let [frame] = frames else {
224 panic!("clipped_text expects exactly one rendered frame");
225 };
226 assert_clipped_text_frame(&frame.pixels, frame.width, frame.height);
227 }
228 Self::FilledPath => {
229 let [frame] = frames else {
230 panic!("filled_path expects exactly one rendered frame");
231 };
232 assert_filled_path_frame(&frame.pixels, frame.width, frame.height);
233 }
234 Self::DrawScopeGroups => {
235 let [frame] = frames else {
236 panic!("draw_scope_groups expects exactly one rendered frame");
237 };
238 draw_groups::assert_draw_groups_frame(&frame.pixels, frame.width, frame.height);
239 }
240 Self::PaintedDrawGroups => {
241 let [frame] = frames else {
242 panic!("painted_draw_groups expects exactly one rendered frame");
243 };
244 draw_groups::assert_painted_groups_frame(&frame.pixels, frame.width, frame.height);
245 }
246 Self::GradientDrawText => {
247 let [frame] = frames else {
248 panic!("gradient_draw_text expects exactly one rendered frame");
249 };
250 draw_text_brush::assert_gradient_text_frame(
251 &frame.pixels,
252 frame.width,
253 frame.height,
254 );
255 }
256 }
257 }
258}
259
260fn rounded_rect_fixture() -> RenderFixture {
261 build_fixture(
262 72,
263 72,
264 vec![draw_node(
265 DrawPrimitive::RoundRect {
266 rect: Rect {
267 x: 12.0,
268 y: 12.0,
269 width: 48.0,
270 height: 48.0,
271 },
272 brush: Brush::solid(FOREGROUND_COLOR),
273 radii: CornerRadii::uniform(18.0),
274 stroke: None,
275 },
276 None,
277 )],
278 )
279}
280
281fn primitive_clip_fixture() -> RenderFixture {
282 build_fixture(
283 52,
284 44,
285 vec![draw_node(
286 DrawPrimitive::Rect {
287 rect: Rect {
288 x: 8.0,
289 y: 10.0,
290 width: 28.0,
291 height: 18.0,
292 },
293 brush: Brush::solid(FOREGROUND_COLOR),
294 stroke: None,
295 },
296 Some(Rect {
297 x: 14.0,
298 y: 15.0,
299 width: 10.0,
300 height: 6.0,
301 }),
302 )],
303 )
304}
305
306fn translated_subtree_fixture(translation_x: f32, translation_y: f32) -> RenderFixture {
307 let subtree_bounds = Rect {
308 x: 0.0,
309 y: 0.0,
310 width: 48.0,
311 height: 36.0,
312 };
313
314 build_translated_fixture(
315 96,
316 84,
317 subtree_bounds,
318 Point::new(translation_x, translation_y),
319 vec![
320 draw_node(
321 DrawPrimitive::RoundRect {
322 rect: Rect {
323 x: 4.0,
324 y: 4.0,
325 width: 40.0,
326 height: 28.0,
327 },
328 brush: Brush::solid(FOREGROUND_COLOR),
329 radii: CornerRadii::uniform(10.0),
330 stroke: None,
331 },
332 None,
333 ),
334 draw_node(
335 DrawPrimitive::Rect {
336 rect: Rect {
337 x: 10.0,
338 y: 18.0,
339 width: 18.0,
340 height: 10.0,
341 },
342 brush: Brush::solid(Color(0.2, 0.8, 1.0, 1.0)),
343 stroke: None,
344 },
345 Some(Rect {
346 x: 12.0,
347 y: 20.0,
348 width: 10.0,
349 height: 4.0,
350 }),
351 ),
352 ],
353 )
354}
355
356fn build_fixture(width: u32, height: u32, children: Vec<RenderNode>) -> RenderFixture {
357 let bounds = Rect {
358 x: 0.0,
359 y: 0.0,
360 width: width as f32,
361 height: height as f32,
362 };
363
364 RenderFixture {
365 width,
366 height,
367 graph: RenderGraph::new(graph_layer(
368 bounds,
369 ProjectiveTransform::identity(),
370 with_background(bounds, children),
371 )),
372 normalized_rect: None,
373 }
374}
375
376fn build_translated_fixture(
377 width: u32,
378 height: u32,
379 subtree_bounds: Rect,
380 translation: Point,
381 subtree_children: Vec<RenderNode>,
382) -> RenderFixture {
383 build_translated_fixture_with_context(
384 width,
385 height,
386 subtree_bounds,
387 translation,
388 false,
389 subtree_children,
390 )
391}
392
393fn build_translated_fixture_with_context(
394 width: u32,
395 height: u32,
396 subtree_bounds: Rect,
397 translation: Point,
398 translated_content_context: bool,
399 subtree_children: Vec<RenderNode>,
400) -> RenderFixture {
401 let bounds = Rect {
402 x: 0.0,
403 y: 0.0,
404 width: width as f32,
405 height: height as f32,
406 };
407 let subtree = graph_layer(
408 subtree_bounds,
409 ProjectiveTransform::translation(translation.x, translation.y),
410 subtree_children,
411 );
412 let mut subtree = subtree;
413 subtree.translated_content_context = translated_content_context;
414
415 RenderFixture {
416 width,
417 height,
418 graph: RenderGraph::new(graph_layer(
419 bounds,
420 ProjectiveTransform::identity(),
421 with_background(bounds, vec![RenderNode::Layer(Box::new(subtree))]),
422 )),
423 normalized_rect: Some(Rect {
424 x: translation.x,
425 y: translation.y,
426 width: subtree_bounds.width,
427 height: subtree_bounds.height,
428 }),
429 }
430}
431
432fn graph_layer(
433 local_bounds: Rect,
434 transform_to_parent: ProjectiveTransform,
435 children: Vec<RenderNode>,
436) -> LayerNode {
437 LayerNode {
438 local_bounds,
439 transform_to_parent,
440 children,
441 ..Default::default()
442 }
443}
444
445fn with_background(bounds: Rect, mut children: Vec<RenderNode>) -> Vec<RenderNode> {
446 children.insert(
447 0,
448 draw_node(
449 DrawPrimitive::Rect {
450 rect: bounds,
451 brush: Brush::solid(BACKGROUND_COLOR),
452 stroke: None,
453 },
454 None,
455 ),
456 );
457 children
458}
459
460fn draw_node(primitive: DrawPrimitive, clip: Option<Rect>) -> RenderNode {
461 RenderNode::Primitive(PrimitiveEntry {
462 phase: PrimitivePhase::BeforeChildren,
463 node: PrimitiveNode::Draw(Box::new(DrawPrimitiveNode { primitive, clip })),
464 })
465}
466
467fn stroked_round_rect_fixture() -> RenderFixture {
468 build_fixture(
469 72,
470 72,
471 vec![draw_node(
472 DrawPrimitive::RoundRect {
473 rect: Rect {
474 x: 16.0,
475 y: 16.0,
476 width: 40.0,
477 height: 40.0,
478 },
479 brush: Brush::solid(FOREGROUND_COLOR),
480 radii: CornerRadii::uniform(10.0),
481 stroke: Some(Stroke::new(6.0)),
482 },
483 None,
484 )],
485 )
486}
487
488fn annular_sector_fixture() -> RenderFixture {
489 build_fixture(
490 72,
491 72,
492 vec![draw_node(
493 DrawPrimitive::Arc {
494 rect: Rect {
495 x: 36.0,
496 y: 36.0,
497 width: 24.0,
498 height: 24.0,
499 },
500 brush: Brush::solid(FOREGROUND_COLOR),
501 center: Point::new(36.0, 36.0),
502 radius: 24.0,
503 start_angle: 0.0,
504 sweep_angle: std::f32::consts::FRAC_PI_2,
505 stroke: None,
506 inner_radius: 12.0,
507 },
508 None,
509 )],
510 )
511}
512
513fn filled_path_primitives() -> Vec<DrawPrimitive> {
516 let mut path = Path::new();
517 path.move_to(Point::new(6.0, 66.0));
518 for (index, y) in [40.0, 8.0, 52.0, 20.5, 61.0, 14.25].into_iter().enumerate() {
519 path.line_to(Point::new(6.0 + index as f32 * 12.0, y));
520 }
521 path.line_to(Point::new(66.0, 66.0));
522 path.close();
523 let mut recording = cranpose_ui_graphics::CommandRecording::default();
524 let mut scope = DrawScopeDefault::new(Size::new(72.0, 72.0), &mut recording);
525 scope.draw_path(&path, Brush::solid(FOREGROUND_COLOR), DrawStyle::Fill);
526 scope.into_primitives()
527}
528
529fn filled_path_fixture() -> RenderFixture {
530 build_fixture(
531 72,
532 72,
533 filled_path_primitives()
534 .into_iter()
535 .map(|primitive| draw_node(primitive, None))
536 .collect(),
537 )
538}
539
540fn assert_filled_path_frame(pixels: &[u8], width: u32, height: u32) {
542 assert_eq!((width, height), (72, 72));
543 let slices: Vec<Trapezoid> = filled_path_primitives()
544 .iter()
545 .filter_map(|primitive| match primitive {
546 DrawPrimitive::Trapezoid { trapezoid, .. } => Some(*trapezoid),
547 _ => None,
548 })
549 .collect();
550 assert_eq!(slices.len(), 5, "the fill slices between its points");
551 let background = f32::from(sample_pixel(pixels, width, 2, 2)[0]);
552 let foreground = FOREGROUND_COLOR.r() * 255.0;
553 for y in 0..height {
554 for x in 0..width {
555 let centre = Point::new(x as f32 + 0.5, y as f32 + 0.5);
556 let expected = slices.iter().fold(0.0f32, |value, slice| {
557 value + slice.coverage(centre) * (1.0 - value)
558 });
559 let actual = (f32::from(sample_pixel(pixels, width, x, y)[0]) - background)
560 / (foreground - background);
561 assert!(
562 (actual - expected).abs() <= 0.05,
563 "filled path pixel ({x}, {y}) carries {actual:.3} of the foreground, \
564 its slices give it {expected:.3}"
565 );
566 }
567 }
568}
569
570fn assert_stroked_round_rect_frame(pixels: &[u8], width: u32, height: u32) {
571 assert_eq!((width, height), (72, 72));
572 let background = sample_pixel(pixels, width, 2, 2);
573
574 assert_pixel_matches_background(
575 pixels,
576 width,
577 background,
578 36,
579 16,
580 false,
581 "stroked round rect top edge should contain stroke ink",
582 );
583 assert_pixel_matches_background(
584 pixels,
585 width,
586 background,
587 16,
588 36,
589 false,
590 "stroked round rect left edge should contain stroke ink",
591 );
592 assert_pixel_matches_background(
593 pixels,
594 width,
595 background,
596 36,
597 36,
598 true,
599 "stroked round rect interior must stay empty — a backend that filled \
600 the shape instead of stroking it would fail here",
601 );
602 assert_pixel_matches_background(
603 pixels,
604 width,
605 background,
606 4,
607 36,
608 true,
609 "outside the stroked round rect should stay background-colored",
610 );
611}
612
613fn assert_annular_sector_frame(pixels: &[u8], width: u32, height: u32) {
614 assert_eq!((width, height), (72, 72));
615 let background = sample_pixel(pixels, width, 2, 2);
616
617 assert_pixel_matches_background(
618 pixels,
619 width,
620 background,
621 54,
622 38,
623 false,
624 "annular sector band near 0 degrees should contain fill",
625 );
626 assert_pixel_matches_background(
627 pixels,
628 width,
629 background,
630 38,
631 54,
632 false,
633 "annular sector band near 90 degrees should contain fill",
634 );
635 assert_pixel_matches_background(
636 pixels,
637 width,
638 background,
639 36,
640 36,
641 true,
642 "the annulus hole must stay empty",
643 );
644 assert_pixel_matches_background(
645 pixels,
646 width,
647 background,
648 54,
649 30,
650 true,
651 "past the flat radial edge must stay empty — a rounded cap here would \
652 mean the sector is being drawn as a stroked arc",
653 );
654 assert_pixel_matches_background(
655 pixels,
656 width,
657 background,
658 66,
659 38,
660 true,
661 "beyond the outer radius must stay empty",
662 );
663}
664
665fn assert_rounded_rect_frame(pixels: &[u8], width: u32, height: u32) {
666 assert_eq!((width, height), (72, 72));
667 let background = sample_pixel(pixels, width, 2, 2);
668
669 assert_pixel_matches_background(
670 pixels,
671 width,
672 background,
673 14,
674 14,
675 true,
676 "rounded rect corner should stay background-colored",
677 );
678 assert_pixel_matches_background(
679 pixels,
680 width,
681 background,
682 30,
683 16,
684 false,
685 "rounded rect top edge should contain fill",
686 );
687 assert_pixel_matches_background(
688 pixels,
689 width,
690 background,
691 36,
692 36,
693 false,
694 "rounded rect center should contain fill",
695 );
696}
697
698fn assert_primitive_clip_frame(pixels: &[u8], width: u32, height: u32) {
699 assert_eq!((width, height), (52, 44));
700 let background = sample_pixel(pixels, width, 2, 2);
701
702 assert_pixel_matches_background(
703 pixels,
704 width,
705 background,
706 18,
707 18,
708 false,
709 "pixel inside primitive clip should contain fill",
710 );
711 assert_pixel_matches_background(
712 pixels,
713 width,
714 background,
715 10,
716 12,
717 true,
718 "pixel inside source rect but outside clip should stay background-colored",
719 );
720 assert_pixel_matches_background(
721 pixels,
722 width,
723 background,
724 30,
725 20,
726 true,
727 "pixel on the far side of the source rect but outside clip should stay background-colored",
728 );
729}
730
731fn assert_translated_subtree_frames(frames: &[RenderedFrame]) {
732 let [base, moved] = frames else {
733 panic!("translated_subtree expects exactly two rendered frames");
734 };
735 assert_eq!((base.width, base.height), (96, 84));
736 assert_eq!((moved.width, moved.height), (96, 84));
737 assert_ne!(
738 base.pixels, moved.pixels,
739 "translated subtree contract should move within the full frame"
740 );
741 assert_normalized_region_matches(
742 base,
743 moved,
744 TRANSLATED_SUBTREE_BUDGET,
745 "translated subtree output should remain invariant under rigid parent translation",
746 );
747}
748
749fn normalize_frame_region(frame: &RenderedFrame) -> Vec<u8> {
750 let rect = normalized_rect(frame);
751 let (width, height) = normalized_output_dimensions(frame);
752 normalize_rgba_region(
753 &frame.pixels,
754 frame.width,
755 frame.height,
756 rect,
757 width,
758 height,
759 )
760}
761
762fn assert_normalized_region_matches(
763 base: &RenderedFrame,
764 moved: &RenderedFrame,
765 budget: NormalizedDifferenceBudget,
766 message: &str,
767) {
768 assert_eq!(
769 normalized_output_dimensions(base),
770 normalized_output_dimensions(moved),
771 "normalized comparison requires matching output sizes",
772 );
773 let (width, height) = normalized_output_dimensions(base);
774 let base_normalized = normalize_frame_region(base);
775 let moved_normalized = normalize_frame_region(moved);
776 let stats = image_difference_stats(
777 &base_normalized,
778 &moved_normalized,
779 width,
780 height,
781 PIXEL_DIFFERENCE_TOLERANCE,
782 );
783 if stats.differing_pixels > budget.max_differing_pixels
784 || stats.max_difference > budget.max_pixel_difference
785 {
786 let diff = stats
787 .first_difference
788 .as_ref()
789 .expect("failing normalized comparison should report first difference");
790 panic!(
791 "{message}; differing_pixels={} max_diff={} first differing normalized pixel at ({}, {}) base={:?} moved={:?} diff={}",
792 stats.differing_pixels,
793 stats.max_difference,
794 diff.x,
795 diff.y,
796 diff.lhs,
797 diff.rhs,
798 diff.difference
799 );
800 }
801}
802
803fn normalized_rect(frame: &RenderedFrame) -> Rect {
804 frame
805 .normalized_rect
806 .expect("normalized render frame missing normalized_rect")
807}
808
809fn normalized_output_dimensions(frame: &RenderedFrame) -> (u32, u32) {
810 let rect = normalized_rect(frame);
811 (
812 normalized_dimension(rect.width, "width"),
813 normalized_dimension(rect.height, "height"),
814 )
815}
816
817fn normalized_dimension(value: f32, axis: &str) -> u32 {
818 let rounded = value.round();
819 assert!(
820 (value - rounded).abs() <= 0.01,
821 "normalized {axis} must stay pixel-sized for stable comparison, got {value}",
822 );
823 assert!(
824 rounded > 0.0,
825 "normalized {axis} must be positive, got {value}"
826 );
827 rounded as u32
828}
829
830fn is_background_like(pixel: [u8; 4], background: [u8; 4]) -> bool {
831 pixel_difference(pixel, background) <= PIXEL_DIFFERENCE_TOLERANCE
832}
833
834fn assert_pixel_matches_background(
835 pixels: &[u8],
836 width: u32,
837 background: [u8; 4],
838 x: u32,
839 y: u32,
840 expect_background: bool,
841 message: &str,
842) {
843 let pixel = sample_pixel(pixels, width, x, y);
844 let background_like = is_background_like(pixel, background);
845 assert_eq!(
846 background_like, expect_background,
847 "{message}; pixel at ({x},{y}) was {pixel:?} against background {background:?}"
848 );
849}
850
851fn count_non_background_pixels(pixels: &[u8], width: u32, height: u32, background: [u8; 4]) -> u32 {
852 count_non_background_pixels_in_band(pixels, width, 0, height, background)
853}
854
855fn count_non_background_pixels_in_band(
856 pixels: &[u8],
857 width: u32,
858 y_start: u32,
859 y_end: u32,
860 background: [u8; 4],
861) -> u32 {
862 let mut count = 0;
863 for y in y_start..y_end {
864 for x in 0..width {
865 if !is_background_like(sample_pixel(pixels, width, x, y), background) {
866 count += 1;
867 }
868 }
869 }
870 count
871}
872
873fn ink_y_range(pixels: &[u8], width: u32, height: u32, background: [u8; 4]) -> Option<(u32, u32)> {
874 let mut top = None;
875 let mut bottom = 0u32;
876 for y in 0..height {
877 for x in 0..width {
878 if !is_background_like(sample_pixel(pixels, width, x, y), background) {
879 top.get_or_insert(y);
880 bottom = y + 1;
881 break;
882 }
883 }
884 }
885 top.map(|top_y| (top_y, bottom))
886}
887
888#[cfg(test)]
889#[path = "tests/render_contract_tests.rs"]
890mod tests;