Skip to main content

cranpose_render_common/
render_contract.rs

1use cranpose_core::NodeId;
2use cranpose_ui::{
3    TextLayoutOptions, TextStyle,
4    text::{Shadow, SpanStyle, TextDecoration},
5};
6use cranpose_ui_graphics::{
7    Brush, Color, CornerRadii, DrawPrimitive, DrawScope, DrawScopeDefault, DrawStyle, Path, Point,
8    Rect, Size, Stroke, Trapezoid,
9};
10
11use crate::{
12    graph::{
13        DrawPrimitiveNode, LayerNode, PrimitiveEntry, PrimitiveNode, PrimitivePhase,
14        ProjectiveTransform, RenderGraph, RenderNode, TextPrimitiveNode,
15    },
16    image_compare::{
17        image_difference_stats, normalize_rgba_region, pixel_difference, sample_pixel,
18    },
19};
20
21const BACKGROUND_COLOR: Color = Color(18.0 / 255.0, 18.0 / 255.0, 24.0 / 255.0, 1.0);
22const FOREGROUND_COLOR: Color = Color::WHITE;
23const PIXEL_DIFFERENCE_TOLERANCE: u32 = 24;
24
25const TRANSLATED_SUBTREE_BUDGET: NormalizedDifferenceBudget = NormalizedDifferenceBudget {
26    max_differing_pixels: 270,
27    max_pixel_difference: 360,
28};
29const TRANSLATED_PLAIN_TEXT_BUDGET: NormalizedDifferenceBudget = NormalizedDifferenceBudget {
30    max_differing_pixels: 550,
31    max_pixel_difference: 360,
32};
33const TRANSLATED_TEXT_DECORATIONS_BUDGET: NormalizedDifferenceBudget = NormalizedDifferenceBudget {
34    max_differing_pixels: 320,
35    max_pixel_difference: 400,
36};
37
38#[derive(Clone)]
39pub struct RenderFixture {
40    pub width: u32,
41    pub height: u32,
42    pub graph: RenderGraph,
43    pub normalized_rect: Option<Rect>,
44}
45
46#[derive(Clone, Debug)]
47pub struct RenderedFrame {
48    pub width: u32,
49    pub height: u32,
50    pub pixels: Vec<u8>,
51    pub normalized_rect: Option<Rect>,
52}
53
54#[derive(Clone, Copy)]
55struct NormalizedDifferenceBudget {
56    max_differing_pixels: u32,
57    max_pixel_difference: u32,
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum SharedRenderCase {
62    RoundedRect,
63    PrimitiveClip,
64    TranslatedSubtree,
65    TranslatedPlainText,
66    TranslatedTextDecorations,
67    MultilineText,
68    ClippedText,
69    StrokedRoundRect,
70    AnnularSector,
71    FilledPath,
72}
73
74pub const ALL_SHARED_RENDER_CASES: [SharedRenderCase; 10] = [
75    SharedRenderCase::RoundedRect,
76    SharedRenderCase::PrimitiveClip,
77    SharedRenderCase::TranslatedSubtree,
78    SharedRenderCase::TranslatedPlainText,
79    SharedRenderCase::TranslatedTextDecorations,
80    SharedRenderCase::MultilineText,
81    SharedRenderCase::ClippedText,
82    SharedRenderCase::StrokedRoundRect,
83    SharedRenderCase::AnnularSector,
84    SharedRenderCase::FilledPath,
85];
86
87impl SharedRenderCase {
88    pub fn name(self) -> &'static str {
89        match self {
90            SharedRenderCase::RoundedRect => "rounded_rect",
91            SharedRenderCase::PrimitiveClip => "primitive_clip",
92            SharedRenderCase::TranslatedSubtree => "translated_subtree",
93            SharedRenderCase::TranslatedPlainText => "translated_plain_text",
94            SharedRenderCase::TranslatedTextDecorations => "translated_text_decorations",
95            SharedRenderCase::MultilineText => "multiline_text",
96            SharedRenderCase::ClippedText => "clipped_text",
97            SharedRenderCase::StrokedRoundRect => "stroked_round_rect",
98            SharedRenderCase::AnnularSector => "annular_sector",
99            SharedRenderCase::FilledPath => "filled_path",
100        }
101    }
102
103    pub fn fixtures(self) -> Vec<RenderFixture> {
104        match self {
105            SharedRenderCase::RoundedRect => vec![rounded_rect_fixture()],
106            SharedRenderCase::PrimitiveClip => vec![primitive_clip_fixture()],
107            SharedRenderCase::TranslatedSubtree => vec![
108                translated_subtree_fixture(12.3, 14.7),
109                translated_subtree_fixture(32.6, 26.2),
110            ],
111            SharedRenderCase::TranslatedPlainText => vec![
112                translated_plain_text_fixture(14.3, 18.6),
113                translated_plain_text_fixture(36.4, 30.1),
114            ],
115            SharedRenderCase::TranslatedTextDecorations => vec![
116                translated_text_decorations_fixture(14.3, 18.6),
117                translated_text_decorations_fixture(36.4, 30.1),
118            ],
119            SharedRenderCase::MultilineText => vec![multiline_text_fixture()],
120            SharedRenderCase::ClippedText => vec![clipped_text_fixture()],
121            SharedRenderCase::StrokedRoundRect => vec![stroked_round_rect_fixture()],
122            SharedRenderCase::AnnularSector => vec![annular_sector_fixture()],
123            SharedRenderCase::FilledPath => vec![filled_path_fixture()],
124        }
125    }
126
127    pub fn assert_frames(self, frames: &[RenderedFrame]) {
128        match self {
129            SharedRenderCase::RoundedRect => {
130                let [frame] = frames else {
131                    panic!("rounded_rect expects exactly one rendered frame");
132                };
133                assert_rounded_rect_frame(&frame.pixels, frame.width, frame.height);
134            }
135            SharedRenderCase::PrimitiveClip => {
136                let [frame] = frames else {
137                    panic!("primitive_clip expects exactly one rendered frame");
138                };
139                assert_primitive_clip_frame(&frame.pixels, frame.width, frame.height);
140            }
141            SharedRenderCase::TranslatedSubtree => {
142                assert_translated_subtree_frames(frames);
143            }
144            SharedRenderCase::TranslatedPlainText => {
145                assert_translated_plain_text_frames(frames);
146            }
147            SharedRenderCase::TranslatedTextDecorations => {
148                assert_translated_text_decorations_frames(frames);
149            }
150            SharedRenderCase::MultilineText => {
151                let [frame] = frames else {
152                    panic!("multiline_text expects exactly one rendered frame");
153                };
154                assert_multiline_text_frame(&frame.pixels, frame.width, frame.height);
155            }
156            SharedRenderCase::StrokedRoundRect => {
157                let [frame] = frames else {
158                    panic!("stroked_round_rect expects exactly one rendered frame");
159                };
160                assert_stroked_round_rect_frame(&frame.pixels, frame.width, frame.height);
161            }
162            SharedRenderCase::AnnularSector => {
163                let [frame] = frames else {
164                    panic!("annular_sector expects exactly one rendered frame");
165                };
166                assert_annular_sector_frame(&frame.pixels, frame.width, frame.height);
167            }
168            SharedRenderCase::ClippedText => {
169                let [frame] = frames else {
170                    panic!("clipped_text expects exactly one rendered frame");
171                };
172                assert_clipped_text_frame(&frame.pixels, frame.width, frame.height);
173            }
174            SharedRenderCase::FilledPath => {
175                let [frame] = frames else {
176                    panic!("filled_path expects exactly one rendered frame");
177                };
178                assert_filled_path_frame(&frame.pixels, frame.width, frame.height);
179            }
180        }
181    }
182}
183
184fn rounded_rect_fixture() -> RenderFixture {
185    build_fixture(
186        72,
187        72,
188        vec![draw_node(
189            DrawPrimitive::RoundRect {
190                rect: Rect {
191                    x: 12.0,
192                    y: 12.0,
193                    width: 48.0,
194                    height: 48.0,
195                },
196                brush: Brush::solid(FOREGROUND_COLOR),
197                radii: CornerRadii::uniform(18.0),
198                stroke: None,
199            },
200            None,
201        )],
202    )
203}
204
205fn primitive_clip_fixture() -> RenderFixture {
206    build_fixture(
207        52,
208        44,
209        vec![draw_node(
210            DrawPrimitive::Rect {
211                rect: Rect {
212                    x: 8.0,
213                    y: 10.0,
214                    width: 28.0,
215                    height: 18.0,
216                },
217                brush: Brush::solid(FOREGROUND_COLOR),
218                stroke: None,
219            },
220            Some(Rect {
221                x: 14.0,
222                y: 15.0,
223                width: 10.0,
224                height: 6.0,
225            }),
226        )],
227    )
228}
229
230fn translated_subtree_fixture(translation_x: f32, translation_y: f32) -> RenderFixture {
231    let subtree_bounds = Rect {
232        x: 0.0,
233        y: 0.0,
234        width: 48.0,
235        height: 36.0,
236    };
237
238    build_translated_fixture(
239        96,
240        84,
241        subtree_bounds,
242        Point::new(translation_x, translation_y),
243        vec![
244            draw_node(
245                DrawPrimitive::RoundRect {
246                    rect: Rect {
247                        x: 4.0,
248                        y: 4.0,
249                        width: 40.0,
250                        height: 28.0,
251                    },
252                    brush: Brush::solid(FOREGROUND_COLOR),
253                    radii: CornerRadii::uniform(10.0),
254                    stroke: None,
255                },
256                None,
257            ),
258            draw_node(
259                DrawPrimitive::Rect {
260                    rect: Rect {
261                        x: 10.0,
262                        y: 18.0,
263                        width: 18.0,
264                        height: 10.0,
265                    },
266                    brush: Brush::solid(Color(0.2, 0.8, 1.0, 1.0)),
267                    stroke: None,
268                },
269                Some(Rect {
270                    x: 12.0,
271                    y: 20.0,
272                    width: 10.0,
273                    height: 4.0,
274                }),
275            ),
276        ],
277    )
278}
279
280fn translated_plain_text_fixture(translation_x: f32, translation_y: f32) -> RenderFixture {
281    let subtree_bounds = Rect {
282        x: 0.0,
283        y: 0.0,
284        width: 116.0,
285        height: 36.0,
286    };
287
288    let mut fixture = build_translated_fixture_with_context(
289        196,
290        112,
291        subtree_bounds,
292        Point::new(translation_x, translation_y),
293        true,
294        vec![
295            draw_node(
296                DrawPrimitive::RoundRect {
297                    rect: Rect {
298                        x: 2.0,
299                        y: 2.0,
300                        width: 112.0,
301                        height: 32.0,
302                    },
303                    brush: Brush::solid(Color(0.24, 0.26, 0.40, 0.92)),
304                    radii: CornerRadii::uniform(8.0),
305                    stroke: None,
306                },
307                None,
308            ),
309            text_node(
310                33,
311                Rect {
312                    x: 10.0,
313                    y: 8.0,
314                    width: 96.0,
315                    height: 18.0,
316                },
317                "Scroll text",
318                None,
319            ),
320        ],
321    );
322    fixture.normalized_rect = Some(Rect {
323        x: translation_x.round(),
324        y: translation_y.round(),
325        width: subtree_bounds.width,
326        height: subtree_bounds.height,
327    });
328    fixture
329}
330
331fn multiline_text_fixture() -> RenderFixture {
332    build_fixture(
333        220,
334        100,
335        vec![text_node(
336            1,
337            Rect {
338                x: 8.0,
339                y: 8.0,
340                width: 180.0,
341                height: 80.0,
342            },
343            "Dynamic\nModifiers",
344            None,
345        )],
346    )
347}
348
349fn clipped_text_fixture() -> RenderFixture {
350    build_fixture(
351        220,
352        100,
353        vec![text_node(
354            2,
355            Rect {
356                x: 8.0,
357                y: 40.0,
358                width: 180.0,
359                height: 24.0,
360            },
361            "Clipped Text",
362            Some(Rect {
363                x: 0.0,
364                y: 0.0,
365                width: 220.0,
366                height: 20.0,
367            }),
368        )],
369    )
370}
371
372fn translated_text_decorations_fixture(translation_x: f32, translation_y: f32) -> RenderFixture {
373    let subtree_bounds = Rect {
374        x: 0.0,
375        y: 0.0,
376        width: 112.0,
377        height: 40.0,
378    };
379    let text_style = TextStyle::from_span_style(SpanStyle {
380        color: Some(FOREGROUND_COLOR),
381        shadow: Some(Shadow {
382            color: Color(0.0, 0.0, 0.0, 0.85),
383            offset: Point::new(3.0, 2.0),
384            blur_radius: 4.0,
385        }),
386        text_decoration: Some(TextDecoration::UNDERLINE),
387        ..Default::default()
388    });
389
390    let mut fixture = build_translated_fixture_with_context(
391        180,
392        96,
393        subtree_bounds,
394        Point::new(translation_x, translation_y),
395        true,
396        vec![text_node_with_style(
397            3,
398            Rect {
399                x: 6.0,
400                y: 6.0,
401                width: 96.0,
402                height: 24.0,
403            },
404            "Shifted",
405            None,
406            text_style,
407        )],
408    );
409    fixture.normalized_rect = Some(Rect {
410        x: translation_x.round(),
411        y: translation_y.round(),
412        width: subtree_bounds.width,
413        height: subtree_bounds.height,
414    });
415    fixture
416}
417
418fn build_fixture(width: u32, height: u32, children: Vec<RenderNode>) -> RenderFixture {
419    let bounds = Rect {
420        x: 0.0,
421        y: 0.0,
422        width: width as f32,
423        height: height as f32,
424    };
425
426    RenderFixture {
427        width,
428        height,
429        graph: RenderGraph::new(graph_layer(
430            bounds,
431            ProjectiveTransform::identity(),
432            with_background(bounds, children),
433        )),
434        normalized_rect: None,
435    }
436}
437
438fn build_translated_fixture(
439    width: u32,
440    height: u32,
441    subtree_bounds: Rect,
442    translation: Point,
443    subtree_children: Vec<RenderNode>,
444) -> RenderFixture {
445    build_translated_fixture_with_context(
446        width,
447        height,
448        subtree_bounds,
449        translation,
450        false,
451        subtree_children,
452    )
453}
454
455fn build_translated_fixture_with_context(
456    width: u32,
457    height: u32,
458    subtree_bounds: Rect,
459    translation: Point,
460    translated_content_context: bool,
461    subtree_children: Vec<RenderNode>,
462) -> RenderFixture {
463    let bounds = Rect {
464        x: 0.0,
465        y: 0.0,
466        width: width as f32,
467        height: height as f32,
468    };
469    let subtree = graph_layer(
470        subtree_bounds,
471        ProjectiveTransform::translation(translation.x, translation.y),
472        subtree_children,
473    );
474    let mut subtree = subtree;
475    subtree.translated_content_context = translated_content_context;
476
477    RenderFixture {
478        width,
479        height,
480        graph: RenderGraph::new(graph_layer(
481            bounds,
482            ProjectiveTransform::identity(),
483            with_background(bounds, vec![RenderNode::Layer(Box::new(subtree))]),
484        )),
485        normalized_rect: Some(Rect {
486            x: translation.x,
487            y: translation.y,
488            width: subtree_bounds.width,
489            height: subtree_bounds.height,
490        }),
491    }
492}
493
494fn graph_layer(
495    local_bounds: Rect,
496    transform_to_parent: ProjectiveTransform,
497    children: Vec<RenderNode>,
498) -> LayerNode {
499    LayerNode {
500        local_bounds,
501        transform_to_parent,
502        children,
503        ..Default::default()
504    }
505}
506
507fn with_background(bounds: Rect, mut children: Vec<RenderNode>) -> Vec<RenderNode> {
508    children.insert(
509        0,
510        draw_node(
511            DrawPrimitive::Rect {
512                rect: bounds,
513                brush: Brush::solid(BACKGROUND_COLOR),
514                stroke: None,
515            },
516            None,
517        ),
518    );
519    children
520}
521
522fn draw_node(primitive: DrawPrimitive, clip: Option<Rect>) -> RenderNode {
523    RenderNode::Primitive(PrimitiveEntry {
524        phase: PrimitivePhase::BeforeChildren,
525        node: PrimitiveNode::Draw(Box::new(DrawPrimitiveNode { primitive, clip })),
526    })
527}
528
529fn text_node(node_id: NodeId, rect: Rect, text: &str, clip: Option<Rect>) -> RenderNode {
530    text_node_with_style(
531        node_id,
532        rect,
533        text,
534        clip,
535        TextStyle::from_span_style(SpanStyle {
536            color: Some(FOREGROUND_COLOR),
537            ..Default::default()
538        }),
539    )
540}
541
542fn text_node_with_style(
543    node_id: NodeId,
544    rect: Rect,
545    text: &str,
546    clip: Option<Rect>,
547    text_style: TextStyle,
548) -> RenderNode {
549    RenderNode::Primitive(PrimitiveEntry {
550        phase: PrimitivePhase::BeforeChildren,
551        node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
552            node_id,
553            rect,
554            text: cranpose_ui::text::shared_plain_annotated_string(text),
555            render_text: cranpose_ui::text::shared_plain_render_string(text),
556            text_style: std::sync::Arc::new(text_style),
557            font_size: 14.0,
558            layout_options: TextLayoutOptions::default(),
559            clip,
560            paint: Default::default(),
561        })),
562    })
563}
564
565fn stroked_round_rect_fixture() -> RenderFixture {
566    build_fixture(
567        72,
568        72,
569        vec![draw_node(
570            DrawPrimitive::RoundRect {
571                rect: Rect {
572                    x: 16.0,
573                    y: 16.0,
574                    width: 40.0,
575                    height: 40.0,
576                },
577                brush: Brush::solid(FOREGROUND_COLOR),
578                radii: CornerRadii::uniform(10.0),
579                stroke: Some(Stroke::new(6.0)),
580            },
581            None,
582        )],
583    )
584}
585
586fn annular_sector_fixture() -> RenderFixture {
587    build_fixture(
588        72,
589        72,
590        vec![draw_node(
591            DrawPrimitive::Arc {
592                rect: Rect {
593                    x: 36.0,
594                    y: 36.0,
595                    width: 24.0,
596                    height: 24.0,
597                },
598                brush: Brush::solid(FOREGROUND_COLOR),
599                center: Point::new(36.0, 36.0),
600                radius: 24.0,
601                start_angle: 0.0,
602                sweep_angle: std::f32::consts::FRAC_PI_2,
603                stroke: None,
604                inner_radius: 12.0,
605            },
606            None,
607        )],
608    )
609}
610
611/// A sparkline a draw scope fills: slices whose sides meet, with steep
612/// edges that stand above the columns beside them.
613fn filled_path_primitives() -> Vec<DrawPrimitive> {
614    let mut path = Path::new();
615    path.move_to(Point::new(6.0, 66.0));
616    for (index, y) in [40.0, 8.0, 52.0, 20.5, 61.0, 14.25].into_iter().enumerate() {
617        path.line_to(Point::new(6.0 + index as f32 * 12.0, y));
618    }
619    path.line_to(Point::new(66.0, 66.0));
620    path.close();
621    let mut scope = DrawScopeDefault::new(Size::new(72.0, 72.0));
622    scope.draw_path(&path, Brush::solid(FOREGROUND_COLOR), DrawStyle::Fill);
623    scope.into_primitives()
624}
625
626fn filled_path_fixture() -> RenderFixture {
627    build_fixture(
628        72,
629        72,
630        filled_path_primitives()
631            .into_iter()
632            .map(|primitive| draw_node(primitive, None))
633            .collect(),
634    )
635}
636
637/// Each pixel takes the share of the foreground the fill's slices give it.
638fn assert_filled_path_frame(pixels: &[u8], width: u32, height: u32) {
639    assert_eq!((width, height), (72, 72));
640    let slices: Vec<Trapezoid> = filled_path_primitives()
641        .iter()
642        .filter_map(|primitive| match primitive {
643            DrawPrimitive::Trapezoid { trapezoid, .. } => Some(*trapezoid),
644            _ => None,
645        })
646        .collect();
647    assert_eq!(slices.len(), 5, "the fill slices between its points");
648    let background = f32::from(sample_pixel(pixels, width, 2, 2)[0]);
649    let foreground = FOREGROUND_COLOR.r() * 255.0;
650    for y in 0..height {
651        for x in 0..width {
652            let centre = Point::new(x as f32 + 0.5, y as f32 + 0.5);
653            let expected = slices.iter().fold(0.0f32, |value, slice| {
654                value + slice.coverage(centre) * (1.0 - value)
655            });
656            let actual = (f32::from(sample_pixel(pixels, width, x, y)[0]) - background)
657                / (foreground - background);
658            assert!(
659                (actual - expected).abs() <= 0.05,
660                "filled path pixel ({x}, {y}) carries {actual:.3} of the foreground, \
661                 its slices give it {expected:.3}"
662            );
663        }
664    }
665}
666
667fn assert_stroked_round_rect_frame(pixels: &[u8], width: u32, height: u32) {
668    assert_eq!((width, height), (72, 72));
669    let background = sample_pixel(pixels, width, 2, 2);
670
671    assert_pixel_matches_background(
672        pixels,
673        width,
674        background,
675        36,
676        16,
677        false,
678        "stroked round rect top edge should contain stroke ink",
679    );
680    assert_pixel_matches_background(
681        pixels,
682        width,
683        background,
684        16,
685        36,
686        false,
687        "stroked round rect left edge should contain stroke ink",
688    );
689    assert_pixel_matches_background(
690        pixels,
691        width,
692        background,
693        36,
694        36,
695        true,
696        "stroked round rect interior must stay empty — a backend that filled \
697         the shape instead of stroking it would fail here",
698    );
699    assert_pixel_matches_background(
700        pixels,
701        width,
702        background,
703        4,
704        36,
705        true,
706        "outside the stroked round rect should stay background-colored",
707    );
708}
709
710fn assert_annular_sector_frame(pixels: &[u8], width: u32, height: u32) {
711    assert_eq!((width, height), (72, 72));
712    let background = sample_pixel(pixels, width, 2, 2);
713
714    assert_pixel_matches_background(
715        pixels,
716        width,
717        background,
718        54,
719        38,
720        false,
721        "annular sector band near 0 degrees should contain fill",
722    );
723    assert_pixel_matches_background(
724        pixels,
725        width,
726        background,
727        38,
728        54,
729        false,
730        "annular sector band near 90 degrees should contain fill",
731    );
732    assert_pixel_matches_background(
733        pixels,
734        width,
735        background,
736        36,
737        36,
738        true,
739        "the annulus hole must stay empty",
740    );
741    assert_pixel_matches_background(
742        pixels,
743        width,
744        background,
745        54,
746        30,
747        true,
748        "past the flat radial edge must stay empty — a rounded cap here would \
749         mean the sector is being drawn as a stroked arc",
750    );
751    assert_pixel_matches_background(
752        pixels,
753        width,
754        background,
755        66,
756        38,
757        true,
758        "beyond the outer radius must stay empty",
759    );
760}
761
762fn assert_rounded_rect_frame(pixels: &[u8], width: u32, height: u32) {
763    assert_eq!((width, height), (72, 72));
764    let background = sample_pixel(pixels, width, 2, 2);
765
766    assert_pixel_matches_background(
767        pixels,
768        width,
769        background,
770        14,
771        14,
772        true,
773        "rounded rect corner should stay background-colored",
774    );
775    assert_pixel_matches_background(
776        pixels,
777        width,
778        background,
779        30,
780        16,
781        false,
782        "rounded rect top edge should contain fill",
783    );
784    assert_pixel_matches_background(
785        pixels,
786        width,
787        background,
788        36,
789        36,
790        false,
791        "rounded rect center should contain fill",
792    );
793}
794
795fn assert_primitive_clip_frame(pixels: &[u8], width: u32, height: u32) {
796    assert_eq!((width, height), (52, 44));
797    let background = sample_pixel(pixels, width, 2, 2);
798
799    assert_pixel_matches_background(
800        pixels,
801        width,
802        background,
803        18,
804        18,
805        false,
806        "pixel inside primitive clip should contain fill",
807    );
808    assert_pixel_matches_background(
809        pixels,
810        width,
811        background,
812        10,
813        12,
814        true,
815        "pixel inside source rect but outside clip should stay background-colored",
816    );
817    assert_pixel_matches_background(
818        pixels,
819        width,
820        background,
821        30,
822        20,
823        true,
824        "pixel on the far side of the source rect but outside clip should stay background-colored",
825    );
826}
827
828fn assert_multiline_text_frame(pixels: &[u8], width: u32, height: u32) {
829    assert_eq!((width, height), (220, 100));
830    let background = sample_pixel(pixels, width, 2, 2);
831    let (ink_top, ink_bottom) = ink_y_range(pixels, width, height, background)
832        .expect("expected rendered text ink in multiline contract frame");
833    let ink_height = ink_bottom - ink_top;
834    assert!(
835        ink_height >= 18,
836        "expected two text lines of ink, observed span {ink_height}px (y={ink_top}..{ink_bottom})"
837    );
838    let mid_y = ink_top + ink_height / 2;
839    let first_line_ink =
840        count_non_background_pixels_in_band(pixels, width, ink_top, mid_y, background);
841    let second_line_ink =
842        count_non_background_pixels_in_band(pixels, width, mid_y, ink_bottom, background);
843    assert!(
844        first_line_ink > 20,
845        "expected first line ink in multiline contract frame, got {first_line_ink}"
846    );
847    assert!(
848        second_line_ink > 20,
849        "expected second line ink in multiline contract frame, got {second_line_ink}"
850    );
851}
852
853fn assert_translated_subtree_frames(frames: &[RenderedFrame]) {
854    let [base, moved] = frames else {
855        panic!("translated_subtree expects exactly two rendered frames");
856    };
857    assert_eq!((base.width, base.height), (96, 84));
858    assert_eq!((moved.width, moved.height), (96, 84));
859    assert_ne!(
860        base.pixels, moved.pixels,
861        "translated subtree contract should move within the full frame"
862    );
863    assert_normalized_region_matches(
864        base,
865        moved,
866        TRANSLATED_SUBTREE_BUDGET,
867        "translated subtree output should remain invariant under rigid parent translation",
868    );
869}
870
871fn assert_translated_plain_text_frames(frames: &[RenderedFrame]) {
872    let [base, moved] = frames else {
873        panic!("translated_plain_text expects exactly two rendered frames");
874    };
875    assert_eq!((base.width, base.height), (196, 112));
876    assert_eq!((moved.width, moved.height), (196, 112));
877    assert_ne!(
878        base.pixels, moved.pixels,
879        "translated plain text contract should move within the full frame"
880    );
881    assert_normalized_region_matches(
882        base,
883        moved,
884        TRANSLATED_PLAIN_TEXT_BUDGET,
885        "translated plain text should remain visually stable after normalization",
886    );
887}
888
889fn assert_translated_text_decorations_frames(frames: &[RenderedFrame]) {
890    let [base, moved] = frames else {
891        panic!("translated_text_decorations expects exactly two rendered frames");
892    };
893    assert_eq!((base.width, base.height), (180, 96));
894    assert_eq!((moved.width, moved.height), (180, 96));
895    assert_ne!(
896        base.pixels, moved.pixels,
897        "translated text contract should move within the full frame"
898    );
899    assert_normalized_region_matches(
900        base,
901        moved,
902        TRANSLATED_TEXT_DECORATIONS_BUDGET,
903        "normalized text/shadow/decoration output should remain invariant under rigid parent translation",
904    );
905
906    let background = sample_pixel(&base.pixels, base.width, 2, 2);
907    let base_crop = normalize_frame_region(base);
908    let (crop_width, crop_height) = normalized_output_dimensions(base);
909    let ink_pixels = count_non_background_pixels(&base_crop, crop_width, crop_height, background);
910    assert!(
911        ink_pixels > 120,
912        "translated text contract should contain visible ink, observed {ink_pixels} differing pixels"
913    );
914}
915
916fn assert_clipped_text_frame(pixels: &[u8], width: u32, height: u32) {
917    assert_eq!((width, height), (220, 100));
918    let background = sample_pixel(pixels, width, 2, 2);
919    let total_ink = count_non_background_pixels(pixels, width, height, background);
920    assert_eq!(
921        total_ink, 0,
922        "fully clipped text should not draw ink, but observed {total_ink} differing pixels"
923    );
924}
925
926fn normalize_frame_region(frame: &RenderedFrame) -> Vec<u8> {
927    let rect = normalized_rect(frame);
928    let (width, height) = normalized_output_dimensions(frame);
929    normalize_rgba_region(
930        &frame.pixels,
931        frame.width,
932        frame.height,
933        rect,
934        width,
935        height,
936    )
937}
938
939fn assert_normalized_region_matches(
940    base: &RenderedFrame,
941    moved: &RenderedFrame,
942    budget: NormalizedDifferenceBudget,
943    message: &str,
944) {
945    assert_eq!(
946        normalized_output_dimensions(base),
947        normalized_output_dimensions(moved),
948        "normalized comparison requires matching output sizes",
949    );
950    let (width, height) = normalized_output_dimensions(base);
951    let base_normalized = normalize_frame_region(base);
952    let moved_normalized = normalize_frame_region(moved);
953    let stats = image_difference_stats(
954        &base_normalized,
955        &moved_normalized,
956        width,
957        height,
958        PIXEL_DIFFERENCE_TOLERANCE,
959    );
960    if stats.differing_pixels > budget.max_differing_pixels
961        || stats.max_difference > budget.max_pixel_difference
962    {
963        let diff = stats
964            .first_difference
965            .as_ref()
966            .expect("failing normalized comparison should report first difference");
967        panic!(
968            "{message}; differing_pixels={} max_diff={} first differing normalized pixel at ({}, {}) base={:?} moved={:?} diff={}",
969            stats.differing_pixels,
970            stats.max_difference,
971            diff.x,
972            diff.y,
973            diff.lhs,
974            diff.rhs,
975            diff.difference
976        );
977    }
978}
979
980fn normalized_rect(frame: &RenderedFrame) -> Rect {
981    frame
982        .normalized_rect
983        .expect("normalized render frame missing normalized_rect")
984}
985
986fn normalized_output_dimensions(frame: &RenderedFrame) -> (u32, u32) {
987    let rect = normalized_rect(frame);
988    (
989        normalized_dimension(rect.width, "width"),
990        normalized_dimension(rect.height, "height"),
991    )
992}
993
994fn normalized_dimension(value: f32, axis: &str) -> u32 {
995    let rounded = value.round();
996    assert!(
997        (value - rounded).abs() <= 0.01,
998        "normalized {axis} must stay pixel-sized for stable comparison, got {value}",
999    );
1000    assert!(
1001        rounded > 0.0,
1002        "normalized {axis} must be positive, got {value}"
1003    );
1004    rounded as u32
1005}
1006
1007fn is_background_like(pixel: [u8; 4], background: [u8; 4]) -> bool {
1008    pixel_difference(pixel, background) <= PIXEL_DIFFERENCE_TOLERANCE
1009}
1010
1011fn assert_pixel_matches_background(
1012    pixels: &[u8],
1013    width: u32,
1014    background: [u8; 4],
1015    x: u32,
1016    y: u32,
1017    expect_background: bool,
1018    message: &str,
1019) {
1020    let pixel = sample_pixel(pixels, width, x, y);
1021    let background_like = is_background_like(pixel, background);
1022    assert_eq!(
1023        background_like, expect_background,
1024        "{message}; pixel at ({x},{y}) was {pixel:?} against background {background:?}"
1025    );
1026}
1027
1028fn count_non_background_pixels(pixels: &[u8], width: u32, height: u32, background: [u8; 4]) -> u32 {
1029    count_non_background_pixels_in_band(pixels, width, 0, height, background)
1030}
1031
1032fn count_non_background_pixels_in_band(
1033    pixels: &[u8],
1034    width: u32,
1035    y_start: u32,
1036    y_end: u32,
1037    background: [u8; 4],
1038) -> u32 {
1039    let mut count = 0;
1040    for y in y_start..y_end {
1041        for x in 0..width {
1042            if !is_background_like(sample_pixel(pixels, width, x, y), background) {
1043                count += 1;
1044            }
1045        }
1046    }
1047    count
1048}
1049
1050fn ink_y_range(pixels: &[u8], width: u32, height: u32, background: [u8; 4]) -> Option<(u32, u32)> {
1051    let mut top = None;
1052    let mut bottom = 0u32;
1053    for y in 0..height {
1054        for x in 0..width {
1055            if !is_background_like(sample_pixel(pixels, width, x, y), background) {
1056                top.get_or_insert(y);
1057                bottom = y + 1;
1058                break;
1059            }
1060        }
1061    }
1062    top.map(|top_y| (top_y, bottom))
1063}
1064
1065#[cfg(test)]
1066#[path = "tests/render_contract_tests.rs"]
1067mod tests;