1use 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
13const 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
23fn 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 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 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 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 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); 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}