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 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 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 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 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 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); 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}