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 pub text: &'a str,
18 pub font: &'a F,
20 pub size: f32,
22 pub color: u32,
24 pub underline: bool,
26 pub extra_character_spacing: f32,
28 pub extra_word_spacing: f32,
30 pub extra_line_height: f32,
32 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
52impl<'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
69pub 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 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 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 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 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 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 ", ®ular, 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?", ®ular, 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 ", ®ular, 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? ", ®ular, 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 ", ®ular, 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", ®ular, 4.)
497 },
498 Span {
499 color: 0xFF_FF_00_FF,
500 ..Span::new(" at?", ®ular, 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 ", ®ular, 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", ®ular, 4.)
557 },
558 Span {
559 color: 0xFF_FF_00_FF,
560 ..Span::new(" at?", ®ular, 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 ", ®ular, 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}