Skip to main content

pdfboss_write/
element.rs

1//! The compose element vocabulary: painted content one step above raw
2//! canvas operators. A [`Content`] value sits in [`crate::pdf::Page::content`]
3//! and lowers onto the page's [`Canvas`] at assemble time, after any
4//! operators the caller already painted there directly — elements paint
5//! over manual canvas work, never under it.
6
7use 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
17/// Something that paints itself onto a [`Canvas`]. `Send` so a
18/// [`Content::Custom`] value never blocks a `Pdf` from crossing a thread
19/// boundary.
20pub trait Draw: Send {
21    /// Paints this value's content onto `canvas`.
22    fn draw(&self, canvas: &mut Canvas) -> Result<()>;
23}
24
25/// One line of text at a fixed baseline origin.
26#[derive(Debug, Clone, PartialEq)]
27pub struct Text {
28    /// The characters to show.
29    pub value: String,
30    /// Baseline origin, in page user-space units.
31    pub at: Point,
32    /// Face to draw in.
33    pub font: Standard14,
34    /// Font size, in points.
35    pub size: f32,
36    /// Fill color.
37    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/// A raster image placed at a point.
60///
61/// No `Default`: there is no meaningful default for `data`.
62#[derive(Debug, Clone, PartialEq)]
63pub struct Image {
64    /// The pixels to embed.
65    pub data: ImageData,
66    /// Placement origin (the box's bottom-left corner), in page user-space
67    /// units.
68    pub at: Point,
69    /// Explicit width, in points. `None` derives it from `height` (by
70    /// aspect) or, with `height` also `None`, from the image's pixel width
71    /// at 72 dpi.
72    pub width: Option<f32>,
73    /// Explicit height, in points. `None` derives it from `width` (by
74    /// aspect) or, with `width` also `None`, from the image's pixel height
75    /// at 72 dpi.
76    pub height: Option<f32>,
77}
78
79impl Image {
80    /// The box this image paints into: the natural size at 72 dpi when
81    /// neither dimension is given, the other dimension scaled by aspect
82    /// when one is given, or the exact box when both are given.
83    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/// A clickable rectangle, lowered into a [`LinkAnnotation`] on the page.
105#[derive(Debug, Clone, PartialEq)]
106pub struct Link {
107    /// The clickable area, `[x0, y0, x1, y1]` in the page's user space.
108    pub rect: [f32; 4],
109    /// Where the link goes.
110    pub target: LinkTarget,
111}
112
113/// Horizontal alignment for a [`Paragraph`].
114#[derive(Debug, Clone, Copy, PartialEq, Default)]
115pub enum ParagraphAlign {
116    /// Flush to the rect's left edge.
117    #[default]
118    Left,
119    /// Centered within the rect width.
120    Center,
121    /// Flush to the rect's right edge.
122    Right,
123    /// Flush to both edges: word spacing stretches every line but the
124    /// last.
125    Justify,
126}
127
128/// A block of text wrapped, aligned, and (for [`ParagraphAlign::Justify`])
129/// stretched to fill a rectangle.
130#[derive(Debug, Clone, PartialEq)]
131pub struct Paragraph {
132    /// The text to lay out. `\n` forces a line break; other whitespace runs
133    /// between words collapse to a single space.
134    pub text: String,
135    /// The box to wrap into, `[x0, y0, x1, y1]` in page user-space units.
136    pub rect: [f32; 4],
137    /// Face to draw in.
138    pub font: Standard14,
139    /// Font size, in points.
140    pub size: f32,
141    /// Line-to-line advance, in points. `None` defaults to `1.2 * size`.
142    pub leading: Option<f32>,
143    /// Horizontal alignment within the rect.
144    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
160/// Greedily wraps `text` to `max_width`: `\n` forces a break, other
161/// whitespace runs between words collapse to single spaces, and a blank
162/// source line yields an empty line so its vertical advance survives. A
163/// word wider than `max_width` on its own still gets a line — no
164/// hyphenation.
165fn 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
196/// The number of lines whose baseline fits between `y0` and the first
197/// baseline at `y1 - size`, stepping down by `leading` per further line. A
198/// small epsilon absorbs floating-point rounding at exact boundaries.
199fn 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
208/// `Tw` (word spacing) is persistent text state that `BeginText` does not
209/// clear, so a stretch an earlier line left active must be reset before the
210/// first following line that does not itself stretch — not only once, at
211/// the very end.
212impl 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
263/// One piece of composed page content.
264pub enum Content {
265    /// A line of text.
266    Text(Text),
267    /// A placed image.
268    Image(Image),
269    /// A clickable link area.
270    Link(Link),
271    /// A wrapped, aligned block of text.
272    Paragraph(Paragraph),
273    /// Anything implementing [`Draw`].
274    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    /// Wraps any [`Draw`] value as custom content.
315    pub fn custom(value: impl Draw + 'static) -> Content {
316        Content::Custom(Box::new(value))
317    }
318}
319
320/// Paints `content` onto `canvas`, in order, after any operators already
321/// there; link elements are appended to `links` instead of painted.
322pub(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}