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}
33
34impl<'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
50pub 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 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 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 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 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 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: ®ular,
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: ®ular,
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: ®ular,
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: ®ular,
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: ®ular,
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: ®ular,
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: ®ular,
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: ®ular,
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: ®ular,
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: ®ular,
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}