Skip to main content

pdfium_render/pdf/document/page/
paragraph.rs

1//! Defines the [PdfParagraph] struct, exposing functionality related to a group of
2//! styled text strings that should be laid out together on a `PdfPage` as single paragraph.
3
4use crate::bindgen::FPDF_PAGEOBJECT;
5use crate::error::PdfiumError;
6use crate::pdf::document::PdfDocument;
7use crate::pdf::document::page::object::private::internal::PdfPageObjectPrivate;
8use crate::pdf::document::page::object::text::PdfPageTextObject;
9use crate::pdf::document::page::object::{PdfPageObject, PdfPageObjectCommon};
10use crate::pdf::font::{PdfFont, PdfFontWeight};
11use crate::pdf::points::PdfPoints;
12use itertools::Itertools;
13use maybe_owned::MaybeOwned;
14use std::cmp::Ordering;
15
16/// Update an `Option<PdfPoints>` to track the minimum value seen.
17fn update_min(slot: &mut Option<PdfPoints>, value: PdfPoints) {
18    match *slot {
19        Some(current) if current <= value => {}
20        _ => *slot = Some(value),
21    }
22}
23
24/// Update an `Option<PdfPoints>` to track the maximum value seen.
25fn update_max(slot: &mut Option<PdfPoints>, value: PdfPoints) {
26    match *slot {
27        Some(current) if current >= value => {}
28        _ => *slot = Some(value),
29    }
30}
31
32/// A single styled string in a [PdfParagraph].
33pub struct PdfStyledString<'a> {
34    text: String,
35    font: MaybeOwned<'a, PdfFont<'a>>,
36    font_size: PdfPoints,
37}
38
39impl<'a> PdfStyledString<'a> {
40    /// Creates a new [PdfStyledString] from the given arguments.
41    #[inline]
42    pub fn new(text: String, font: &'a PdfFont<'a>, font_size: PdfPoints) -> Self {
43        PdfStyledString {
44            text,
45            font: MaybeOwned::Borrowed(font),
46            font_size,
47        }
48    }
49
50    /// Creates a new [PdfStyledString] from the given [PdfPageTextObject].
51    #[inline]
52    pub fn from_text_object(text_object: &'a PdfPageTextObject<'a>) -> Self {
53        PdfStyledString {
54            text: text_object.text(),
55            font: MaybeOwned::Owned(text_object.font()),
56            font_size: text_object.unscaled_font_size(),
57        }
58    }
59
60    /// Adds the given string to the text in this [PdfStyledString]. The given separator will be used
61    /// to separate the existing text in this [PdfStyledString] from the given string.
62    #[inline]
63    pub(crate) fn push(&mut self, text: impl ToString, separator: &str) {
64        if !self.text.ends_with(separator) {
65            self.text.push_str(separator);
66        }
67
68        self.text.push_str(text.to_string().as_str());
69    }
70
71    /// Returns the text in this [PdfStyledString].
72    #[inline]
73    pub fn text(&self) -> &str {
74        self.text.as_str()
75    }
76
77    /// Returns the [PdfFont] used to style this [PdfStyledString].
78    #[inline]
79    pub fn font(&self) -> &PdfFont<'_> {
80        self.font.as_ref()
81    }
82
83    /// Returns the font size used to style this [PdfStyledString].
84    #[inline]
85    pub fn font_size(&self) -> PdfPoints {
86        self.font_size
87    }
88
89    /// Returns `true` if the font and font size of this [PdfStyledString] is the same as
90    /// that of the given string.
91    #[inline]
92    pub fn does_match_string_styling(&self, other: &PdfStyledString) -> bool {
93        self.does_match_raw_styling(other.font_size(), other.font())
94    }
95
96    /// Returns `true` if the font and font size of this [PdfStyledString] is the same as
97    /// that of the given [PdfPageTextObject].
98    #[inline]
99    pub fn does_match_object_styling(&self, other: &PdfPageTextObject) -> bool {
100        self.does_match_raw_styling(other.unscaled_font_size(), &other.font())
101    }
102
103    /// Returns `true` if this styled string's font is bold.
104    ///
105    /// Checks the font descriptor's force-bold flag, the font weight (>= 700),
106    /// and the font family name for "bold" substring.
107    pub fn is_bold(&self) -> bool {
108        let font = self.font();
109
110        if font.is_bold_reenforced() {
111            return true;
112        }
113
114        if let Ok(weight) = font.weight()
115            && matches!(
116                weight,
117                PdfFontWeight::Weight700Bold | PdfFontWeight::Weight800 | PdfFontWeight::Weight900
118            )
119        {
120            return true;
121        }
122
123        font.family().to_lowercase().contains("bold")
124    }
125
126    /// Returns `true` if this styled string's font is italic.
127    ///
128    /// Checks the font descriptor's italic flag and the font family name
129    /// for "italic" or "oblique" substrings.
130    pub fn is_italic(&self) -> bool {
131        let font = self.font();
132
133        if font.is_italic() {
134            return true;
135        }
136
137        let name = font.family().to_lowercase();
138        name.contains("italic") || name.contains("oblique")
139    }
140
141    /// Returns `true` if this styled string's font is monospace.
142    ///
143    /// Checks the font descriptor's fixed-pitch flag and the font family name
144    /// against common monospace font patterns.
145    pub fn is_monospace(&self) -> bool {
146        let font = self.font();
147
148        if font.is_fixed_pitch() {
149            return true;
150        }
151
152        let name = font.family().to_lowercase();
153        const MONOSPACE_PATTERNS: &[&str] = &[
154            "mono",
155            "courier",
156            "consolas",
157            "menlo",
158            "source code",
159            "inconsolata",
160            "fira code",
161            "liberation mono",
162            "lucida console",
163            "andale mono",
164            "dejavu sans mono",
165            "roboto mono",
166            "noto mono",
167            "ibm plex mono",
168            "jetbrains mono",
169            "cascadia",
170            "hack",
171        ];
172        MONOSPACE_PATTERNS.iter().any(|p| name.contains(p))
173    }
174
175    fn does_match_raw_styling(&self, other_font_size: PdfPoints, other_font: &PdfFont) -> bool {
176        if self.font_size() != other_font_size {
177            return false;
178        }
179
180        let this_font = self.font();
181
182        if this_font.handle() != other_font.handle() {
183            return false;
184        }
185
186        let this_font_name = this_font.family();
187
188        let other_font_name = other_font.family();
189
190        if this_font_name.is_empty() && other_font_name.is_empty() {
191            return true;
192        }
193
194        (!this_font_name.is_empty() || !other_font_name.is_empty()) && this_font_name == other_font_name
195    }
196
197    /// Creates a new [PdfPageTextObject] from this styled string, using the Pdfium bindings in
198    /// the given document.
199    #[inline]
200    pub fn as_text_object(&self, document: &PdfDocument<'a>) -> Result<PdfPageTextObject<'a>, PdfiumError> {
201        PdfPageTextObject::new(document, self.text(), self.font(), self.font_size())
202    }
203}
204
205/// A single fragment in a [PdfParagraph]. The fragment may later be split into sub-fragments when
206/// assembling the [PdfParagraph] into lines.
207pub enum PdfParagraphFragment<'a> {
208    /// A run of styled text.
209    StyledString(PdfStyledString<'a>),
210    /// A line break with alignment and position information from the preceding line.
211    LineBreak {
212        alignment: PdfLineAlignment,
213        bottom: PdfPoints,
214        left: PdfPoints,
215    },
216    /// A non-text page object (image, path, shading, etc.).
217    NonTextObject(FPDF_PAGEOBJECT),
218}
219
220/// Controls the line alignment behaviour of a [PdfParagraph].
221#[derive(Copy, Clone, Debug, PartialEq)]
222pub enum PdfParagraphAlignment {
223    /// All lines will be non-justified, aligned to the left.
224    LeftAlign,
225
226    /// All lines will be non-justified, aligned to the right.
227    RightAlign,
228
229    /// All lines will be non-justified and centered.
230    Center,
231
232    /// All lines except the last will be justified.
233    Justify,
234
235    /// All lines, including the last, will be justified.
236    ForceJustify,
237}
238
239/// The paragraph-relative alignment of a single [PdfLine].
240#[derive(Copy, Clone, Debug, PartialEq)]
241pub enum PdfLineAlignment {
242    /// No alignment detected.
243    None,
244    /// Left-aligned.
245    LeftAlign,
246    /// Right-aligned.
247    RightAlign,
248    /// Centered.
249    Center,
250    /// Justified.
251    Justify,
252}
253
254/// A span of paragraph fragments that make up one line in a [PdfParagraph].
255pub struct PdfLine<'a> {
256    /// The alignment of this line within the paragraph.
257    pub alignment: PdfLineAlignment,
258    /// The bottom Y position of this line in PDF points.
259    pub bottom: PdfPoints,
260    /// The left X position of this line in PDF points.
261    pub left: PdfPoints,
262    /// The width of this line in PDF points.
263    pub width: PdfPoints,
264    /// The fragments composing this line.
265    pub fragments: Vec<PdfParagraphFragment<'a>>,
266}
267
268impl<'a> PdfLine<'a> {
269    #[inline]
270    fn new(
271        alignment: PdfLineAlignment,
272        bottom: PdfPoints,
273        left: PdfPoints,
274        width: PdfPoints,
275        fragments: Vec<PdfParagraphFragment<'a>>,
276    ) -> Self {
277        PdfLine {
278            alignment,
279            bottom,
280            left,
281            width,
282            fragments,
283        }
284    }
285}
286
287/// A group of [PdfPageTextObject] objects contained in the same `PdfPageObjects` collection
288/// that should be laid out together as a single paragraph.
289///
290/// Text layout in PDF files is handled entirely by text objects. Each text object contains
291/// a single span of text that is styled consistently and can be at most a single line long.
292/// Multiple text objects stitched together visually at the time the page is generated are
293/// interpreted by the reader as paragraphs, but there is no concept in the PDF file format
294/// of a multi-line text block, and there is no native functionality for retrieving a single
295/// paragraph from its constituent text objects. This makes it difficult to work with long spans
296/// of text.
297///
298/// The [PdfParagraph] is an attempt to improve multi-line text handling. Paragraphs can
299/// be created from existing groups of page objects, or created by scratch; once created, text in
300/// a paragraph can be edited and re-formatted, and then used to generate a group of text objects
301/// that can be placed on a page.
302pub struct PdfParagraph<'a> {
303    fragments: Vec<PdfParagraphFragment<'a>>,
304    bottom: Option<PdfPoints>,
305    left: Option<PdfPoints>,
306    max_width: Option<PdfPoints>,
307    alignment: PdfParagraphAlignment,
308}
309
310impl<'a> PdfParagraph<'a> {
311    // ~keep TODO: lifetime issues, using iterator is a possibility but PdfPage::objects().iter()
312    // ~keep and PdfPageGroupObject::iter() return iterators over PdfPageObject<'a> whereas
313    // ~keep &[PdfPageObject<'a>] returns an iterator over &PdfPageObject<'a>
314
315    // #[inline]
316    // #[inline]
317
318    /// Creates a set of one or more [PdfParagraph] objects from the given slice of page objects.
319    pub fn from_objects(objects: &'a [PdfPageObject<'a>]) -> Vec<PdfParagraph<'a>> {
320        let mut lines = Vec::new();
321
322        let mut current_line_fragments = Vec::new();
323
324        let mut objects_bottom = None;
325
326        let mut objects_top = None;
327
328        let mut objects_left = None;
329
330        let mut objects_right = None;
331
332        let positioned_objects = objects
333            .iter()
334            .map(|object| {
335                let bounds = object.bounds().ok();
336
337                let object_bottom = bounds.map(|b| b.bottom()).unwrap_or(PdfPoints::ZERO);
338                let object_top = bounds.map(|b| b.top()).unwrap_or(PdfPoints::ZERO);
339                let object_left = bounds.map(|b| b.left()).unwrap_or(PdfPoints::ZERO);
340                let object_right = bounds.map(|b| b.right()).unwrap_or(PdfPoints::ZERO);
341
342                update_min(&mut objects_bottom, object_bottom);
343                update_max(&mut objects_top, object_top);
344                update_min(&mut objects_left, object_left);
345                update_max(&mut objects_right, object_right);
346
347                (object_bottom, object_top, object_left, object_right, object)
348            })
349            .sorted_by(|a, b| {
350                let (a_top, a_left) = (a.1, a.2);
351                let (b_top, b_left) = (b.1, b.2);
352
353                match b_top.value.total_cmp(&a_top.value) {
354                    Ordering::Equal => a_left.value.total_cmp(&b_left.value),
355                    other => other,
356                }
357            })
358            .collect::<Vec<_>>();
359
360        let positioned_objects: Vec<_> = positioned_objects
361            .into_iter()
362            .filter(|(_, _, _, _, object)| object.as_text_object().is_none() || !is_significantly_rotated(object))
363            .collect();
364
365        let paragraph_left = objects_left.unwrap_or(PdfPoints::ZERO);
366        let paragraph_right = objects_right.unwrap_or(paragraph_left);
367
368        let mut current_line_bottom = PdfPoints::ZERO;
369        let mut current_line_left = PdfPoints::ZERO;
370        let mut current_line_right = PdfPoints::ZERO;
371        let mut current_line_alignment = PdfLineAlignment::None;
372
373        let mut last_object_bottom = None;
374        let mut last_object_height = None;
375        let mut last_object_left = None;
376        let mut last_object_right = None;
377
378        for (bottom, top, left, right, object) in positioned_objects.iter() {
379            let top = *top;
380
381            let bottom = *bottom;
382
383            let left = *left;
384
385            let right = *right;
386
387            if last_object_left.is_none() || left < last_object_left.unwrap() {
388                let next_line_alignment = Self::guess_line_alignment(
389                    last_object_left,
390                    last_object_right,
391                    left,
392                    right,
393                    paragraph_left,
394                    paragraph_right,
395                );
396
397                if next_line_alignment != current_line_alignment
398                    || last_object_bottom.unwrap_or(PdfPoints::ZERO) - last_object_height.unwrap_or(PdfPoints::ZERO)
399                        > top
400                {
401                    lines.push(PdfLine::new(
402                        current_line_alignment,
403                        current_line_bottom,
404                        current_line_left,
405                        right - current_line_left,
406                        current_line_fragments,
407                    ));
408
409                    current_line_fragments = vec![PdfParagraphFragment::LineBreak {
410                        alignment: current_line_alignment,
411                        bottom,
412                        left,
413                    }];
414                    current_line_left = left;
415                    current_line_right = PdfPoints::ZERO;
416                    current_line_bottom = bottom;
417                    current_line_alignment = next_line_alignment;
418                }
419            }
420
421            last_object_left = Some(left);
422            last_object_right = Some(right);
423            last_object_bottom = Some(bottom);
424            last_object_height = Some(top - bottom);
425
426            if let Some(object) = object.as_text_object() {
427                current_line_right = right;
428
429                if let Some(PdfParagraphFragment::StyledString(last_string)) = current_line_fragments.last_mut() {
430                    if last_string.does_match_object_styling(object) {
431                        let separator = if let Ok(bounds) = object.bounds() {
432                            if let Some(last_object_right) = last_object_right {
433                                if last_object_right > bounds.left() { "" } else { " " }
434                            } else {
435                                ""
436                            }
437                        } else {
438                            " "
439                        };
440
441                        last_string.push(object.text(), separator);
442                    } else {
443                        current_line_fragments.push(PdfParagraphFragment::StyledString(
444                            PdfStyledString::from_text_object(object),
445                        ));
446                    }
447                } else {
448                    current_line_fragments.push(PdfParagraphFragment::StyledString(PdfStyledString::from_text_object(
449                        object,
450                    )));
451                }
452            } else {
453                current_line_fragments.push(PdfParagraphFragment::NonTextObject(object.object_handle()));
454            }
455        }
456
457        lines.push(PdfLine::new(
458            current_line_alignment,
459            current_line_bottom,
460            current_line_left,
461            current_line_right - current_line_left,
462            current_line_fragments,
463        ));
464
465        let mut paragraphs = Vec::new();
466
467        let mut current_paragraph_fragments = Vec::new();
468
469        let mut current_paragraph_bottom = None;
470
471        let mut current_paragraph_left = None;
472
473        let mut current_paragraph_right = None;
474
475        let mut last_line_alignment = lines
476            .first()
477            .map(|line| line.alignment)
478            .unwrap_or(PdfLineAlignment::None);
479
480        let mut first_line_alignment = last_line_alignment;
481
482        for mut line in lines.drain(..) {
483            if line.alignment != last_line_alignment {
484                // ~keep TODO: this won't work as expected for non-force-justified paragraphs
485                // ~keep where the last line in the paragraph is left-aligned, not justified
486
487                if !current_paragraph_fragments.is_empty() {
488                    paragraphs.push(Self::paragraph_from_lines(
489                        current_paragraph_fragments,
490                        current_paragraph_bottom,
491                        current_paragraph_left,
492                        current_paragraph_right,
493                        first_line_alignment,
494                        last_line_alignment,
495                    ));
496
497                    current_paragraph_fragments = Vec::new();
498                    current_paragraph_bottom = None;
499                    current_paragraph_left = None;
500                    current_paragraph_right = None;
501                    first_line_alignment = last_line_alignment
502                }
503            }
504
505            current_paragraph_fragments.append(&mut line.fragments);
506
507            last_line_alignment = line.alignment;
508
509            update_min(&mut current_paragraph_left, line.left);
510            update_max(&mut current_paragraph_right, line.left + line.width);
511            update_min(&mut current_paragraph_bottom, line.bottom);
512        }
513
514        paragraphs.push(Self::paragraph_from_lines(
515            current_paragraph_fragments,
516            current_paragraph_bottom,
517            current_paragraph_left,
518            current_paragraph_right,
519            first_line_alignment,
520            last_line_alignment,
521        ));
522
523        paragraphs
524    }
525
526    fn paragraph_from_lines(
527        fragments: Vec<PdfParagraphFragment<'a>>,
528        bottom: Option<PdfPoints>,
529        left: Option<PdfPoints>,
530        right: Option<PdfPoints>,
531        first_line_alignment: PdfLineAlignment,
532        last_line_alignment: PdfLineAlignment,
533    ) -> PdfParagraph<'a> {
534        PdfParagraph {
535            fragments,
536            bottom,
537            left,
538            max_width: match (left, right) {
539                (Some(left), Some(right)) => Some(right - left),
540                _ => None,
541            },
542            alignment: if first_line_alignment == last_line_alignment
543                && first_line_alignment == PdfLineAlignment::Justify
544            {
545                PdfParagraphAlignment::ForceJustify
546            } else {
547                match first_line_alignment {
548                    PdfLineAlignment::None | PdfLineAlignment::LeftAlign => PdfParagraphAlignment::LeftAlign,
549                    PdfLineAlignment::RightAlign => PdfParagraphAlignment::RightAlign,
550                    PdfLineAlignment::Center => PdfParagraphAlignment::Center,
551                    PdfLineAlignment::Justify => PdfParagraphAlignment::Justify,
552                }
553            },
554        }
555    }
556
557    fn guess_line_alignment(
558        previous_line_left: Option<PdfPoints>,
559        previous_line_right: Option<PdfPoints>,
560        line_left: PdfPoints,
561        line_right: PdfPoints,
562        paragraph_left: PdfPoints,
563        paragraph_right: PdfPoints,
564    ) -> PdfLineAlignment {
565        const ALIGNMENT_THRESHOLD: f32 = 2.0;
566
567        if let (Some(previous_line_left), Some(previous_line_right)) = (previous_line_left, previous_line_right) {
568            let is_aligned_left = (previous_line_left.value - line_left.value).abs() < ALIGNMENT_THRESHOLD;
569
570            let is_aligned_right = (previous_line_right.value - line_right.value).abs() < ALIGNMENT_THRESHOLD;
571
572            match (is_aligned_left, is_aligned_right) {
573                (true, true) => PdfLineAlignment::Justify,
574                (true, false) => PdfLineAlignment::LeftAlign,
575                (false, true) => PdfLineAlignment::RightAlign,
576                (false, false) => PdfLineAlignment::Center,
577            }
578        } else {
579            let is_aligned_left = (paragraph_left.value - line_left.value).abs() < ALIGNMENT_THRESHOLD;
580
581            let is_aligned_right = (paragraph_right.value - line_right.value).abs() < ALIGNMENT_THRESHOLD;
582
583            match (is_aligned_left, is_aligned_right) {
584                (true, true) => PdfLineAlignment::Justify,
585                (true, false) => PdfLineAlignment::LeftAlign,
586                (false, true) => PdfLineAlignment::RightAlign,
587                (false, false) => PdfLineAlignment::Center,
588            }
589        }
590    }
591
592    /// Creates a new, empty [PdfParagraph] with the given maximum line width
593    /// and alignment settings.
594    #[inline]
595    pub fn empty(maximum_width: PdfPoints, alignment: PdfParagraphAlignment) -> Self {
596        PdfParagraph {
597            fragments: vec![],
598            bottom: None,
599            left: None,
600            max_width: Some(maximum_width),
601            alignment,
602        }
603    }
604
605    /// Returns `true` if this [PdfParagraph] contains no fragments.
606    #[inline]
607    pub fn is_empty(&self) -> bool {
608        self.fragments.is_empty()
609    }
610
611    /// Returns a reference to the fragments in this paragraph.
612    #[inline]
613    pub fn fragments(&self) -> &[PdfParagraphFragment<'a>] {
614        &self.fragments
615    }
616
617    /// Returns the bottom Y position of this paragraph, if known.
618    #[inline]
619    pub fn bottom(&self) -> Option<PdfPoints> {
620        self.bottom
621    }
622
623    /// Returns the left X position of this paragraph, if known.
624    #[inline]
625    pub fn left(&self) -> Option<PdfPoints> {
626        self.left
627    }
628
629    /// Returns the alignment of this paragraph.
630    #[inline]
631    pub fn alignment(&self) -> PdfParagraphAlignment {
632        self.alignment
633    }
634
635    /// Adds a new fragment containing the given styled string to this paragraph.
636    #[inline]
637    pub fn push(&mut self, string: PdfStyledString<'a>) {
638        if let Some(PdfParagraphFragment::StyledString(last_string)) = self.fragments.last_mut() {
639            if last_string.does_match_string_styling(&string) {
640                last_string.push(string.text(), " ");
641            } else {
642                self.fragments.push(PdfParagraphFragment::StyledString(string));
643            }
644        } else {
645            self.fragments.push(PdfParagraphFragment::StyledString(string));
646        }
647    }
648
649    /// Returns the maximum line width of this paragraph.
650    #[inline]
651    pub fn maximum_width(&self) -> PdfPoints {
652        self.max_width.unwrap_or(PdfPoints::ZERO)
653    }
654
655    /// Sets the maximum line width of this paragraph to the given value.
656    #[inline]
657    pub fn set_maximum_width(&mut self, width: PdfPoints) {
658        self.max_width = Some(width);
659    }
660
661    /// Returns the text contained within all text fragments in this paragraph.
662    #[inline]
663    pub fn text(&self) -> String {
664        self.fragments
665            .iter()
666            .filter_map(|fragment| match fragment {
667                PdfParagraphFragment::StyledString(string) => Some(string.text.as_str()),
668                PdfParagraphFragment::LineBreak { .. } => Some("\n"),
669                _ => None,
670            })
671            .collect::<Vec<_>>()
672            .join("")
673    }
674
675    /// Returns the text contained within all text fragments in this paragraph,
676    /// separating each text fragment with the given separator.
677    pub fn text_separated(&self, separator: &str) -> String {
678        self.fragments
679            .iter()
680            .filter_map(|fragment| match fragment {
681                PdfParagraphFragment::StyledString(string) => Some(string.text.as_str()),
682                _ => None,
683            })
684            .collect::<Vec<_>>()
685            .join(separator)
686    }
687
688    /// Assembles the fragments in this paragraph into lines, taking into account the paragraph's
689    /// current sizing, overflow, indent, and alignment settings. Consumes the paragraph and
690    /// returns the assembled lines.
691    pub fn into_lines(self) -> Vec<PdfLine<'a>> {
692        let mut lines: Vec<PdfLine<'a>> = Vec::new();
693        let mut current_fragments: Vec<PdfParagraphFragment<'a>> = Vec::new();
694        let mut current_width = PdfPoints::ZERO;
695        let mut current_bottom = self.bottom.unwrap_or(PdfPoints::ZERO);
696        let mut current_left = self.left.unwrap_or(PdfPoints::ZERO);
697
698        let effective_max_width = self.max_width.unwrap_or(PdfPoints::new(f32::MAX));
699
700        for fragment in self.fragments {
701            match fragment {
702                PdfParagraphFragment::LineBreak {
703                    alignment,
704                    bottom: line_bottom,
705                    left: line_left,
706                } => {
707                    if !current_fragments.is_empty() {
708                        lines.push(PdfLine::new(
709                            alignment,
710                            current_bottom,
711                            current_left,
712                            current_width,
713                            std::mem::take(&mut current_fragments),
714                        ));
715                        current_width = PdfPoints::ZERO;
716                        current_bottom = line_bottom;
717                        current_left = line_left;
718                    }
719                }
720                PdfParagraphFragment::StyledString(ref styled) => {
721                    let estimated_width = PdfPoints::new(styled.text().len() as f32 * styled.font_size().value * 0.5);
722
723                    if current_width.value + estimated_width.value > effective_max_width.value
724                        && !current_fragments.is_empty()
725                    {
726                        lines.push(PdfLine::new(
727                            PdfLineAlignment::None,
728                            current_bottom,
729                            current_left,
730                            current_width,
731                            std::mem::take(&mut current_fragments),
732                        ));
733                        current_width = PdfPoints::ZERO;
734                    }
735
736                    current_width = PdfPoints::new(current_width.value + estimated_width.value);
737                    current_fragments.push(fragment);
738                }
739                PdfParagraphFragment::NonTextObject(_) => {
740                    current_fragments.push(fragment);
741                }
742            }
743        }
744
745        if !current_fragments.is_empty() {
746            lines.push(PdfLine::new(
747                PdfLineAlignment::None,
748                current_bottom,
749                current_left,
750                current_width,
751                current_fragments,
752            ));
753        }
754
755        lines
756    }
757}
758
759/// Returns true if a page object is rotated more than 10 degrees from horizontal.
760///
761/// Used to filter out vertical sidebar text (e.g. arXiv identifiers) that would
762/// otherwise produce individual characters interleaved with body text.
763fn is_significantly_rotated(object: &PdfPageObject) -> bool {
764    const ROTATION_THRESHOLD_DEGREES: f32 = 10.0;
765    let rotation = object.get_rotation_counter_clockwise_degrees().abs();
766    let normalized = if rotation > 180.0 { 360.0 - rotation } else { rotation };
767    normalized > ROTATION_THRESHOLD_DEGREES
768}
769
770#[cfg(test)]
771mod tests {
772    use crate::pdf::document::page::paragraph::PdfParagraph;
773    use crate::prelude::*;
774    use crate::utils::test::{test_bind_to_pdfium, test_fixture_path};
775
776    #[test]
777    fn test_paragraph_construction() -> Result<(), PdfiumError> {
778        let pdfium = test_bind_to_pdfium();
779
780        let document = pdfium.load_pdf_from_file(&test_fixture_path("text-test.pdf"), None)?;
781
782        let page = document.pages().get(0)?;
783
784        let objects = page.objects().iter().collect::<Vec<_>>();
785
786        let paragraphs = PdfParagraph::from_objects(objects.as_slice());
787
788        assert!(
789            !paragraphs.is_empty(),
790            "Expected at least one paragraph from page objects"
791        );
792
793        for paragraph in paragraphs.iter() {
794            let text = paragraph.text();
795            assert!(
796                !text.trim().is_empty() || paragraph.is_empty(),
797                "Non-empty paragraph should produce non-empty text"
798            );
799        }
800
801        for paragraph in paragraphs.iter() {
802            let separated = paragraph.text_separated(" ");
803            let plain = paragraph.text();
804            if !paragraph.is_empty() {
805                assert!(
806                    !separated.is_empty() || !plain.is_empty(),
807                    "Text extraction should return content for non-empty paragraphs"
808                );
809            }
810        }
811
812        Ok(())
813    }
814}