Skip to main content

lightweight_pdf_layout/
image.rs

1//! `Image` layout (Phase 5, `plan/phases/phase-5-images.md` step 1):
2//! constraint-based scaling with `Contain` as the only/default fit — the
3//! image is scaled proportionally to fit entirely inside its target box,
4//! never cropped, never stretched, never drawn past the box
5//! (`05-overflow-and-robustness.md`'s element table: "Default `Contain`-Fit").
6
7use crate::geometry::{Constraints, Rect, Size};
8use crate::layoutable::{LayoutCtx, LayoutResult, Layoutable};
9use crate::render_node::RenderNode;
10use crate::warnings::LayoutWarning;
11use lightweight_pdf_core::Image;
12
13/// Assumed density when neither explicit `.width()`/`.height()` is set and
14/// the image carries no density metadata (V1 doesn't parse JFIF/`pHYs`
15/// density — a documented simplification, see `plan/progress.md`): 96 CSS
16/// pixels per inch, the common web/document default.
17const PX_TO_PT: f32 = 72.0 / 96.0;
18
19fn natural_size_pt(image: &Image) -> (f32, f32) {
20    (image.width_px as f32 * PX_TO_PT, image.height_px as f32 * PX_TO_PT)
21}
22
23/// Resolves the actual drawn size for a given bound. `Contain`: scale the
24/// natural aspect ratio down (or up) to fit entirely within the bound,
25/// never exceeding it on either axis.
26fn contain_size(image: &Image, bound_w: f32, bound_h: f32) -> (f32, f32) {
27    let (nw, nh) = natural_size_pt(image);
28    if nw <= 0.0 || nh <= 0.0 {
29        return (0.0, 0.0);
30    }
31    match (image.common.width, image.common.height) {
32        (Some(w), Some(h)) => {
33            let scale = (w / nw).min(h / nh);
34            (nw * scale, nh * scale)
35        }
36        (Some(w), None) => (w, nh * (w / nw)),
37        (None, Some(h)) => (nw * (h / nh), h),
38        (None, None) => {
39            if bound_w.is_finite() && nw > bound_w {
40                let scale = (bound_w / nw).min(if bound_h.is_finite() { bound_h / nh } else { f32::INFINITY });
41                (nw * scale, nh * scale)
42            } else if bound_h.is_finite() && nh > bound_h {
43                let scale = bound_h / nh;
44                (nw * scale, nh * scale)
45            } else {
46                (nw, nh)
47            }
48        }
49    }
50}
51
52impl Layoutable for Image {
53    fn measure(&self, _ctx: &LayoutCtx, constraints: Constraints) -> Size {
54        let (w, h) = contain_size(self, constraints.max_width, constraints.max_height);
55        Size { width: w, height: h }
56    }
57
58    fn layout(&self, _ctx: &LayoutCtx, area: Rect, _warnings: &mut Vec<LayoutWarning>, _page: usize) -> LayoutResult {
59        let (w, h) = contain_size(self, area.width, area.height);
60        let x = area.x + ((area.width - w).max(0.0)) / 2.0;
61        let y = area.y + ((area.height - h).max(0.0)) / 2.0;
62        let node = RenderNode::Image {
63            area: Rect { x, y, width: w, height: h },
64            bytes: self.bytes.clone(),
65            format: self.format,
66            width_px: self.width_px,
67            height_px: self.height_px,
68            components: self.components,
69        };
70        LayoutResult::Fit(RenderNode::clipped(area, node))
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use crate::font_resolver::{FontMetrics, FontResolver};
78    use lightweight_pdf_core::FontKey;
79
80    struct NoopMetrics;
81    impl FontMetrics for NoopMetrics {
82        fn advance(&self, _ch: char) -> f32 {
83            500.0
84        }
85        fn ascent(&self) -> f32 {
86            800.0
87        }
88        fn descent(&self) -> f32 {
89            -200.0
90        }
91    }
92    struct NoopResolver;
93    impl FontResolver for NoopResolver {
94        fn metrics(&self, _key: FontKey) -> &dyn FontMetrics {
95            &NoopMetrics
96        }
97    }
98    fn ctx() -> LayoutCtx<'static> {
99        LayoutCtx { resolver: &NoopResolver }
100    }
101
102    fn image(width_px: u32, height_px: u32) -> Image {
103        // A 1x1 minimal PNG is enough to construct a valid `Image` for
104        // layout math tests — this module never touches pixel data.
105        let png: [u8; 67] = [
106            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, b'I', b'H', b'D', b'R', 0, 0, 0, 1, 0, 0, 0, 1, 8, 6,
107            0, 0, 0, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0D, b'I', b'D', b'A', b'T', 0x78, 0x9C, 0x62, 0x00, 0x01, 0x00, 0x00,
108            0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, b'I', b'E', b'N', b'D', 0xAE, 0x42, 0x60, 0x82,
109        ];
110        let mut img = Image::new(png.to_vec()).expect("valid minimal PNG");
111        img.width_px = width_px;
112        img.height_px = height_px;
113        img
114    }
115
116    #[test]
117    fn fills_target_box_when_both_dimensions_are_explicit_and_aspect_matches() {
118        let img = image(100, 100).width(50.0).height(50.0);
119        let c = ctx();
120        let size = img.measure(
121            &c,
122            Constraints {
123                max_width: 500.0,
124                max_height: 500.0,
125            },
126        );
127        assert_eq!((size.width, size.height), (50.0, 50.0));
128    }
129
130    #[test]
131    fn contain_leaves_slack_on_the_shorter_axis_when_aspect_does_not_match() {
132        // 2:1 natural aspect into a 1:1 box -> width-limited, height gets slack.
133        let img = image(200, 100).width(50.0).height(50.0);
134        let c = ctx();
135        let size = img.measure(
136            &c,
137            Constraints {
138                max_width: 500.0,
139                max_height: 500.0,
140            },
141        );
142        assert_eq!(size.width, 50.0);
143        assert_eq!(size.height, 25.0, "must preserve aspect ratio, not stretch to fill the box");
144    }
145
146    #[test]
147    fn single_dimension_preserves_aspect_exactly() {
148        let img = image(200, 100).width(80.0);
149        let c = ctx();
150        let size = img.measure(
151            &c,
152            Constraints {
153                max_width: 500.0,
154                max_height: 500.0,
155            },
156        );
157        assert_eq!(size.width, 80.0);
158        assert!((size.height - 40.0).abs() < 0.01);
159    }
160
161    #[test]
162    fn shrinks_to_fit_available_width_when_natural_size_overflows() {
163        // Natural width at 96 DPI: 2000px * 0.75 = 1500pt, way over a
164        // typical page's available width.
165        let img = image(2000, 1000);
166        let c = ctx();
167        let size = img.measure(
168            &c,
169            Constraints {
170                max_width: 300.0,
171                max_height: f32::INFINITY,
172            },
173        );
174        assert!(size.width <= 300.0 + 0.01);
175        assert!(
176            (size.width / size.height - 2.0).abs() < 0.01,
177            "aspect ratio must be preserved while shrinking"
178        );
179    }
180
181    #[test]
182    fn never_exceeds_an_explicit_box_even_when_natural_size_is_smaller() {
183        // object-fit: contain also scales *up* to fill the box, matching
184        // "proportional eingepasst" wording (not "never upscale").
185        let img = image(10, 10).width(200.0).height(200.0);
186        let c = ctx();
187        let size = img.measure(
188            &c,
189            Constraints {
190                max_width: 500.0,
191                max_height: 500.0,
192            },
193        );
194        assert_eq!((size.width, size.height), (200.0, 200.0));
195    }
196
197    #[test]
198    fn layout_centers_the_contained_image_within_a_larger_box() {
199        let img = image(200, 100).width(50.0).height(50.0); // -> 50x25, centered in a 50x50 box
200        let c = ctx();
201        let mut warnings = Vec::new();
202        let area = Rect {
203            x: 10.0,
204            y: 20.0,
205            width: 50.0,
206            height: 50.0,
207        };
208        let LayoutResult::Fit(RenderNode::Group { children, .. }) = img.layout(&c, area, &mut warnings, 1) else {
209            panic!("expected Fit Group (clip wrapper)");
210        };
211        let RenderNode::Image { area: img_area, .. } = &children[0] else {
212            panic!("expected Image node");
213        };
214        assert_eq!(img_area.width, 50.0);
215        assert_eq!(img_area.height, 25.0);
216        assert_eq!(
217            img_area.y,
218            20.0 + (50.0 - 25.0) / 2.0,
219            "must be vertically centered in the slack axis"
220        );
221    }
222}