1use 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
16fn update_min(slot: &mut Option<PdfPoints>, value: PdfPoints) {
18 match *slot {
19 Some(current) if current <= value => {}
20 _ => *slot = Some(value),
21 }
22}
23
24fn update_max(slot: &mut Option<PdfPoints>, value: PdfPoints) {
26 match *slot {
27 Some(current) if current >= value => {}
28 _ => *slot = Some(value),
29 }
30}
31
32pub struct PdfStyledString<'a> {
34 text: String,
35 font: MaybeOwned<'a, PdfFont<'a>>,
36 font_size: PdfPoints,
37}
38
39impl<'a> PdfStyledString<'a> {
40 #[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 #[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 #[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 #[inline]
73 pub fn text(&self) -> &str {
74 self.text.as_str()
75 }
76
77 #[inline]
79 pub fn font(&self) -> &PdfFont<'_> {
80 self.font.as_ref()
81 }
82
83 #[inline]
85 pub fn font_size(&self) -> PdfPoints {
86 self.font_size
87 }
88
89 #[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 #[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 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 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 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 #[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
205pub enum PdfParagraphFragment<'a> {
208 StyledString(PdfStyledString<'a>),
210 LineBreak {
212 alignment: PdfLineAlignment,
213 bottom: PdfPoints,
214 left: PdfPoints,
215 },
216 NonTextObject(FPDF_PAGEOBJECT),
218}
219
220#[derive(Copy, Clone, Debug, PartialEq)]
222pub enum PdfParagraphAlignment {
223 LeftAlign,
225
226 RightAlign,
228
229 Center,
231
232 Justify,
234
235 ForceJustify,
237}
238
239#[derive(Copy, Clone, Debug, PartialEq)]
241pub enum PdfLineAlignment {
242 None,
244 LeftAlign,
246 RightAlign,
248 Center,
250 Justify,
252}
253
254pub struct PdfLine<'a> {
256 pub alignment: PdfLineAlignment,
258 pub bottom: PdfPoints,
260 pub left: PdfPoints,
262 pub width: PdfPoints,
264 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
287pub 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 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 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 #[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 #[inline]
607 pub fn is_empty(&self) -> bool {
608 self.fragments.is_empty()
609 }
610
611 #[inline]
613 pub fn fragments(&self) -> &[PdfParagraphFragment<'a>] {
614 &self.fragments
615 }
616
617 #[inline]
619 pub fn bottom(&self) -> Option<PdfPoints> {
620 self.bottom
621 }
622
623 #[inline]
625 pub fn left(&self) -> Option<PdfPoints> {
626 self.left
627 }
628
629 #[inline]
631 pub fn alignment(&self) -> PdfParagraphAlignment {
632 self.alignment
633 }
634
635 #[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 #[inline]
651 pub fn maximum_width(&self) -> PdfPoints {
652 self.max_width.unwrap_or(PdfPoints::ZERO)
653 }
654
655 #[inline]
657 pub fn set_maximum_width(&mut self, width: PdfPoints) {
658 self.max_width = Some(width);
659 }
660
661 #[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 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 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
759fn 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}