1use pdfboss_core::content::Op;
8use pdfboss_core::Point;
9
10use crate::canvas::Canvas;
11use crate::color::Color;
12use crate::error::{Error, Result};
13use crate::font::Standard14;
14use crate::image::ImageData;
15use crate::pdf::{LinkAnnotation, LinkTarget};
16
17pub trait Draw: Send {
21 fn draw(&self, canvas: &mut Canvas) -> Result<()>;
23}
24
25#[derive(Debug, Clone, PartialEq)]
27pub struct Text {
28 pub value: String,
30 pub at: Point,
32 pub font: Standard14,
34 pub size: f32,
36 pub color: Color,
38}
39
40impl Default for Text {
41 fn default() -> Text {
42 Text {
43 value: String::new(),
44 at: Point::default(),
45 font: Standard14::Helvetica,
46 size: 12.0,
47 color: Color::BLACK,
48 }
49 }
50}
51
52impl Draw for Text {
53 fn draw(&self, canvas: &mut Canvas) -> Result<()> {
54 canvas.set_fill(self.color);
55 canvas.text(&self.value, self.at.x, self.at.y, self.font, self.size)
56 }
57}
58
59#[derive(Debug, Clone, PartialEq)]
63pub struct Image {
64 pub data: ImageData,
66 pub at: Point,
69 pub width: Option<f32>,
73 pub height: Option<f32>,
77}
78
79impl Image {
80 pub fn placed_size(&self) -> (f32, f32) {
84 let natural_width = self.data.width() as f32;
85 let natural_height = self.data.height() as f32;
86 match (self.width, self.height) {
87 (Some(width), Some(height)) => (width, height),
88 (Some(width), None) => (width, width * natural_height / natural_width),
89 (None, Some(height)) => (height * natural_width / natural_height, height),
90 (None, None) => (natural_width, natural_height),
91 }
92 }
93}
94
95impl Draw for Image {
96 fn draw(&self, canvas: &mut Canvas) -> Result<()> {
97 let (width, height) = self.placed_size();
98 let handle = canvas.add_image(self.data.clone());
99 canvas.draw_image(handle, self.at.x, self.at.y, width, height);
100 Ok(())
101 }
102}
103
104#[derive(Debug, Clone, PartialEq)]
106pub struct Link {
107 pub rect: [f32; 4],
109 pub target: LinkTarget,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Default)]
115pub enum ParagraphAlign {
116 #[default]
118 Left,
119 Center,
121 Right,
123 Justify,
126}
127
128#[derive(Debug, Clone, PartialEq)]
131pub struct Paragraph {
132 pub text: String,
135 pub rect: [f32; 4],
137 pub font: Standard14,
139 pub size: f32,
141 pub leading: Option<f32>,
143 pub align: ParagraphAlign,
145 pub color: Color,
147}
148
149impl Default for Paragraph {
150 fn default() -> Paragraph {
151 Paragraph {
152 text: String::new(),
153 rect: [0.0, 0.0, 0.0, 0.0],
154 font: Standard14::Helvetica,
155 size: 11.0,
156 leading: None,
157 align: ParagraphAlign::Left,
158 color: Color::BLACK,
159 }
160 }
161}
162
163fn wrap_lines(text: &str, font: Standard14, size: f32, max_width: f32) -> Result<Vec<Vec<&str>>> {
169 let mut lines = Vec::new();
170 for source_line in text.split('\n') {
171 let words: Vec<&str> = source_line.split_whitespace().collect();
172 if words.is_empty() {
173 lines.push(Vec::new());
174 continue;
175 }
176 let mut current: Vec<&str> = Vec::new();
177 let mut current_text = String::new();
178 for word in words {
179 let candidate = if current.is_empty() {
180 word.to_string()
181 } else {
182 format!("{current_text} {word}")
183 };
184 let width = font.text_width(&candidate, size)?;
185 if current.is_empty() || width <= max_width {
186 current.push(word);
187 current_text = candidate;
188 continue;
189 }
190 lines.push(std::mem::take(&mut current));
191 current_text = word.to_string();
192 current.push(word);
193 }
194 lines.push(current);
195 }
196 Ok(lines)
197}
198
199fn lines_that_fit(y0: f32, y1: f32, size: f32, leading: f32) -> usize {
203 const EPSILON: f32 = 1e-3;
204 let first_baseline = y1 - size;
205 if first_baseline < y0 - EPSILON {
206 return 0;
207 }
208 (((first_baseline - y0) / leading) + EPSILON).floor() as usize + 1
209}
210
211impl Draw for Paragraph {
216 fn draw(&self, canvas: &mut Canvas) -> Result<()> {
217 canvas.set_fill(self.color);
218 let [x0, y0, x1, y1] = self.rect;
219 let width = x1 - x0;
220 let leading = self.leading.unwrap_or(1.2 * self.size);
221 let lines = wrap_lines(&self.text, self.font, self.size, width)?;
222 let fits = lines_that_fit(y0, y1, self.size, leading);
223 if lines.len() > fits {
224 return Err(Error::Other(format!(
225 "paragraph overflows its rect: {fits} lines fit, {} needed",
226 lines.len()
227 )));
228 }
229 let last_visible = lines.iter().rposition(|words| !words.is_empty());
230 let mut stretch_active = false;
231 for (index, words) in lines.iter().enumerate() {
232 if words.is_empty() {
233 continue;
234 }
235 let baseline = y1 - self.size - index as f32 * leading;
236 let line_text = words.join(" ");
237 let line_width = self.font.text_width(&line_text, self.size)?;
238 let is_final = Some(index) == last_visible;
239 let stretch = match self.align {
240 ParagraphAlign::Justify if !is_final && words.len() >= 2 => {
241 Some((width - line_width) / (words.len() as f32 - 1.0))
242 }
243 _ => None,
244 };
245 let x = match self.align {
246 ParagraphAlign::Right => x1 - line_width,
247 ParagraphAlign::Center => x0 + (width - line_width) / 2.0,
248 ParagraphAlign::Left | ParagraphAlign::Justify => x0,
249 };
250 if stretch.is_none() && stretch_active {
251 canvas.op(Op::SetWordSpacing(0.0));
252 stretch_active = false;
253 }
254 if let Some(spacing) = stretch {
255 canvas.op(Op::SetWordSpacing(spacing));
256 stretch_active = true;
257 }
258 canvas.text(&line_text, x, baseline, self.font, self.size)?;
259 }
260 if stretch_active {
261 canvas.op(Op::SetWordSpacing(0.0));
262 }
263 Ok(())
264 }
265}
266
267pub enum Content {
269 Text(Text),
271 Image(Image),
273 Link(Link),
275 Paragraph(Paragraph),
277 Custom(Box<dyn Draw>),
279}
280
281impl std::fmt::Debug for Content {
282 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283 match self {
284 Content::Text(text) => f.debug_tuple("Text").field(text).finish(),
285 Content::Image(image) => f.debug_tuple("Image").field(image).finish(),
286 Content::Link(link) => f.debug_tuple("Link").field(link).finish(),
287 Content::Paragraph(paragraph) => f.debug_tuple("Paragraph").field(paragraph).finish(),
288 Content::Custom(..) => f.write_str("Custom(..)"),
289 }
290 }
291}
292
293impl From<Text> for Content {
294 fn from(value: Text) -> Content {
295 Content::Text(value)
296 }
297}
298
299impl From<Image> for Content {
300 fn from(value: Image) -> Content {
301 Content::Image(value)
302 }
303}
304
305impl From<Link> for Content {
306 fn from(value: Link) -> Content {
307 Content::Link(value)
308 }
309}
310
311impl From<Paragraph> for Content {
312 fn from(value: Paragraph) -> Content {
313 Content::Paragraph(value)
314 }
315}
316
317impl Content {
318 pub fn custom(value: impl Draw + 'static) -> Content {
320 Content::Custom(Box::new(value))
321 }
322}
323
324pub(crate) fn lower(
327 content: Vec<Content>,
328 canvas: &mut Canvas,
329 links: &mut Vec<LinkAnnotation>,
330) -> Result<()> {
331 for item in content {
332 match item {
333 Content::Text(text) => text.draw(canvas)?,
334 Content::Image(image) => image.draw(canvas)?,
335 Content::Link(link) => links.push(LinkAnnotation {
336 rect: link.rect,
337 target: link.target,
338 }),
339 Content::Paragraph(paragraph) => paragraph.draw(canvas)?,
340 Content::Custom(drawable) => drawable.draw(canvas)?,
341 }
342 }
343 Ok(())
344}
345
346#[cfg(test)]
347mod tests {
348 use pdfboss_core::content::{parse_content, Op};
349 use pdfboss_core::{Document, Name};
350 use pdfboss_output::extract_text;
351
352 use super::*;
353 use crate::pdf::{Page, PageSize};
354 use crate::Pdf;
355
356 #[test]
357 fn elements_lower_in_sequence_order_after_canvas_ops() {
358 let mut page = Page::new(PageSize::A4);
359 page.canvas
360 .text("under", 72.0, 100.0, Standard14::Helvetica, 10.0)
361 .unwrap();
362 page.content.push(Content::from(Text {
363 value: "over".into(),
364 at: Point::new(72.0, 700.0),
365 size: 24.0,
366 ..Text::default()
367 }));
368 let ops_before = page.canvas.ops().len();
369 let bytes = Pdf {
370 pages: vec![page],
371 ..Pdf::default()
372 }
373 .to_bytes()
374 .unwrap();
375 let doc = Document::load(bytes).unwrap();
376 let loaded = doc.page(0).unwrap();
377 let text = extract_text(&doc, &loaded, pdfboss_output::ReadingOrder::Content).unwrap();
378 assert!(text.contains("under") && text.contains("over"));
379 assert!(ops_before > 0);
380
381 let stream = loaded.content(&doc).unwrap();
382 let ops = parse_content(&stream).unwrap();
383 let index_of = |needle: &[u8]| {
384 ops.iter()
385 .position(|op| matches!(op, Op::ShowText(s) if s == needle))
386 .unwrap_or_else(|| {
387 panic!(
388 "no ShowText carrying {:?} in {:?}",
389 String::from_utf8_lossy(needle),
390 ops
391 )
392 })
393 };
394 let under_index = index_of(b"under");
395 let over_index = index_of(b"over");
396 assert!(
397 under_index < over_index,
398 "expected \"under\" ({under_index}) before \"over\" ({over_index})"
399 );
400 }
401
402 #[test]
403 fn custom_draw_paints_through_the_canvas() {
404 struct Letterhead;
405 impl Draw for Letterhead {
406 fn draw(&self, canvas: &mut Canvas) -> Result<()> {
407 canvas.set_line_width(0.5);
408 canvas.move_to(72.0, 806.0);
409 canvas.line_to(523.0, 806.0);
410 canvas.stroke();
411 canvas.text("ACME GmbH", 72.0, 812.0, Standard14::Helvetica, 8.0)
412 }
413 }
414 let custom = Content::custom(Letterhead);
415 assert_eq!(format!("{custom:?}"), "Custom(..)");
416 let mut page = Page::new(PageSize::A4);
417 page.content.push(custom);
418 let bytes = Pdf {
419 pages: vec![page],
420 ..Pdf::default()
421 }
422 .to_bytes()
423 .unwrap();
424 let doc = Document::load(bytes).unwrap();
425 let loaded = doc.page(0).unwrap();
426 let text = extract_text(&doc, &loaded, pdfboss_output::ReadingOrder::Content).unwrap();
427 assert!(text.contains("ACME GmbH"));
428 }
429
430 #[test]
431 fn image_placed_size_is_natural_at_72dpi_when_both_none() {
432 let image = Image {
433 data: ImageData::gray8(16, 8, vec![0u8; 128]).unwrap(),
434 at: Point::new(10.0, 10.0),
435 width: None,
436 height: None,
437 };
438 assert_eq!(image.placed_size(), (16.0, 8.0));
439 }
440
441 #[test]
442 fn image_element_scales_by_aspect_when_one_dimension_is_given() {
443 let image = Image {
444 data: ImageData::gray8(16, 8, vec![0u8; 128]).unwrap(),
445 at: Point::new(10.0, 10.0),
446 width: Some(32.0),
447 height: None,
448 };
449 assert_eq!(image.placed_size(), (32.0, 16.0));
450 }
451
452 #[test]
453 fn image_placed_size_scales_by_aspect_when_only_height_is_given() {
454 let image = Image {
455 data: ImageData::gray8(16, 8, vec![0u8; 128]).unwrap(),
456 at: Point::new(10.0, 10.0),
457 width: None,
458 height: Some(4.0),
459 };
460 assert_eq!(image.placed_size(), (8.0, 4.0));
461 }
462
463 #[test]
464 fn image_placed_size_is_exact_when_both_dimensions_are_given() {
465 let image = Image {
466 data: ImageData::gray8(16, 8, vec![0u8; 128]).unwrap(),
467 at: Point::new(10.0, 10.0),
468 width: Some(50.0),
469 height: Some(90.0),
470 };
471 assert_eq!(image.placed_size(), (50.0, 90.0));
472 }
473
474 #[test]
475 fn text_draw_sets_fill_then_shows_text() {
476 let mut canvas = Canvas::new();
477 let text = Text {
478 value: "hi".into(),
479 at: Point::new(1.0, 2.0),
480 font: Standard14::Helvetica,
481 size: 10.0,
482 color: Color::Rgb(1.0, 0.0, 0.0),
483 };
484 text.draw(&mut canvas).unwrap();
485 assert_eq!(
486 canvas.ops(),
487 [
488 Op::SetFillRGB(1.0, 0.0, 0.0),
489 Op::BeginText,
490 Op::SetFont(Name("F1".into()), 10.0),
491 Op::TextMove(1.0, 2.0),
492 Op::ShowText(b"hi".to_vec()),
493 Op::EndText,
494 ]
495 );
496 }
497
498 #[test]
499 fn paragraph_default_is_helvetica_11_left_and_none_leading() {
500 let paragraph = Paragraph::default();
501 assert_eq!(paragraph.text, "");
502 assert_eq!(paragraph.font, Standard14::Helvetica);
503 assert_eq!(paragraph.size, 11.0);
504 assert_eq!(paragraph.leading, None);
505 assert_eq!(paragraph.align, ParagraphAlign::Left);
506 }
507
508 #[test]
509 fn content_from_paragraph_debug_prints_paragraph() {
510 let content = Content::from(Paragraph::default());
511 assert_eq!(
512 format!("{content:?}"),
513 format!("Paragraph({:?})", Paragraph::default())
514 );
515 }
516
517 #[test]
518 fn paragraph_sets_its_own_fill() {
519 let mut canvas = Canvas::new();
520 let paragraph = Paragraph {
521 text: "hi".into(),
522 rect: [0.0, 0.0, 100.0, 100.0],
523 font: Standard14::Helvetica,
524 size: 10.0,
525 color: Color::Rgb(0.0, 0.5, 0.0),
526 ..Paragraph::default()
527 };
528 paragraph.draw(&mut canvas).unwrap();
529 assert!(matches!(canvas.ops()[0], Op::SetFillRGB(0.0, 0.5, 0.0)));
530 }
531
532 #[test]
533 fn paragraph_wraps_at_word_boundaries_courier_metrics() {
534 let mut canvas = Canvas::new();
535 let mut links = Vec::new();
536 let paragraph = Paragraph {
537 text: "aaaaaaaaa bbbbbbbbbb cccccccccc".into(),
538 rect: [0.0, 0.0, 120.0, 100.0],
539 font: Standard14::Courier,
540 size: 10.0,
541 ..Paragraph::default()
542 };
543 lower(vec![paragraph.into()], &mut canvas, &mut links).unwrap();
544 assert_eq!(
545 canvas.ops(),
546 [
547 Op::SetFillGray(0.0),
548 Op::BeginText,
549 Op::SetFont(Name("F1".into()), 10.0),
550 Op::TextMove(0.0, 90.0),
551 Op::ShowText(b"aaaaaaaaa bbbbbbbbbb".to_vec()),
552 Op::EndText,
553 Op::BeginText,
554 Op::SetFont(Name("F1".into()), 10.0),
555 Op::TextMove(0.0, 78.0),
556 Op::ShowText(b"cccccccccc".to_vec()),
557 Op::EndText,
558 ]
559 );
560 }
561
562 #[test]
563 fn paragraph_overflow_reports_lines_fit_and_needed() {
564 let mut canvas = Canvas::new();
565 let mut links = Vec::new();
566 let paragraph = Paragraph {
567 text: "aaaaaaaaa bbbbbbbbbb cccccccccc".into(),
568 rect: [0.0, 80.0, 120.0, 95.0],
569 font: Standard14::Courier,
570 size: 10.0,
571 ..Paragraph::default()
572 };
573 let err = lower(vec![paragraph.into()], &mut canvas, &mut links).unwrap_err();
574 match err {
575 Error::Other(msg) => {
576 assert_eq!(msg, "paragraph overflows its rect: 1 lines fit, 2 needed")
577 }
578 other => panic!("expected Error::Other, got {other:?}"),
579 }
580 }
581
582 #[test]
583 fn paragraph_justify_stretches_non_final_lines_and_resets_once() {
584 let mut canvas = Canvas::new();
585 let mut links = Vec::new();
586 let paragraph = Paragraph {
587 text: "aaaaaaa bbbbbbb ddddd".into(),
588 rect: [0.0, 0.0, 120.0, 100.0],
589 font: Standard14::Courier,
590 size: 10.0,
591 align: ParagraphAlign::Justify,
592 ..Paragraph::default()
593 };
594 lower(vec![paragraph.into()], &mut canvas, &mut links).unwrap();
595 assert_eq!(
596 canvas.ops(),
597 [
598 Op::SetFillGray(0.0),
599 Op::SetWordSpacing(30.0),
600 Op::BeginText,
601 Op::SetFont(Name("F1".into()), 10.0),
602 Op::TextMove(0.0, 90.0),
603 Op::ShowText(b"aaaaaaa bbbbbbb".to_vec()),
604 Op::EndText,
605 Op::SetWordSpacing(0.0),
606 Op::BeginText,
607 Op::SetFont(Name("F1".into()), 10.0),
608 Op::TextMove(0.0, 78.0),
609 Op::ShowText(b"ddddd".to_vec()),
610 Op::EndText,
611 ],
612 "the reset must land before the final line's BeginText, not after it \
613 (Tw is persistent text state that BeginText does not clear)"
614 );
615
616 let bytes = crate::content::serialize_ops(canvas.ops());
617 let parsed = parse_content(&bytes).unwrap();
618 let stretched: Vec<f32> = parsed
619 .iter()
620 .filter_map(|op| match op {
621 Op::SetWordSpacing(value) if *value > 0.0 => Some(*value),
622 _ => None,
623 })
624 .collect();
625 assert_eq!(stretched, [30.0]);
626 assert!(parsed.contains(&Op::SetWordSpacing(0.0)));
627 }
628
629 #[test]
630 fn paragraph_justify_final_line_with_multiple_words_gets_no_leftover_spacing() {
631 let mut canvas = Canvas::new();
632 let mut links = Vec::new();
633 let paragraph = Paragraph {
634 text: "aaaaaaa bbbbbbb ccccc dd".into(),
635 rect: [0.0, 0.0, 120.0, 100.0],
636 font: Standard14::Courier,
637 size: 10.0,
638 align: ParagraphAlign::Justify,
639 ..Paragraph::default()
640 };
641 lower(vec![paragraph.into()], &mut canvas, &mut links).unwrap();
642 let expected = [
643 Op::SetFillGray(0.0),
644 Op::SetWordSpacing(30.0),
645 Op::BeginText,
646 Op::SetFont(Name("F1".into()), 10.0),
647 Op::TextMove(0.0, 90.0),
648 Op::ShowText(b"aaaaaaa bbbbbbb".to_vec()),
649 Op::EndText,
650 Op::SetWordSpacing(0.0),
651 Op::BeginText,
652 Op::SetFont(Name("F1".into()), 10.0),
653 Op::TextMove(0.0, 78.0),
654 Op::ShowText(b"ccccc dd".to_vec()),
655 Op::EndText,
656 ];
657 assert_eq!(
658 canvas.ops(),
659 expected,
660 "the final line has 2 words (a space glyph) — a leftover non-zero \
661 Tw here would visibly over-stretch it, which is exactly the bug"
662 );
663
664 let bytes = crate::content::serialize_ops(canvas.ops());
665 let parsed = parse_content(&bytes).unwrap();
666 let reset_index = parsed
667 .iter()
668 .position(|op| *op == Op::SetWordSpacing(0.0))
669 .expect("a zero reset must be present");
670 let final_show_index = parsed
671 .iter()
672 .position(|op| matches!(op, Op::ShowText(s) if s == b"ccccc dd"))
673 .expect("final line's ShowText must be present");
674 assert!(
675 reset_index < final_show_index,
676 "reset (index {reset_index}) must precede the final line's ShowText \
677 (index {final_show_index}): {parsed:?}"
678 );
679 let stray_nonzero_between = parsed[reset_index..final_show_index]
680 .iter()
681 .any(|op| matches!(op, Op::SetWordSpacing(value) if *value != 0.0));
682 assert!(
683 !stray_nonzero_between,
684 "no non-zero word spacing may sit between the reset and the final \
685 line's ShowText: {:?}",
686 &parsed[reset_index..final_show_index]
687 );
688 }
689
690 #[test]
691 fn paragraph_trailing_blank_line_does_not_become_the_justify_final_line() {
692 let text = "aaaaaaa bbbbbbb ccccc dd\n";
693
694 let mut canvas = Canvas::new();
695 let mut links = Vec::new();
696 let paragraph = Paragraph {
697 text: text.into(),
698 rect: [0.0, 0.0, 120.0, 100.0],
699 font: Standard14::Courier,
700 size: 10.0,
701 align: ParagraphAlign::Justify,
702 ..Paragraph::default()
703 };
704 lower(vec![paragraph.into()], &mut canvas, &mut links).unwrap();
705 assert_eq!(
706 canvas.ops(),
707 [
708 Op::SetFillGray(0.0),
709 Op::SetWordSpacing(30.0),
710 Op::BeginText,
711 Op::SetFont(Name("F1".into()), 10.0),
712 Op::TextMove(0.0, 90.0),
713 Op::ShowText(b"aaaaaaa bbbbbbb".to_vec()),
714 Op::EndText,
715 Op::SetWordSpacing(0.0),
716 Op::BeginText,
717 Op::SetFont(Name("F1".into()), 10.0),
718 Op::TextMove(0.0, 78.0),
719 Op::ShowText(b"ccccc dd".to_vec()),
720 Op::EndText,
721 ],
722 "the trailing blank line (from the trailing \\n) must not steal the \
723 'final line never stretches' exemption from \"ccccc dd\""
724 );
725
726 let tight_rect_without_trailing_newline = Paragraph {
727 text: "aaaaaaa bbbbbbb ccccc dd".into(),
728 rect: [0.0, 0.0, 120.0, 24.0],
729 font: Standard14::Courier,
730 size: 10.0,
731 align: ParagraphAlign::Justify,
732 ..Paragraph::default()
733 };
734 let mut fits_canvas = Canvas::new();
735 lower(
736 vec![tight_rect_without_trailing_newline.into()],
737 &mut fits_canvas,
738 &mut links,
739 )
740 .expect("two visible lines fit in a rect sized for exactly two lines");
741
742 let tight_rect_with_trailing_newline = Paragraph {
743 text: text.into(),
744 rect: [0.0, 0.0, 120.0, 24.0],
745 font: Standard14::Courier,
746 size: 10.0,
747 align: ParagraphAlign::Justify,
748 ..Paragraph::default()
749 };
750 let mut overflow_canvas = Canvas::new();
751 let err = lower(
752 vec![tight_rect_with_trailing_newline.into()],
753 &mut overflow_canvas,
754 &mut links,
755 )
756 .unwrap_err();
757 match err {
758 Error::Other(msg) => assert_eq!(
759 msg, "paragraph overflows its rect: 2 lines fit, 3 needed",
760 "the trailing blank line must still count toward vertical advance"
761 ),
762 other => panic!("expected Error::Other, got {other:?}"),
763 }
764 }
765
766 #[test]
767 fn paragraph_center_and_right_align_offset_by_rect_width() {
768 let mut links = Vec::new();
769 let base = Paragraph {
770 text: "aaaaaaaaa".into(),
771 rect: [0.0, 0.0, 120.0, 50.0],
772 font: Standard14::Courier,
773 size: 10.0,
774 ..Paragraph::default()
775 };
776
777 let mut center_canvas = Canvas::new();
778 lower(
779 vec![Paragraph {
780 align: ParagraphAlign::Center,
781 ..base.clone()
782 }
783 .into()],
784 &mut center_canvas,
785 &mut links,
786 )
787 .unwrap();
788 assert_eq!(center_canvas.ops()[3], Op::TextMove(33.0, 40.0));
789
790 let mut right_canvas = Canvas::new();
791 lower(
792 vec![Paragraph {
793 align: ParagraphAlign::Right,
794 ..base
795 }
796 .into()],
797 &mut right_canvas,
798 &mut links,
799 )
800 .unwrap();
801 assert_eq!(right_canvas.ops()[3], Op::TextMove(66.0, 40.0));
802 }
803
804 #[test]
805 fn paragraph_blank_line_keeps_advance_without_drawing() {
806 let mut canvas = Canvas::new();
807 let mut links = Vec::new();
808 let paragraph = Paragraph {
809 text: "a\n\nb".into(),
810 rect: [0.0, 0.0, 100.0, 100.0],
811 font: Standard14::Helvetica,
812 size: 10.0,
813 ..Paragraph::default()
814 };
815 lower(vec![paragraph.into()], &mut canvas, &mut links).unwrap();
816 let moves: Vec<&Op> = canvas
817 .ops()
818 .iter()
819 .filter(|op| matches!(op, Op::TextMove(..)))
820 .collect();
821 assert_eq!(
822 moves,
823 [&Op::TextMove(0.0, 90.0), &Op::TextMove(0.0, 66.0)],
824 "blank line should still consume a leading slot"
825 );
826 let shows: Vec<&Op> = canvas
827 .ops()
828 .iter()
829 .filter(|op| matches!(op, Op::ShowText(..)))
830 .collect();
831 assert_eq!(
832 shows,
833 [&Op::ShowText(b"a".to_vec()), &Op::ShowText(b"b".to_vec()),]
834 );
835 }
836
837 #[test]
838 fn paragraph_leading_override_changes_line_advance() {
839 let mut canvas = Canvas::new();
840 let mut links = Vec::new();
841 let paragraph = Paragraph {
842 text: "aaaaaaaaaa bbbbbbbbbb".into(),
843 rect: [0.0, 0.0, 60.0, 100.0],
844 font: Standard14::Courier,
845 size: 10.0,
846 leading: Some(20.0),
847 ..Paragraph::default()
848 };
849 lower(vec![paragraph.into()], &mut canvas, &mut links).unwrap();
850 let moves: Vec<&Op> = canvas
851 .ops()
852 .iter()
853 .filter(|op| matches!(op, Op::TextMove(..)))
854 .collect();
855 assert_eq!(moves, [&Op::TextMove(0.0, 90.0), &Op::TextMove(0.0, 70.0)]);
856 }
857
858 #[test]
859 fn paragraph_propagates_unencodable_character_error_untouched() {
860 let mut canvas = Canvas::new();
861 let mut links = Vec::new();
862 let paragraph = Paragraph {
863 text: "\u{2318}".into(),
864 rect: [0.0, 0.0, 100.0, 100.0],
865 font: Standard14::Helvetica,
866 size: 10.0,
867 ..Paragraph::default()
868 };
869 let err = lower(vec![paragraph.into()], &mut canvas, &mut links).unwrap_err();
870 assert!(matches!(
871 err,
872 Error::Unencodable {
873 ch: '\u{2318}',
874 font: "Helvetica"
875 }
876 ));
877 }
878
879 #[test]
880 fn image_draw_uses_placed_size() {
881 use pdfboss_core::Matrix;
882
883 let mut canvas = Canvas::new();
884 let image = Image {
885 data: ImageData::gray8(2, 2, vec![0u8; 4]).unwrap(),
886 at: Point::new(5.0, 6.0),
887 width: Some(40.0),
888 height: None,
889 };
890 image.draw(&mut canvas).unwrap();
891 assert_eq!(
892 canvas.ops(),
893 [
894 Op::Save,
895 Op::Concat(Matrix {
896 a: 40.0,
897 b: 0.0,
898 c: 0.0,
899 d: 40.0,
900 e: 5.0,
901 f: 6.0,
902 }),
903 Op::XObject(Name("Im1".into())),
904 Op::Restore,
905 ]
906 );
907 }
908}