Skip to main content

laser_pdf/elements/
styled_box.rs

1use crate::{
2    utils::{mm_to_pt, u32_to_color_and_alpha},
3    *,
4};
5
6pub struct StyledBox<E: Element> {
7    pub element: E,
8    pub padding_left: f32,
9    pub padding_right: f32,
10    pub padding_top: f32,
11    pub padding_bottom: f32,
12    pub border_radius: f32,
13    pub fill: Option<u32>,
14    pub outline: Option<LineStyle>,
15}
16
17impl<E: Element> StyledBox<E> {
18    pub fn new(element: E) -> Self {
19        StyledBox {
20            element,
21            padding_top: 0.,
22            padding_bottom: 0.,
23            padding_left: 0.,
24            padding_right: 0.,
25            border_radius: 0.,
26            fill: None,
27            outline: None,
28        }
29    }
30}
31
32struct Common {
33    top: f32,
34    bottom: f32,
35    left: f32,
36    right: f32,
37
38    inner_width_constraint: WidthConstraint,
39    width: Option<f32>,
40}
41
42impl Common {
43    fn location(&self, pdf: &mut Pdf, location: &Location) -> Location {
44        Location {
45            pos: (location.pos.0 + self.left, location.pos.1 - self.top),
46            ..location.next_layer(pdf)
47        }
48    }
49
50    fn height(&self, input: f32) -> f32 {
51        input - self.top - self.bottom
52    }
53}
54
55impl<E: Element> StyledBox<E> {
56    fn common(&self, width: WidthConstraint) -> Common {
57        let extra_outline_offset = self.outline.map(|o| o.thickness).unwrap_or(0.0);
58
59        let top = self.padding_top + extra_outline_offset;
60        let bottom = self.padding_bottom + extra_outline_offset;
61        let left = self.padding_left + extra_outline_offset;
62        let right = self.padding_right + extra_outline_offset;
63
64        let inner_width_constraint = WidthConstraint {
65            max: width.max - left - right,
66            expand: width.expand,
67        };
68
69        let width = width.expand.then_some(inner_width_constraint.max);
70
71        Common {
72            top,
73            bottom,
74            left,
75            right,
76            inner_width_constraint,
77            width,
78        }
79    }
80
81    fn size(&self, common: &Common, size: ElementSize) -> ElementSize {
82        ElementSize {
83            width: common
84                .width
85                .or(size.width)
86                .map(|w| w + common.left + common.right),
87            height: size.height.map(|h| h + common.top + common.bottom),
88        }
89    }
90
91    fn draw_box(&self, pdf: &mut Pdf, location: &Location, size: (f32, f32)) {
92        use kurbo::{PathEl, RoundedRect, Shape};
93
94        let size = (
95            size.0 + self.padding_left + self.padding_right,
96            size.1 + self.padding_top + self.padding_bottom,
97        );
98
99        let thickness = self.outline.map(|o| o.thickness).unwrap_or(0.);
100        let half_thickness = thickness / 2.;
101
102        let fill_alpha = self
103            .fill
104            .map(|c| u32_to_color_and_alpha(c).1)
105            .filter(|&a| a != 1.);
106
107        let outline_alpha = self
108            .outline
109            .map(|o| u32_to_color_and_alpha(o.color).1)
110            .filter(|&a| a != 1.);
111
112        location.layer(pdf).save_state();
113
114        if fill_alpha.is_some() || outline_alpha.is_some() {
115            let ext_graphics_ref = pdf.alloc();
116
117            let mut ext_graphics = pdf.pdf.ext_graphics(ext_graphics_ref);
118            fill_alpha.inspect(|&a| {
119                ext_graphics.non_stroking_alpha(a);
120            });
121            outline_alpha.inspect(|&a| {
122                ext_graphics.stroking_alpha(a);
123            });
124
125            let resource_id = pdf.pages[location.page_idx].add_ext_g_state(ext_graphics_ref);
126            drop(ext_graphics);
127            location
128                .layer(pdf)
129                .set_parameters(Name(format!("{}", resource_id).as_bytes()));
130        }
131
132        let layer = location.layer(pdf);
133
134        if let Some(color) = self.fill {
135            let (color, _) = u32_to_color_and_alpha(color);
136
137            layer.set_fill_rgb(color[0], color[1], color[2]);
138        }
139
140        if let Some(line_style) = self.outline {
141            let (color, _) = u32_to_color_and_alpha(line_style.color);
142
143            layer
144                .set_line_width(mm_to_pt(thickness as f32))
145                .set_stroke_rgb(color[0], color[1], color[2])
146                .set_line_cap(line_style.cap_style.into());
147
148            if let Some(pattern) = line_style.dash_pattern {
149                layer.set_dash_pattern(pattern.dashes.map(f32::from), pattern.offset as f32);
150            }
151        }
152
153        let shape = RoundedRect::new(
154            mm_to_pt(location.pos.0 + half_thickness) as f64,
155            mm_to_pt(location.pos.1 - half_thickness) as f64,
156            mm_to_pt(location.pos.0 + size.0 + thickness + half_thickness) as f64,
157            mm_to_pt(location.pos.1 - size.1 - thickness - half_thickness) as f64,
158            mm_to_pt(self.border_radius) as f64,
159        );
160
161        let els = shape.path_elements(0.1);
162
163        let mut closed = false;
164
165        for el in els {
166            use PathEl::*;
167
168            match el {
169                MoveTo(point) => {
170                    layer.move_to(point.x as f32, point.y as f32);
171                }
172                LineTo(point) => {
173                    layer.line_to(point.x as f32, point.y as f32);
174                }
175                QuadTo(a, b) => {
176                    layer.cubic_to_initial(a.x as f32, a.y as f32, b.x as f32, b.y as f32);
177                }
178                CurveTo(a, b, c) => {
179                    layer.cubic_to(
180                        a.x as f32, a.y as f32, b.x as f32, b.y as f32, c.x as f32, c.y as f32,
181                    );
182                }
183                ClosePath => closed = true,
184            };
185        }
186
187        match (self.outline.is_some(), self.fill.is_some(), closed) {
188            (true, true, true) => layer.close_fill_nonzero_and_stroke(),
189            (true, true, false) => layer.fill_nonzero(),
190            (true, false, true) => layer.close_and_stroke(),
191            (true, false, false) => layer.stroke(),
192            (false, true, _) => layer.fill_nonzero(),
193            _ => layer.end_path(),
194        };
195
196        layer.restore_state();
197    }
198}
199
200impl<E: Element> Element for StyledBox<E> {
201    fn first_location_usage(&self, ctx: FirstLocationUsageCtx) -> FirstLocationUsage {
202        let common = self.common(ctx.width);
203        let first_height = common.height(ctx.first_height);
204        let full_height = common.height(ctx.full_height);
205
206        self.element.first_location_usage(FirstLocationUsageCtx {
207            text_pieces_cache: ctx.text_pieces_cache,
208            width: common.inner_width_constraint,
209            first_height,
210            full_height,
211        })
212    }
213
214    fn measure(&self, ctx: MeasureCtx) -> ElementSize {
215        let common = self.common(ctx.width);
216        let first_height = common.height(ctx.first_height);
217
218        let size = if let Some(breakable) = ctx.breakable {
219            let full_height = common.height(breakable.full_height);
220
221            let size = self.element.measure(MeasureCtx {
222                text_pieces_cache: ctx.text_pieces_cache,
223                width: common.inner_width_constraint,
224                first_height,
225                breakable: Some(BreakableMeasure {
226                    full_height,
227                    break_count: breakable.break_count,
228                    extra_location_min_height: breakable.extra_location_min_height,
229                }),
230            });
231
232            *breakable.extra_location_min_height = breakable
233                .extra_location_min_height
234                .map(|x| x + self.padding_top + self.padding_bottom);
235
236            size
237        } else {
238            self.element.measure(MeasureCtx {
239                text_pieces_cache: ctx.text_pieces_cache,
240                width: common.inner_width_constraint,
241                first_height,
242                breakable: None,
243            })
244        };
245
246        self.size(&common, size)
247    }
248
249    fn draw(&self, ctx: DrawCtx) -> ElementSize {
250        let common = self.common(ctx.width);
251        let first_height = common.height(ctx.first_height);
252
253        let size = if let Some(breakable) = ctx.breakable {
254            let full_height = common.height(breakable.full_height);
255
256            let mut break_count = 0;
257
258            let width = if ctx.width.expand {
259                Some(ctx.width.max - common.left - common.right)
260            } else {
261                let mut break_count = 0;
262                let mut extra_location_min_height = None;
263
264                self.element
265                    .measure(MeasureCtx {
266                        text_pieces_cache: ctx.text_pieces_cache,
267                        width: common.inner_width_constraint,
268                        first_height,
269                        breakable: Some(BreakableMeasure {
270                            full_height,
271                            break_count: &mut break_count,
272                            extra_location_min_height: &mut extra_location_min_height,
273                        }),
274                    })
275                    .width
276            };
277
278            let element_location = common.location(ctx.pdf, &ctx.location);
279            let mut last_location = ctx.location;
280            let size = self.element.draw(DrawCtx {
281                pdf: ctx.pdf,
282                text_pieces_cache: ctx.text_pieces_cache,
283                location: element_location,
284                width: common.inner_width_constraint,
285                first_height,
286                preferred_height: ctx.preferred_height.map(|p| common.height(p)),
287                breakable: Some(BreakableDraw {
288                    full_height,
289                    preferred_height_break_count: breakable.preferred_height_break_count,
290                    do_break: &mut |pdf, location_idx, height| {
291                        let location = (breakable.do_break)(
292                            pdf,
293                            location_idx,
294                            height.map(|h| h + common.top + common.bottom),
295                        );
296
297                        match (width, height) {
298                            (Some(width), Some(height)) if location_idx >= break_count => {
299                                let location = if location_idx == break_count {
300                                    &last_location
301                                } else {
302                                    &(breakable.do_break)(pdf, location_idx, None)
303                                };
304
305                                self.draw_box(pdf, location, (width, height));
306                            }
307                            _ => (),
308                        }
309
310                        let ret = common.location(pdf, &location);
311                        if location_idx >= break_count {
312                            last_location = location;
313                        }
314                        break_count = break_count.max(location_idx + 1);
315                        ret
316                    },
317                }),
318            });
319
320            if let (Some(width), Some(height)) = (width, size.height) {
321                self.draw_box(ctx.pdf, &last_location, (width, height));
322            }
323
324            size
325        } else {
326            let location = common.location(ctx.pdf, &ctx.location);
327
328            let size = self.element.draw(DrawCtx {
329                pdf: ctx.pdf,
330                text_pieces_cache: ctx.text_pieces_cache,
331                location,
332                preferred_height: ctx.preferred_height.map(|p| common.height(p)),
333                width: common.inner_width_constraint,
334                first_height,
335                breakable: None,
336            });
337
338            if let ElementSize {
339                width: Some(width),
340                height: Some(height),
341            } = size
342            {
343                self.draw_box(ctx.pdf, &ctx.location, (width, height));
344            }
345
346            size
347        };
348
349        self.size(&common, size)
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::{
357        elements::{
358            rectangle::Rectangle,
359            ref_element::RefElement,
360            row::{Flex, Row},
361            text::Text,
362        },
363        fonts::builtin::BuiltinFont,
364        test_utils::{
365            record_passes::{Break, BreakableDraw, DrawPass, RecordPasses},
366            *,
367        },
368    };
369
370    #[test]
371    fn test_unbreakable() {
372        let width = WidthConstraint {
373            max: 7.,
374            expand: false,
375        };
376        let first_height = 30.;
377        let pos = (2., 10.);
378
379        let output = test_element(
380            TestElementParams {
381                width,
382                first_height,
383                breakable: None,
384                pos,
385                ..Default::default()
386            },
387            |assert, callback| {
388                let content = RecordPasses::new(FakeText {
389                    lines: 3,
390                    line_height: 5.,
391                    width: 3.,
392                });
393
394                let element = StyledBox {
395                    padding_left: 1.,
396                    padding_right: 2.,
397                    padding_top: 3.,
398                    padding_bottom: 4.,
399
400                    ..StyledBox::new(RefElement(&content))
401                };
402
403                let ret = callback.call(element);
404
405                if assert {
406                    content.assert_first_location_usage_count(0);
407                    content.assert_measure_count(0);
408                    content.assert_draw(DrawPass {
409                        width: WidthConstraint {
410                            max: 4.,
411                            expand: false,
412                        },
413                        first_height: 23.,
414                        preferred_height: None,
415                        page: 0,
416                        layer: 1,
417                        pos: (3., 7.),
418                        breakable: None,
419                    });
420                }
421
422                ret
423            },
424        );
425
426        output.assert_size(ElementSize {
427            width: Some(6.),
428            height: Some(22.),
429        });
430    }
431
432    #[test]
433    fn test_pre_break() {
434        let width = WidthConstraint {
435            max: 7.,
436            expand: false,
437        };
438        let first_height = 9.;
439        let full_height = 18.;
440        let pos = (2., 18.);
441
442        let output = test_element(
443            TestElementParams {
444                width,
445                first_height,
446                breakable: Some(TestElementParamsBreakable {
447                    full_height,
448                    ..Default::default()
449                }),
450                pos,
451                ..Default::default()
452            },
453            |assert, callback| {
454                let content = RecordPasses::new(FakeText {
455                    lines: 3,
456                    line_height: 5.,
457                    width: 3.,
458                });
459
460                let element = StyledBox {
461                    padding_left: 1.,
462                    padding_right: 2.,
463                    padding_top: 3.,
464                    padding_bottom: 4.,
465
466                    ..StyledBox::new(RefElement(&content))
467                };
468
469                let ret = callback.call(element);
470
471                if assert {
472                    content.assert_first_location_usage_count(0);
473                    content.assert_measure_count(1);
474                    content.assert_draw(DrawPass {
475                        width: WidthConstraint {
476                            max: 4.,
477                            expand: false,
478                        },
479                        first_height: 2.,
480                        preferred_height: None,
481                        page: 0,
482                        layer: 1,
483                        pos: (3., 6.),
484                        breakable: Some(BreakableDraw {
485                            full_height: 11.,
486                            preferred_height_break_count: 0,
487                            breaks: vec![
488                                // we don't actually pre-break anymore
489                                Break {
490                                    page: 1,
491                                    layer: 1,
492                                    pos: (3., 15.),
493                                },
494                                Break {
495                                    page: 2,
496                                    layer: 1,
497                                    pos: (3., 15.),
498                                },
499                            ],
500                        }),
501                    });
502                }
503
504                ret
505            },
506        );
507
508        output.assert_size(ElementSize {
509            width: Some(6.),
510            height: Some(12.),
511        });
512
513        output
514            .breakable
515            .unwrap()
516            .assert_first_location_usage(FirstLocationUsage::WillSkip)
517            .assert_break_count(2)
518            .assert_extra_location_min_height(None);
519    }
520
521    #[test]
522    fn test_x_size() {
523        use crate::test_utils::binary_snapshots::*;
524        use insta::*;
525
526        let bytes = test_element_bytes(TestElementParams::breakable(), |callback| {
527            // let font = BuiltinFont::courier(callback.document());
528
529            // // let first = Text::basic("test", &font, 12.);
530            let first = Rectangle {
531                size: (12., 12.),
532                fill: Some(0x00_00_77_FF),
533                outline: Some((2., 0x00_00_00_FF)),
534            };
535            let first = first.debug(1).show_max_width();
536
537            callback.call(
538                &StyledBox {
539                    element: first,
540                    padding_left: 1.,
541                    padding_right: 2.,
542                    padding_top: 3.,
543                    padding_bottom: 4.,
544                    border_radius: 1.,
545                    fill: None,
546                    outline: Some(LineStyle {
547                        thickness: 1.,
548                        color: 0x00_00_00_FF,
549                        dash_pattern: None,
550                        cap_style: LineCapStyle::Butt,
551                    }),
552                }
553                .debug(0)
554                .show_max_width()
555                .show_last_location_max_height(),
556            );
557        });
558        assert_binary_snapshot!(".pdf", bytes);
559    }
560
561    #[test]
562    fn test_border_sizing() {
563        use crate::test_utils::binary_snapshots::*;
564        use insta::*;
565
566        let bytes = test_element_bytes(TestElementParams::breakable(), |callback| {
567            let first = Rectangle {
568                size: (12., 12.),
569                fill: Some(0x00_00_77_FF),
570                outline: None,
571            };
572            let first = first.debug(1).show_max_width();
573
574            callback.call(
575                &StyledBox {
576                    outline: Some(LineStyle {
577                        thickness: 32.,
578                        color: 0x00_00_00_FF,
579                        dash_pattern: None,
580                        cap_style: LineCapStyle::Butt,
581                    }),
582                    ..StyledBox::new(first)
583                }
584                .debug(0)
585                .show_max_width()
586                .show_last_location_max_height(),
587            );
588        });
589        assert_binary_snapshot!(".pdf", bytes);
590    }
591
592    #[test]
593    fn test_last_location() {
594        use crate::test_utils::binary_snapshots::*;
595        use insta::*;
596
597        let bytes = test_element_bytes(TestElementParams::breakable(), |mut callback| {
598            let font = BuiltinFont::courier(callback.pdf());
599
600            let text_a = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam mauris \
601                massa, sollicitudin nec nunc eu, lacinia venenatis felis. Etiam non tempus nisl, \
602                euismod accumsan arcu. Vivamus aliquam lorem a odio maximus volutpat. Phasellus \
603                volutpat leo quis varius posuere. Nam sagittis nisl eget suscipit pretium. Donec \
604                varius tortor eget nibh maximus sagittis. Duis id libero eu mi vulputate congue id \
605                eu est. Maecenas pellentesque massa id dui fringilla, et porta nulla imperdiet. \
606                Sed eu est rutrum, scelerisque tortor ac, lacinia turpis. \
607                Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam mauris \
608                massa, sollicitudin nec nunc eu, lacinia venenatis felis. Etiam non tempus nisl, \
609                euismod accumsan arcu. Vivamus aliquam lorem a odio maximus volutpat. Phasellus \
610                volutpat leo quis varius posuere. Nam sagittis nisl eget suscipit pretium. Donec \
611                varius tortor eget nibh maximus sagittis. Duis id libero eu mi vulputate congue id \
612                eu est. Maecenas pellentesque massa id dui fringilla, et porta nulla imperdiet. \
613                Sed eu est rutrum, scelerisque tortor ac, lacinia turpis. \
614                Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam mauris \
615                massa, sollicitudin nec nunc eu, lacinia venenatis felis. Etiam non tempus nisl, \
616                euismod accumsan arcu. Vivamus aliquam lorem a odio maximus volutpat. Phasellus \
617                volutpat leo quis varius posuere. Nam sagittis nisl eget suscipit pretium. Donec \
618                varius tortor eget nibh maximus sagittis. Duis id libero eu mi vulputate congue id \
619                eu est. Maecenas pellentesque massa id dui fringilla, et porta nulla imperdiet. \
620                Sed eu est rutrum, scelerisque tortor ac, lacinia turpis.";
621            let text_b = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam mauris \
622                massa, sollicitudin nec nunc eu, lacinia venenatis felis. Etiam non tempus nisl, \
623                euismod accumsan arcu. Vivamus aliquam lorem a odio maximus volutpat. Phasellus \
624                volutpat leo quis varius posuere. Nam sagittis nisl eget suscipit pretium. Donec \
625                varius tortor eget nibh maximus sagittis. Duis id libero eu mi vulputate congue id \
626                eu est. Maecenas pellentesque massa id dui fringilla, et porta nulla imperdiet. \
627                Sed eu est rutrum, scelerisque tortor ac, lacinia turpis. \
628                Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam mauris \
629                massa, sollicitudin nec nunc eu, lacinia venenatis felis. Etiam non tempus nisl, \
630                euismod accumsan arcu. Vivamus aliquam lorem a odio maximus volutpat. Phasellus \
631                volutpat leo quis varius posuere. Nam sagittis nisl eget suscipit pretium. Donec \
632                varius tortor eget nibh maximus sagittis. Duis id libero eu mi vulputate congue id \
633                eu est. Maecenas pellentesque massa id dui fringilla, et porta nulla imperdiet. \
634                Sed eu est rutrum, scelerisque tortor ac, lacinia turpis.";
635
636            callback.call(
637                &StyledBox {
638                    fill: Some(0x00_00_FF_FF),
639                    ..StyledBox::new(Row::new(|content| {
640                        content.add(&Text::basic(text_a, &font, 24.), Flex::Expand(1));
641                        content.add(&Text::basic(text_b, &font, 24.), Flex::Expand(1));
642                    }))
643                }
644                .debug(0)
645                .show_max_width()
646                .show_last_location_max_height(),
647            );
648        });
649        assert_binary_snapshot!(".pdf", bytes);
650    }
651}