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