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            alt: self.alt.clone(),
70        };
71        LayoutResult::Fit(RenderNode::clipped(area, node))
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::font_resolver::{FontMetrics, FontResolver};
79    use lightweight_pdf_core::FontKey;
80
81    struct NoopMetrics;
82    impl FontMetrics for NoopMetrics {
83        fn advance(&self, _ch: char) -> f32 {
84            500.0
85        }
86        fn ascent(&self) -> f32 {
87            800.0
88        }
89        fn descent(&self) -> f32 {
90            -200.0
91        }
92    }
93    struct NoopResolver;
94    impl FontResolver for NoopResolver {
95        fn metrics(&self, _key: FontKey) -> &dyn FontMetrics {
96            &NoopMetrics
97        }
98    }
99    fn ctx() -> LayoutCtx<'static> {
100        LayoutCtx::new(&NoopResolver)
101    }
102
103    fn image(width_px: u32, height_px: u32) -> Image {
104        // A 1x1 minimal PNG is enough to construct a valid `Image` for
105        // layout math tests — this module never touches pixel data.
106        let png: [u8; 67] = [
107            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,
108            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,
109            0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, b'I', b'E', b'N', b'D', 0xAE, 0x42, 0x60, 0x82,
110        ];
111        let mut img = Image::new(png.to_vec()).expect("valid minimal PNG");
112        img.width_px = width_px;
113        img.height_px = height_px;
114        img
115    }
116
117    #[test]
118    fn fills_target_box_when_both_dimensions_are_explicit_and_aspect_matches() {
119        let img = image(100, 100).width(50.0).height(50.0);
120        let c = ctx();
121        let size = img.measure(
122            &c,
123            Constraints {
124                max_width: 500.0,
125                max_height: 500.0,
126            },
127        );
128        assert_eq!((size.width, size.height), (50.0, 50.0));
129    }
130
131    #[test]
132    fn contain_leaves_slack_on_the_shorter_axis_when_aspect_does_not_match() {
133        // 2:1 natural aspect into a 1:1 box -> width-limited, height gets slack.
134        let img = image(200, 100).width(50.0).height(50.0);
135        let c = ctx();
136        let size = img.measure(
137            &c,
138            Constraints {
139                max_width: 500.0,
140                max_height: 500.0,
141            },
142        );
143        assert_eq!(size.width, 50.0);
144        assert_eq!(size.height, 25.0, "must preserve aspect ratio, not stretch to fill the box");
145    }
146
147    #[test]
148    fn single_dimension_preserves_aspect_exactly() {
149        let img = image(200, 100).width(80.0);
150        let c = ctx();
151        let size = img.measure(
152            &c,
153            Constraints {
154                max_width: 500.0,
155                max_height: 500.0,
156            },
157        );
158        assert_eq!(size.width, 80.0);
159        assert!((size.height - 40.0).abs() < 0.01);
160    }
161
162    #[test]
163    fn shrinks_to_fit_available_width_when_natural_size_overflows() {
164        // Natural width at 96 DPI: 2000px * 0.75 = 1500pt, way over a
165        // typical page's available width.
166        let img = image(2000, 1000);
167        let c = ctx();
168        let size = img.measure(
169            &c,
170            Constraints {
171                max_width: 300.0,
172                max_height: f32::INFINITY,
173            },
174        );
175        assert!(size.width <= 300.0 + 0.01);
176        assert!(
177            (size.width / size.height - 2.0).abs() < 0.01,
178            "aspect ratio must be preserved while shrinking"
179        );
180    }
181
182    #[test]
183    fn never_exceeds_an_explicit_box_even_when_natural_size_is_smaller() {
184        // object-fit: contain also scales *up* to fill the box, matching
185        // "proportional eingepasst" wording (not "never upscale").
186        let img = image(10, 10).width(200.0).height(200.0);
187        let c = ctx();
188        let size = img.measure(
189            &c,
190            Constraints {
191                max_width: 500.0,
192                max_height: 500.0,
193            },
194        );
195        assert_eq!((size.width, size.height), (200.0, 200.0));
196    }
197
198    #[test]
199    fn layout_centers_the_contained_image_within_a_larger_box() {
200        let img = image(200, 100).width(50.0).height(50.0); // -> 50x25, centered in a 50x50 box
201        let c = ctx();
202        let mut warnings = Vec::new();
203        let area = Rect {
204            x: 10.0,
205            y: 20.0,
206            width: 50.0,
207            height: 50.0,
208        };
209        let LayoutResult::Fit(RenderNode::Group { children, .. }) = img.layout(&c, area, &mut warnings, 1) else {
210            panic!("expected Fit Group (clip wrapper)");
211        };
212        let RenderNode::Image { area: img_area, .. } = &children[0] else {
213            panic!("expected Image node");
214        };
215        assert_eq!(img_area.width, 50.0);
216        assert_eq!(img_area.height, 25.0);
217        assert_eq!(
218            img_area.y,
219            20.0 + (50.0 - 25.0) / 2.0,
220            "must be vertically centered in the slack axis"
221        );
222    }
223}