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