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    /// Fill color.
146    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
163/// Greedily wraps `text` to `max_width`: `\n` forces a break, other
164/// whitespace runs between words collapse to single spaces, and a blank
165/// source line yields an empty line so its vertical advance survives. A
166/// word wider than `max_width` on its own still gets a line — no
167/// hyphenation.
168fn 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
199/// The number of lines whose baseline fits between `y0` and the first
200/// baseline at `y1 - size`, stepping down by `leading` per further line. A
201/// small epsilon absorbs floating-point rounding at exact boundaries.
202fn 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
211/// `Tw` (word spacing) is persistent text state that `BeginText` does not
212/// clear, so a stretch an earlier line left active must be reset before the
213/// first following line that does not itself stretch — not only once, at
214/// the very end.
215impl 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
267/// One piece of composed page content.
268pub enum Content {
269    /// A line of text.
270    Text(Text),
271    /// A placed image.
272    Image(Image),
273    /// A clickable link area.
274    Link(Link),
275    /// A wrapped, aligned block of text.
276    Paragraph(Paragraph),
277    /// Anything implementing [`Draw`].
278    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    /// Wraps any [`Draw`] value as custom content.
319    pub fn custom(value: impl Draw + 'static) -> Content {
320        Content::Custom(Box::new(value))
321    }
322}
323
324/// Paints `content` onto `canvas`, in order, after any operators already
325/// there; link elements are appended to `links` instead of painted.
326pub(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}