Skip to main content

laser_pdf/elements/
column.rs

1use crate::*;
2
3use self::utils::add_optional_size_with_gap;
4
5/// A container that arranges child elements vertically with optional gaps.
6///
7/// Elements are laid out from top to bottom with configurable spacing between them.
8/// Supports page breaking and collapsing behavior for empty elements.
9///
10/// ## Gap Behavior
11///
12/// Gaps are only applied between elements that have actual height. Elements with
13/// `None` height (collapsed elements) don't get gaps before or after them.
14///
15/// ## Collapse Behavior
16///
17/// When `collapse: true` (default):
18/// - If all children have `None` height, the column returns `None` height
19/// - If all children have `None` width, the column returns `None` width
20/// - The gaps around a child with a `None` height are collapsed into one.
21///
22/// When `collapse: false`:
23/// - Empty columns return `Some(0.0)` for height/width instead of `None`
24/// - Useful when you need a column to always occupy space even when empty
25/// - A child with a `None` height will still have a gap on either side
26///
27/// ## Page Breaking
28///
29/// In breakable contexts, when a child element causes a page break, the column's
30/// accumulated height is reset and continues on the new page.
31pub struct Column<C: Fn(ColumnContent) -> Option<()>> {
32    /// Closure that gets called for adding the content.
33    ///
34    /// The closure is basically an internal iterator that produces elements by calling
35    /// [ColumnContent::add]. For short circuiting with the `?` operator [ColumnContent::add] and
36    /// this closure return an [Option].
37    ///
38    /// If the column is in a context that measures it before drawing (such as `BreakWhole`), this
39    /// function will be called twice. In more complicated nested layouts it could be called more
40    /// than that (though in real world layouts this effect should be minimal as not all containers
41    /// need a measure pass before drawing). Because of this it's beneficial to keep expensive
42    /// computations and allocation outside of this closure.
43    pub content: C,
44    /// Vertical spacing between elements in millimeters
45    pub gap: f32,
46    /// Whether to collapse to None size when all children are collapsed.
47    /// When false, empty columns return Some(0.0) instead of None.
48    pub collapse: bool,
49}
50
51impl<C: Fn(ColumnContent) -> Option<()>> Column<C> {
52    pub fn new(content: C) -> Self {
53        Column {
54            content,
55            gap: 0.,
56            collapse: true,
57        }
58    }
59
60    pub fn with_gap(self, gap: f32) -> Self {
61        Column { gap, ..self }
62    }
63
64    pub fn with_collapse(self, collapse: bool) -> Self {
65        Column { collapse, ..self }
66    }
67}
68
69impl<C: Fn(ColumnContent) -> Option<()>> Element for Column<C> {
70    fn first_location_usage(&self, ctx: FirstLocationUsageCtx) -> FirstLocationUsage {
71        let mut ret = FirstLocationUsage::NoneHeight;
72
73        (self.content)(ColumnContent {
74            pass: Pass::InsufficientFirstHeight { ctx, ret: &mut ret },
75            gap: self.gap,
76        });
77
78        if !self.collapse && ret == FirstLocationUsage::NoneHeight {
79            ret = FirstLocationUsage::WillUse;
80        }
81
82        ret
83    }
84
85    fn measure(&self, mut ctx: MeasureCtx) -> ElementSize {
86        let mut width = None;
87        let mut height = None;
88        let mut break_count = 0;
89
90        (self.content)(ColumnContent {
91            pass: Pass::Measure {
92                text_pieces_cache: ctx.text_pieces_cache,
93                width_constraint: ctx.width,
94                breakable: ctx.breakable.as_mut().map(|b| BreakableMeasure {
95                    break_count: &mut break_count,
96                    extra_location_min_height: b.extra_location_min_height,
97                    ..*b
98                }),
99                height_available: ctx.first_height,
100                width: &mut width,
101                height: &mut height,
102            },
103            gap: self.gap,
104        });
105
106        if let Some(breakable) = ctx.breakable {
107            *breakable.break_count = break_count;
108        }
109
110        if !self.collapse {
111            if height.is_none() && break_count == 0 {
112                height = Some(0.);
113            }
114
115            if width.is_none() {
116                width = Some(0.);
117            }
118        }
119
120        ElementSize { width, height }
121    }
122
123    fn draw(&self, ctx: DrawCtx) -> ElementSize {
124        let mut width = None;
125        let mut height = None;
126        let mut location_offset = 0;
127
128        (self.content)(ColumnContent {
129            pass: Pass::Draw {
130                pdf: ctx.pdf,
131                text_pieces_cache: ctx.text_pieces_cache,
132                location: ctx.location,
133                location_offset: &mut location_offset,
134                width_constraint: ctx.width,
135                breakable: ctx.breakable,
136                height_available: ctx.first_height,
137                width: &mut width,
138                height: &mut height,
139            },
140            gap: self.gap,
141        });
142
143        if !self.collapse {
144            if height.is_none() && location_offset == 0 {
145                height = Some(0.);
146            }
147
148            if width.is_none() {
149                width = Some(0.);
150            }
151        }
152
153        ElementSize { width, height }
154    }
155}
156
157pub struct ColumnContent<'a, 'b, 'r> {
158    pass: Pass<'a, 'b, 'r>,
159    gap: f32,
160}
161
162enum Pass<'a, 'b, 'r> {
163    InsufficientFirstHeight {
164        ctx: FirstLocationUsageCtx<'a>,
165        ret: &'r mut FirstLocationUsage,
166    },
167    Measure {
168        text_pieces_cache: &'a TextPiecesCache,
169        width_constraint: WidthConstraint,
170        breakable: Option<BreakableMeasure<'a>>,
171
172        /// this is initially first_height and when breaking we set it to full height
173        height_available: f32,
174        width: &'r mut Option<f32>,
175        height: &'r mut Option<f32>,
176    },
177    Draw {
178        pdf: &'a mut Pdf,
179        text_pieces_cache: &'a TextPiecesCache,
180        location: Location,
181        location_offset: &'r mut u32,
182        width_constraint: WidthConstraint,
183        breakable: Option<BreakableDraw<'b>>,
184
185        /// this is initially first_height and when breaking we set it to full height
186        height_available: f32,
187        width: &'r mut Option<f32>,
188        height: &'r mut Option<f32>,
189    },
190}
191
192impl<'a, 'b, 'r> ColumnContent<'a, 'b, 'r> {
193    pub fn add<E: Element>(mut self, element: &E) -> Option<Self> {
194        match self.pass {
195            Pass::InsufficientFirstHeight {
196                ref mut ctx,
197                ret: &mut ref mut ret,
198            } => {
199                let first_location_usage =
200                    element.first_location_usage(FirstLocationUsageCtx { ..*ctx });
201
202                if first_location_usage == FirstLocationUsage::NoneHeight {
203                    Some(self)
204                } else {
205                    *ret = first_location_usage;
206                    None
207                }
208            }
209            Pass::Measure {
210                text_pieces_cache,
211                width_constraint,
212                ref mut breakable,
213                ref mut height_available,
214                width: &mut ref mut width,
215                height: &mut ref mut height,
216            } => {
217                // The gap is applied here, but will only be actually applied to the height and
218                // position for subsequent elements if this element ends up having a height.
219                let measure_ctx = MeasureCtx {
220                    text_pieces_cache,
221                    width: width_constraint,
222                    first_height: *height_available
223                        - height.unwrap_or(0.)
224                        - if height.is_some() { self.gap } else { 0. },
225                    breakable: None,
226                };
227
228                let size;
229
230                if let Some(b) = breakable {
231                    let mut break_count = 0;
232
233                    // We ignore this because we also don't pass on preferred height.
234                    let mut extra_location_min_height = None;
235
236                    size = element.measure(MeasureCtx {
237                        breakable: Some(BreakableMeasure {
238                            full_height: b.full_height,
239                            break_count: &mut break_count,
240                            extra_location_min_height: &mut extra_location_min_height,
241                        }),
242                        ..measure_ctx
243                    });
244
245                    if break_count > 0 {
246                        *height_available = b.full_height;
247                        *height = None;
248                        *b.break_count += break_count;
249                    }
250                } else {
251                    size = element.measure(measure_ctx);
252                }
253
254                if let Some(h) = size.height {
255                    if let Some(height) = height {
256                        *height += self.gap;
257                        *height += h;
258                    } else {
259                        *height = Some(h);
260                    }
261                }
262
263                if let Some(w) = size.width {
264                    if let Some(width) = width {
265                        *width = width.max(w);
266                    } else {
267                        *width = Some(w);
268                    }
269                }
270
271                Some(self)
272            }
273            Pass::Draw {
274                pdf: &mut ref mut pdf,
275                text_pieces_cache,
276                ref mut location,
277                location_offset: &mut ref mut location_offset,
278                width_constraint,
279                ref mut breakable,
280                ref mut height_available,
281                width: &mut ref mut width,
282                height: &mut ref mut height,
283            } => {
284                // The gap is applied here, but will only be actually applied to the height and
285                // position for subsequent elements if this element ends up having a height.
286                let draw_ctx = DrawCtx {
287                    pdf,
288                    text_pieces_cache,
289                    location: Location {
290                        pos: if height.is_some() {
291                            (location.pos.0, location.pos.1 - self.gap)
292                        } else {
293                            location.pos
294                        },
295                        ..*location
296                    },
297                    width: width_constraint,
298                    first_height: *height_available
299                        - height.unwrap_or(0.)
300                        - if height.is_some() { self.gap } else { 0. },
301                    preferred_height: None,
302                    breakable: None,
303                };
304
305                let size = if let Some(b) = breakable {
306                    let mut break_count = 0;
307
308                    let size = element.draw(DrawCtx {
309                        breakable: Some(BreakableDraw {
310                            full_height: b.full_height,
311                            preferred_height_break_count: 0,
312                            do_break: &mut |pdf, location_idx, location_height| {
313                                *height_available = b.full_height;
314
315                                let location_height = if location_idx == 0 {
316                                    add_optional_size_with_gap(location_height, *height, self.gap)
317                                } else {
318                                    location_height
319                                };
320
321                                let new_location = (b.do_break)(
322                                    pdf,
323                                    location_idx + *location_offset,
324                                    location_height,
325                                );
326
327                                if location_idx + 1 > break_count {
328                                    break_count = location_idx + 1;
329                                    *location = new_location.clone();
330                                }
331
332                                new_location
333                            },
334                        }),
335                        ..draw_ctx
336                    });
337
338                    if break_count > 0 {
339                        *location_offset += break_count;
340                        *height_available = b.full_height;
341                        *height = None;
342                    }
343
344                    size
345                } else {
346                    element.draw(draw_ctx)
347                };
348
349                if let Some(h) = size.height {
350                    if let Some(height) = height {
351                        location.pos.1 -= self.gap;
352                        *height += self.gap;
353
354                        *height += h;
355                    } else {
356                        *height = Some(h);
357                    }
358
359                    location.pos.1 -= h;
360                }
361
362                if let Some(w) = size.width {
363                    if let Some(width) = width {
364                        *width = width.max(w);
365                    } else {
366                        *width = Some(w);
367                    }
368                }
369
370                Some(self)
371            }
372        }
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use crate::{elements::force_break::ForceBreak, elements::none::NoneElement, test_utils::*};
380
381    #[test]
382    fn test_column_empty() {
383        let element = Column {
384            gap: 100.,
385            collapse: true,
386            content: |_| Some(()),
387        };
388
389        for output in ElementTestParams::default().run(&element) {
390            output.assert_size(ElementSize {
391                width: None,
392                height: None,
393            });
394
395            if let Some(b) = output.breakable {
396                b.assert_break_count(0)
397                    .assert_extra_location_min_height(None);
398            }
399        }
400    }
401
402    #[test]
403    fn test_column_with_multiple_nones() {
404        use assert_passes::*;
405
406        let element = BuildElement(|build_ctx, callback| {
407            // we need to build this multiple times because AssertPasses keeps internal state
408            let build = || {
409                AssertPasses::new(
410                    NoneElement,
411                    match build_ctx.pass {
412                        build_element::Pass::FirstLocationUsage { full_height } => {
413                            vec![Pass::FirstLocationUsage {
414                                width: build_ctx.width,
415                                first_height: build_ctx.first_height,
416                                full_height,
417                            }]
418                        }
419                        build_element::Pass::Measure { full_height } => vec![Pass::Measure {
420                            width: build_ctx.width,
421                            first_height: build_ctx.first_height,
422                            full_height,
423                        }],
424                        build_element::Pass::Draw { ref breakable, .. } => vec![Pass::Draw {
425                            width: build_ctx.width,
426                            first_height: build_ctx.first_height,
427                            preferred_height: None,
428
429                            page: 0,
430                            layer: 0,
431                            pos: (3., 12.),
432
433                            breakable: breakable.as_ref().map(|b| BreakableDraw {
434                                full_height: b.full_height,
435                                preferred_height_break_count: 0,
436                                breaks: Vec::new(),
437                            }),
438                        }],
439                    },
440                )
441            };
442
443            let none_0 = build();
444            let none_1 = build();
445            let none_2 = build();
446
447            let element = Column {
448                gap: 1.,
449                collapse: true,
450                content: |content| {
451                    content.add(&none_0)?.add(&none_1)?.add(&none_2)?;
452
453                    None
454                },
455            };
456
457            callback.call(element)
458        });
459
460        for output in (ElementTestParams {
461            first_height: 4.,
462            full_height: 10.,
463            width: 6.,
464            pos: (3., 12.),
465            ..Default::default()
466        })
467        .run(&element)
468        {
469            output.assert_size(ElementSize {
470                width: None,
471                height: None,
472            });
473
474            if let Some(b) = output.breakable {
475                b.assert_break_count(0)
476                    .assert_extra_location_min_height(None);
477            }
478        }
479    }
480
481    #[test]
482    fn test_column() {
483        use assert_passes::*;
484
485        let element = BuildElement(|build_ctx, callback| {
486            let less_first_height = build_ctx.first_height == 4.;
487
488            let child_0 = AssertPasses::new(
489                NoneElement,
490                match build_ctx.pass {
491                    build_element::Pass::FirstLocationUsage { full_height } => {
492                        vec![Pass::FirstLocationUsage {
493                            width: build_ctx.width,
494                            first_height: build_ctx.first_height,
495                            full_height,
496                        }]
497                    }
498                    build_element::Pass::Measure { full_height } => vec![Pass::Measure {
499                        width: build_ctx.width,
500                        first_height: build_ctx.first_height,
501                        full_height,
502                    }],
503                    build_element::Pass::Draw { ref breakable, .. } => vec![Pass::Draw {
504                        width: build_ctx.width,
505                        first_height: build_ctx.first_height,
506                        preferred_height: None,
507
508                        page: 0,
509                        layer: 0,
510                        pos: (3., 12.),
511
512                        breakable: breakable.as_ref().map(|b| BreakableDraw {
513                            full_height: b.full_height,
514                            preferred_height_break_count: 0,
515                            breaks: Vec::new(),
516                        }),
517                    }],
518                },
519            );
520
521            let child_1 = AssertPasses::new(
522                FakeText {
523                    lines: 8,
524                    line_height: 2.,
525                    width: 5.,
526                },
527                match build_ctx.pass {
528                    build_element::Pass::FirstLocationUsage { full_height } => {
529                        vec![Pass::FirstLocationUsage {
530                            width: build_ctx.width,
531                            first_height: build_ctx.first_height,
532                            full_height,
533                        }]
534                    }
535                    build_element::Pass::Measure { full_height } => vec![Pass::Measure {
536                        width: build_ctx.width,
537                        first_height: build_ctx.first_height,
538                        full_height,
539                    }],
540                    build_element::Pass::Draw { ref breakable, .. } => vec![Pass::Draw {
541                        width: build_ctx.width,
542                        first_height: build_ctx.first_height,
543                        preferred_height: None,
544
545                        page: 0,
546                        layer: 0,
547                        pos: (3., 12.),
548
549                        breakable: breakable.as_ref().map(|b| BreakableDraw {
550                            full_height: b.full_height,
551                            preferred_height_break_count: 0,
552                            breaks: if less_first_height {
553                                vec![
554                                    Break {
555                                        page: 1,
556                                        layer: 0,
557                                        pos: (3., 12.),
558                                    },
559                                    Break {
560                                        page: 2,
561                                        layer: 0,
562                                        pos: (3., 12.),
563                                    },
564                                ]
565                            } else {
566                                vec![Break {
567                                    page: 1,
568                                    layer: 0,
569                                    pos: (3., 12.),
570                                }]
571                            },
572                        }),
573                    }],
574                },
575            );
576
577            let child_2 = {
578                let first_height = match (build_ctx.is_breakable(), less_first_height) {
579                    (false, false) => 10. - 16. - 1.,
580                    (false, true) => 4. - 16. - 1.,
581                    (true, false) => 3.,
582                    (true, true) => 7.,
583                };
584
585                AssertPasses::new(
586                    ForceBreak,
587                    match build_ctx.pass {
588                        build_element::Pass::FirstLocationUsage { .. } => vec![],
589                        build_element::Pass::Measure { full_height } => vec![Pass::Measure {
590                            width: build_ctx.width,
591                            first_height,
592                            full_height,
593                        }],
594                        build_element::Pass::Draw { ref breakable, .. } => {
595                            vec![if let Some(breakable) = breakable {
596                                Pass::Draw {
597                                    width: build_ctx.width,
598                                    first_height,
599                                    preferred_height: None,
600
601                                    page: if less_first_height { 2 } else { 1 },
602                                    layer: 0,
603                                    pos: if less_first_height {
604                                        (3., 12. - 3.)
605                                    } else {
606                                        (3., 12. - 7.)
607                                    },
608
609                                    breakable: Some(BreakableDraw {
610                                        full_height: breakable.full_height,
611                                        preferred_height_break_count: 0,
612                                        breaks: if less_first_height {
613                                            vec![Break {
614                                                page: 3,
615                                                layer: 0,
616                                                pos: (3., 12.),
617                                            }]
618                                        } else {
619                                            vec![Break {
620                                                page: 2,
621                                                layer: 0,
622                                                pos: (3., 12.),
623                                            }]
624                                        },
625                                    }),
626                                }
627                            } else {
628                                Pass::Draw {
629                                    width: build_ctx.width,
630                                    first_height,
631                                    preferred_height: None,
632
633                                    page: 0,
634                                    layer: 0,
635                                    pos: (3., 12. - 16. - 1.),
636
637                                    breakable: None,
638                                }
639                            }]
640                        }
641                    },
642                )
643            };
644
645            let child_3 = {
646                let first_height = match (build_ctx.is_breakable(), less_first_height) {
647                    (false, false) => 10. - 16. - 1.,
648                    (false, true) => 4. - 16. - 1.,
649                    (true, _) => 10.,
650                };
651
652                AssertPasses::new(
653                    FranticJumper {
654                        jumps: vec![
655                            (0, Some(0.)),
656                            (5, Some(1.5)),
657                            (3, Some(1.5)),
658                            (3, Some(1.5)),
659                        ],
660                        size: ElementSize {
661                            width: Some(5.5),
662                            height: Some(1.5),
663                        },
664                    },
665                    match build_ctx.pass {
666                        build_element::Pass::FirstLocationUsage { .. } => vec![],
667                        build_element::Pass::Measure { full_height } => vec![Pass::Measure {
668                            width: build_ctx.width,
669                            first_height,
670                            full_height,
671                        }],
672                        build_element::Pass::Draw { ref breakable, .. } => {
673                            vec![if let Some(breakable) = breakable {
674                                let start_page = if less_first_height { 3 } else { 2 };
675
676                                Pass::Draw {
677                                    width: build_ctx.width,
678                                    first_height,
679                                    preferred_height: None,
680                                    page: start_page,
681                                    layer: 0,
682                                    pos: (3., 12.),
683                                    breakable: Some(BreakableDraw {
684                                        full_height: breakable.full_height,
685                                        preferred_height_break_count: 0,
686                                        breaks: vec![
687                                            Break {
688                                                page: start_page + 1,
689                                                layer: 0,
690                                                pos: (3., 12.),
691                                            },
692                                            Break {
693                                                page: start_page + 6,
694                                                layer: 0,
695                                                pos: (3., 12.),
696                                            },
697                                            Break {
698                                                page: start_page + 4,
699                                                layer: 0,
700                                                pos: (3., 12.),
701                                            },
702                                            Break {
703                                                page: start_page + 4,
704                                                layer: 0,
705                                                pos: (3., 12.),
706                                            },
707                                        ],
708                                    }),
709                                }
710                            } else {
711                                Pass::Draw {
712                                    width: build_ctx.width,
713                                    first_height,
714                                    preferred_height: None,
715
716                                    page: 0,
717                                    layer: 0,
718                                    pos: (3., 12. - 16. - 1.),
719
720                                    breakable: None,
721                                }
722                            }]
723                        }
724                    },
725                )
726            };
727
728            let child_4 = {
729                let first_height = match (build_ctx.is_breakable(), less_first_height) {
730                    (false, false) => 10. - 16. - 1. - 1.5 - 1.,
731                    (false, true) => 4. - 16. - 1. - 1.5 - 1.,
732                    (true, _) => 10. - 1.5 - 1.,
733                };
734
735                AssertPasses::new(
736                    NoneElement,
737                    match build_ctx.pass {
738                        build_element::Pass::FirstLocationUsage { .. } => vec![],
739                        build_element::Pass::Measure { full_height } => vec![Pass::Measure {
740                            width: build_ctx.width,
741                            first_height,
742                            full_height,
743                        }],
744                        build_element::Pass::Draw { ref breakable, .. } => {
745                            vec![if let Some(breakable) = breakable {
746                                let start_page = if less_first_height { 3 } else { 2 } + 6;
747
748                                Pass::Draw {
749                                    width: build_ctx.width,
750                                    first_height,
751                                    preferred_height: None,
752                                    page: start_page,
753                                    layer: 0,
754                                    pos: (3., 12. - 1.5 - 1.),
755                                    breakable: Some(BreakableDraw {
756                                        full_height: breakable.full_height,
757                                        preferred_height_break_count: 0,
758                                        breaks: vec![],
759                                    }),
760                                }
761                            } else {
762                                Pass::Draw {
763                                    width: build_ctx.width,
764                                    first_height,
765                                    preferred_height: None,
766
767                                    page: 0,
768                                    layer: 0,
769                                    pos: (3., 12. - 16. - 1. - 1.5 - 1.),
770
771                                    breakable: None,
772                                }
773                            }]
774                        }
775                    },
776                )
777            };
778
779            let element = Column {
780                gap: 1.,
781                collapse: false,
782                content: |content| {
783                    content
784                        .add(&child_0)?
785                        .add(&child_1)?
786                        .add(&child_2)?
787                        .add(&child_3)?
788                        .add(&child_4)?;
789
790                    Some(())
791                },
792            };
793
794            callback.call(element)
795        });
796
797        for output in (ElementTestParams {
798            first_height: 4.,
799            full_height: 10.,
800            width: 6.,
801            pos: (3., 12.),
802            ..Default::default()
803        })
804        .run(&element)
805        {
806            output.assert_size(ElementSize {
807                width: Some(output.width.constrain(5.5)),
808                height: Some(if output.breakable.is_some() {
809                    1.5
810                } else {
811                    16. + 1. + 1.5
812                }),
813            });
814
815            if let Some(b) = output.breakable {
816                b.assert_break_count(if output.first_height == 4. { 9 } else { 8 })
817                    .assert_extra_location_min_height(None);
818            }
819        }
820    }
821}