Skip to main content

laser_pdf/elements/
rich_text.rs

1use crate::{
2    text::{Line, Piece, draw_line, lines_from_pieces},
3    utils::{mm_to_pt, pt_to_mm},
4    *,
5};
6
7#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub enum TextAlign {
9    Left,
10    Center,
11    Right,
12}
13
14#[derive(Debug)]
15pub struct Span<'a, F> {
16    /// The text content to render
17    pub text: &'a str,
18    /// Font reference
19    pub font: &'a F,
20    /// Font size in points
21    pub size: f32,
22    /// Text color as RGBA (default: black 0x00_00_00_FF)
23    pub color: u32,
24    /// Whether to underline the text
25    pub underline: bool,
26    /// Additional spacing between characters
27    pub extra_character_spacing: f32,
28    /// Additional spacing between words
29    pub extra_word_spacing: f32,
30    /// Additional line height
31    pub extra_line_height: f32,
32}
33
34// This is a manual impl because we don't need the `F: Clone` constraint.
35impl<'a, F> Clone for Span<'a, F> {
36    fn clone(&self) -> Self {
37        Self {
38            text: self.text,
39            font: self.font,
40            size: self.size.clone(),
41            color: self.color.clone(),
42            underline: self.underline.clone(),
43            extra_character_spacing: self.extra_character_spacing.clone(),
44            extra_word_spacing: self.extra_word_spacing.clone(),
45            extra_line_height: self.extra_line_height.clone(),
46        }
47    }
48}
49
50/// An element for displaying text with mixed fonts, sizes, colors, etc.
51///
52/// Note: Newline characters belong to both the line they end and the next line. So if you have a
53/// newline character at the end of a span with a larger font than the next one, the line after the
54/// one terminated by the newline will have at least the height of the larger font as well (it could
55/// also be more depending on where the fonts baselines are). This behavior also means that if there
56/// are no more spans after one terminated by a newline, the empty line at the end will have the
57/// height of the font of the span containing the newline.
58pub struct RichText<S> {
59    pub spans: S,
60    pub align: TextAlign,
61}
62
63impl<'a, F: Font + 'a, S: Iterator<Item = Span<'a, F>> + Clone> Element for RichText<S> {
64    fn first_location_usage(&self, ctx: FirstLocationUsageCtx) -> FirstLocationUsage {
65        let mut lines = self.break_into_lines(ctx.text_pieces_cache, ctx.width.max);
66        let Some(first_line) = lines.next() else {
67            return FirstLocationUsage::NoneHeight;
68        };
69
70        let line_height =
71            pt_to_mm(first_line.height_above_baseline + first_line.height_below_baseline);
72
73        if line_height > ctx.first_height {
74            FirstLocationUsage::WillSkip
75        } else {
76            FirstLocationUsage::WillUse
77        }
78    }
79
80    fn measure(&self, mut ctx: MeasureCtx) -> ElementSize {
81        let lines = self.break_into_lines(ctx.text_pieces_cache, ctx.width.max);
82        let size = self.layout_lines(lines, Some(&mut ctx));
83
84        ElementSize {
85            width: size.map(|s| ctx.width.max(s.0)),
86            height: size.map(|s| s.1),
87        }
88    }
89
90    fn draw(&self, ctx: DrawCtx) -> ElementSize {
91        // For left alignment we don't need to pre-layout because the
92        // x offset is always zero.
93        let width = if ctx.width.expand {
94            ctx.width.max
95        } else if self.align == TextAlign::Left {
96            0.
97        } else {
98            let lines = self.break_into_lines(ctx.text_pieces_cache, ctx.width.max);
99            let Some((width, _)) = self.layout_lines(lines, None) else {
100                // Returning early is fine here because a None returned from layout_lines also means
101                // there's no breaks. If there's a break there has to be at least a line after the
102                // break. This also applies if there's a newline at the end of the text because that
103                // still causes a line with the height of main font of the span.
104                return ElementSize {
105                    width: None,
106                    height: None,
107                };
108            };
109            width
110        };
111
112        let width_constraint = ctx.width;
113        let lines = self.break_into_lines(ctx.text_pieces_cache, ctx.width.max);
114        let size = self.render_lines(lines, ctx, width);
115
116        ElementSize {
117            width: size.map(|s| width_constraint.max(s.0)),
118            height: size.map(|s| s.1),
119        }
120    }
121}
122
123impl<'a, F: Font + 'a, S: Iterator<Item = Span<'a, F>> + Clone> RichText<S> {
124    #[inline(always)]
125    fn render_lines<'c, L: Iterator<Item = Line<'c, F, impl Iterator<Item = (&'c F, &'c Piece)>>>>(
126        &self,
127        lines: L,
128        mut ctx: DrawCtx,
129        width: f32,
130    ) -> Option<(f32, f32)>
131    where
132        F: 'c,
133    {
134        let mut max_width = width;
135        let mut last_line_full_width = 0.;
136
137        let mut x = ctx.location.pos.0;
138
139        // This in points because there's no reason to work with mm here.
140        let mut y = mm_to_pt(ctx.location.pos.1);
141
142        let mut height_available = ctx.first_height;
143
144        let mut line_count = 0;
145        let mut draw_rect = 0;
146
147        let mut height = 0.;
148
149        let start = |pdf: &mut Pdf, location: &Location| {
150            let layer = location.layer(pdf);
151            layer.save_state();
152            layer.begin_text();
153        };
154
155        let end = |pdf: &mut Pdf, location: &Location| {
156            location.layer(pdf).end_text().restore_state();
157        };
158
159        start(ctx.pdf, &ctx.location);
160
161        for line in lines {
162            let line_height = pt_to_mm(line.height_above_baseline + line.height_below_baseline);
163            let height_above_baseline = line.height_above_baseline;
164            let height_below_baseline = line.height_below_baseline;
165
166            let line_width = pt_to_mm(line.width);
167            max_width = max_width.max(line_width);
168
169            last_line_full_width = line.width + line.trailing_whitespace_width;
170
171            if height_available < line_height {
172                if let Some(ref mut breakable) = ctx.breakable {
173                    end(ctx.pdf, &ctx.location);
174
175                    let new_location = (breakable.do_break)(
176                        ctx.pdf,
177                        draw_rect,
178                        if line_count == 0 { None } else { Some(height) },
179                    );
180                    draw_rect += 1;
181                    x = new_location.pos.0;
182                    y = mm_to_pt(new_location.pos.1);
183                    height_available = breakable.full_height;
184                    ctx.location.page_idx = new_location.page_idx;
185                    ctx.location.layer_idx = new_location.layer_idx;
186                    line_count = 0;
187                    height = 0.;
188
189                    start(ctx.pdf, &ctx.location);
190                }
191            }
192
193            let layer = ctx.location.layer(ctx.pdf);
194
195            let x_offset = match self.align {
196                TextAlign::Left => 0.,
197                TextAlign::Center => (width - line_width) / 2.,
198                TextAlign::Right => width - line_width,
199            };
200
201            let x = x + x_offset;
202
203            y -= height_above_baseline;
204
205            layer.set_text_matrix([1.0, 0.0, 0.0, 1.0, mm_to_pt(x), y]);
206
207            draw_line(ctx.pdf, &ctx.location, line);
208
209            y -= height_below_baseline;
210            height_available -= line_height;
211            line_count += 1;
212            height += line_height;
213        }
214
215        end(ctx.pdf, &ctx.location);
216
217        (line_count > 0).then_some((max_width.max(pt_to_mm(last_line_full_width)), height))
218    }
219
220    #[inline(always)]
221    fn layout_lines<'c, L: Iterator<Item = Line<'c, F, impl Iterator<Item = (&'c F, &'c Piece)>>>>(
222        &self,
223        lines: L,
224        measure_ctx: Option<&mut MeasureCtx>,
225    ) -> Option<(f32, f32)>
226    where
227        F: 'c,
228    {
229        let mut max_width: f32 = 0.;
230        let mut last_line_full_width: f32 = 0.;
231        let mut height = 0.;
232
233        // This function is a bit hacky because it's both used for measure and for determining the
234        // max line width in unconstrained-width contexts.
235        let mut height_available = if let Some(&mut MeasureCtx { first_height, .. }) = measure_ctx {
236            first_height
237        } else {
238            f32::INFINITY
239        };
240
241        let mut line_count = 0;
242
243        for line in lines {
244            let line_height = pt_to_mm(line.height_above_baseline + line.height_below_baseline);
245
246            if let Some(&mut MeasureCtx {
247                breakable: Some(ref mut breakable),
248                ..
249            }) = measure_ctx
250            {
251                if height_available < line_height {
252                    *breakable.break_count += 1;
253                    height_available = breakable.full_height;
254                    height = 0.;
255                    line_count = 0;
256                }
257            }
258
259            max_width = max_width.max(line.width);
260            last_line_full_width = line.width + line.trailing_whitespace_width;
261
262            height_available -= line_height;
263            height += line_height;
264            line_count += 1;
265        }
266
267        (line_count > 0).then_some((pt_to_mm(max_width.max(last_line_full_width)), height))
268    }
269
270    fn break_into_lines<'b>(
271        &'b self,
272        text_pieces_cache: &'b TextPiecesCache,
273        width: f32,
274    ) -> impl Iterator<Item = Line<'b, F, impl Iterator<Item = (&'b F, &'b Piece)>>>
275    where
276        'a: 'b,
277    {
278        let pieces = self.spans.clone().flat_map(|span| {
279            let pieces = text_pieces_cache.pieces(
280                span.text,
281                span.font,
282                span.size,
283                span.color,
284                span.extra_character_spacing,
285                span.extra_word_spacing,
286                mm_to_pt(span.extra_line_height),
287            );
288
289            pieces.into_iter().map(move |p| (span.font, p))
290        });
291
292        // The `next_up` mitigates a problem when we get passed the width we returned from
293        // measuring. In some cases it would then put one more piece onto the next line. This likely
294        // doesn't fix the problem in all cases. TODO
295        let lines = lines_from_pieces(pieces, mm_to_pt(width).next_up());
296
297        lines
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use elements::column::{Column, ColumnContent};
304    use fonts::{builtin::BuiltinFont, truetype::TruetypeFont};
305    use insta::*;
306
307    use crate::{elements::ref_element::RefElement, test_utils::binary_snapshots::*};
308
309    use super::*;
310
311    #[test]
312    fn test_truetype() {
313        let bytes = test_element_bytes(TestElementParams::breakable(), |mut callback| {
314            let regular =
315                TruetypeFont::new(callback.pdf(), include_bytes!("../fonts/Kenney Future.ttf"));
316            let bold =
317                TruetypeFont::new(callback.pdf(), include_bytes!("../fonts/Kenney Bold.ttf"));
318
319            let rich_text = RichText {
320                spans: [
321                    Span {
322                        text: "Where are ",
323                        font: &regular,
324                        size: 12.,
325                        underline: false,
326                        color: 0x00_00_00_FF,
327                        extra_character_spacing: 0.,
328                        extra_word_spacing: 0.,
329                        extra_line_height: 0.,
330                    },
331                    Span {
332                        text: "they",
333                        font: &bold,
334                        size: 12.,
335                        underline: false,
336                        color: 0x00_00_FF_FF,
337                        extra_character_spacing: 0.,
338                        extra_word_spacing: 0.,
339                        extra_line_height: 0.,
340                    },
341                    Span {
342                        text: "\n",
343                        font: &bold,
344                        size: 12.,
345                        underline: false,
346                        color: 0x00_00_FF_FF,
347                        extra_character_spacing: 0.,
348                        extra_word_spacing: 0.,
349                        extra_line_height: 0.,
350                    },
351                    Span {
352                        text: "at?",
353                        font: &regular,
354                        size: 12.,
355                        underline: false,
356                        color: 0xFF_00_00_FF,
357                        extra_character_spacing: 0.,
358                        extra_word_spacing: 0.,
359                        extra_line_height: 0.,
360                    },
361                ]
362                .into_iter(),
363                align: TextAlign::Left,
364            };
365
366            let list = Column {
367                gap: 16.,
368                collapse: false,
369                content: |content: ColumnContent| {
370                    content
371                        .add(&RefElement(&rich_text).debug(0))?
372                        .add(&Padding::right(
373                            140.,
374                            RefElement(&rich_text).debug(1).show_max_width(),
375                        ))?
376                        .add(&Padding::right(
377                            160.,
378                            RefElement(&rich_text).debug(2).show_max_width(),
379                        ))?
380                        .add(&Padding::right(
381                            180.,
382                            RefElement(&rich_text).debug(3).show_max_width(),
383                        ))?
384                        .add(&Padding::right(
385                            194.,
386                            RefElement(&rich_text).debug(4).show_max_width(),
387                        ))?;
388                    None
389                },
390            };
391
392            callback.call(&list);
393        });
394        assert_binary_snapshot!(".pdf", bytes);
395    }
396
397    #[test]
398    fn test_truetype_trailing_whitespace() {
399        let mut params = TestElementParams::breakable();
400        params.width.expand = false;
401
402        let bytes = test_element_bytes(params, |mut callback| {
403            let regular =
404                TruetypeFont::new(callback.pdf(), include_bytes!("../fonts/Kenney Future.ttf"));
405            let bold =
406                TruetypeFont::new(callback.pdf(), include_bytes!("../fonts/Kenney Bold.ttf"));
407
408            let rich_text = RichText {
409                spans: [
410                    Span {
411                        text: "Where are ",
412                        font: &regular,
413                        size: 12.,
414                        underline: false,
415                        color: 0x00_00_00_FF,
416                        extra_character_spacing: 0.,
417                        extra_word_spacing: 0.,
418                        extra_line_height: 0.,
419                    },
420                    Span {
421                        text: "they ",
422                        font: &bold,
423                        size: 12.,
424                        underline: false,
425                        color: 0x00_FF_00_FF,
426                        extra_character_spacing: 0.,
427                        extra_word_spacing: 0.,
428                        extra_line_height: 0.,
429                    },
430                    Span {
431                        text: "at?        ",
432                        font: &regular,
433                        size: 12.,
434                        underline: false,
435                        color: 0xFF_00_00_FF,
436                        extra_character_spacing: 0.,
437                        extra_word_spacing: 0.,
438                        extra_line_height: 0.,
439                    },
440                ]
441                .into_iter(),
442                align: TextAlign::Left,
443            };
444
445            let list = Column {
446                gap: 16.,
447                collapse: false,
448                content: |content: ColumnContent| {
449                    content
450                        .add(&RefElement(&rich_text).debug(0))?
451                        .add(&Padding::right(
452                            145.,
453                            RefElement(&rich_text).debug(1).show_max_width(),
454                        ))?
455                        .add(&Padding::right(
456                            160.,
457                            RefElement(&rich_text).debug(2).show_max_width(),
458                        ))?
459                        .add(&Padding::right(
460                            180.,
461                            RefElement(&rich_text).debug(3).show_max_width(),
462                        ))?
463                        .add(&Padding::right(
464                            194.,
465                            RefElement(&rich_text).debug(4).show_max_width(),
466                        ))?;
467                    None
468                },
469            };
470
471            callback.call(&list);
472        });
473        assert_binary_snapshot!(".pdf", bytes);
474    }
475
476    #[test]
477    fn test_truetype_small() {
478        let bytes = test_element_bytes(
479            TestElementParams::breakable().no_expand(),
480            |mut callback| {
481                let regular =
482                    TruetypeFont::new(callback.pdf(), include_bytes!("../fonts/Kenney Future.ttf"));
483                let bold =
484                    TruetypeFont::new(callback.pdf(), include_bytes!("../fonts/Kenney Bold.ttf"));
485
486                let rich_text = RichText {
487                    spans: [
488                        Span {
489                            text: "Where are ",
490                            font: &regular,
491                            size: 12.,
492                            underline: false,
493                            color: 0x00_00_00_FF,
494                            extra_character_spacing: 0.,
495                            extra_word_spacing: 0.,
496                            extra_line_height: 0.,
497                        },
498                        Span {
499                            text: "they ",
500                            font: &bold,
501                            size: 4.,
502                            underline: false,
503                            color: 0x00_00_FF_FF,
504                            extra_character_spacing: 0.,
505                            extra_word_spacing: 0.,
506                            extra_line_height: 0.,
507                        },
508                        Span {
509                            text: "they",
510                            font: &regular,
511                            size: 4.,
512                            underline: false,
513                            color: 0x00_FF_FF_FF,
514                            extra_character_spacing: 0.,
515                            extra_word_spacing: 0.,
516                            extra_line_height: 0.,
517                        },
518                        Span {
519                            text: " at?",
520                            font: &regular,
521                            size: 12.,
522                            underline: false,
523                            color: 0xFF_FF_00_FF,
524                            extra_character_spacing: 0.,
525                            extra_word_spacing: 0.,
526                            extra_line_height: 0.,
527                        },
528                    ]
529                    .into_iter(),
530                    align: TextAlign::Left,
531                };
532
533                let list = Column {
534                    gap: 16.,
535                    collapse: false,
536                    content: |content: ColumnContent| {
537                        content
538                            .add(&RefElement(&rich_text).debug(0).show_max_width())?
539                            .add(&Padding::right(
540                                140.,
541                                RefElement(&rich_text).debug(1).show_max_width(),
542                            ))?
543                            .add(&Padding::right(
544                                155.,
545                                RefElement(&rich_text).debug(2).show_max_width(),
546                            ))?
547                            .add(&Padding::right(
548                                180.,
549                                RefElement(&rich_text).debug(3).show_max_width(),
550                            ))?
551                            .add(&Padding::right(
552                                194.,
553                                RefElement(&rich_text).debug(4).show_max_width(),
554                            ))?;
555                        None
556                    },
557                };
558
559                callback.call(&list);
560            },
561        );
562        assert_binary_snapshot!(".pdf", bytes);
563    }
564
565    #[test]
566    fn test_small() {
567        let bytes = test_element_bytes(
568            TestElementParams::breakable().no_expand(),
569            |mut callback| {
570                let regular = BuiltinFont::helvetica(callback.pdf());
571                let bold = BuiltinFont::helvetica_bold(callback.pdf());
572
573                let rich_text = RichText {
574                    spans: [
575                        Span {
576                            text: "Where are ",
577                            font: &regular,
578                            underline: false,
579                            color: 0x00_00_00_FF,
580                            size: 12.,
581                            extra_character_spacing: 0.,
582                            extra_word_spacing: 0.,
583                            extra_line_height: 0.,
584                        },
585                        Span {
586                            text: "they ",
587                            font: &bold,
588                            underline: false,
589                            color: 0x00_00_FF_FF,
590                            size: 4.,
591                            extra_character_spacing: 0.,
592                            extra_word_spacing: 0.,
593                            extra_line_height: 0.,
594                        },
595                        Span {
596                            text: "they",
597                            font: &regular,
598                            underline: false,
599                            color: 0x00_FF_FF_FF,
600                            size: 4.,
601                            extra_character_spacing: 0.,
602                            extra_word_spacing: 0.,
603                            extra_line_height: 0.,
604                        },
605                        Span {
606                            text: " at?",
607                            font: &regular,
608                            underline: false,
609                            color: 0xFF_FF_00_FF,
610                            size: 12.,
611                            extra_character_spacing: 0.,
612                            extra_word_spacing: 0.,
613                            extra_line_height: 0.,
614                        },
615                    ]
616                    .into_iter(),
617                    align: TextAlign::Left,
618                };
619
620                let list = Column {
621                    gap: 16.,
622                    collapse: false,
623                    content: |content: ColumnContent| {
624                        content
625                            .add(&RefElement(&rich_text).debug(0).show_max_width())?
626                            .add(&Padding::right(
627                                140.,
628                                RefElement(&rich_text).debug(1).show_max_width(),
629                            ))?
630                            .add(&Padding::right(
631                                155.,
632                                RefElement(&rich_text).debug(2).show_max_width(),
633                            ))?
634                            .add(&Padding::right(
635                                180.,
636                                RefElement(&rich_text).debug(3).show_max_width(),
637                            ))?
638                            .add(&Padding::right(
639                                194.,
640                                RefElement(&rich_text).debug(4).show_max_width(),
641                            ))?;
642                        None
643                    },
644                };
645
646                callback.call(&list);
647            },
648        );
649        assert_binary_snapshot!(".pdf", bytes);
650    }
651
652    #[test]
653    fn test_no_rich_text_content() {
654        let bytes = test_element_bytes(
655            TestElementParams::breakable().no_expand(),
656            |mut callback| {
657                BuiltinFont::helvetica(callback.pdf());
658
659                let spans: [Span<BuiltinFont>; 0] = [];
660
661                let rich_text = RichText {
662                    spans: spans.into_iter(),
663                    align: TextAlign::Left,
664                };
665
666                let list = Column {
667                    gap: 16.,
668                    collapse: true,
669                    content: |content: ColumnContent| {
670                        content
671                            .add(&RefElement(&rich_text).debug(0).show_max_width())?
672                            .add(&Padding::top(
673                                120.,
674                                RefElement(&rich_text).debug(1).show_max_width(),
675                            ))?;
676                        None
677                    },
678                };
679
680                callback.call(&list);
681            },
682        );
683        assert_binary_snapshot!(".pdf", bytes);
684    }
685}