use crate::bindgen::FPDF_PAGEOBJECT;
use crate::error::PdfiumError;
use crate::pdf::document::PdfDocument;
use crate::pdf::document::page::object::private::internal::PdfPageObjectPrivate;
use crate::pdf::document::page::object::text::PdfPageTextObject;
use crate::pdf::document::page::object::{PdfPageObject, PdfPageObjectCommon};
use crate::pdf::font::{PdfFont, PdfFontWeight};
use crate::pdf::points::PdfPoints;
use itertools::Itertools;
use maybe_owned::MaybeOwned;
use std::cmp::Ordering;
fn update_min(slot: &mut Option<PdfPoints>, value: PdfPoints) {
match *slot {
Some(current) if current <= value => {}
_ => *slot = Some(value),
}
}
fn update_max(slot: &mut Option<PdfPoints>, value: PdfPoints) {
match *slot {
Some(current) if current >= value => {}
_ => *slot = Some(value),
}
}
pub struct PdfStyledString<'a> {
text: String,
font: MaybeOwned<'a, PdfFont<'a>>,
font_size: PdfPoints,
}
impl<'a> PdfStyledString<'a> {
#[inline]
pub fn new(text: String, font: &'a PdfFont<'a>, font_size: PdfPoints) -> Self {
PdfStyledString {
text,
font: MaybeOwned::Borrowed(font),
font_size,
}
}
#[inline]
pub fn from_text_object(text_object: &'a PdfPageTextObject<'a>) -> Self {
PdfStyledString {
text: text_object.text(),
font: MaybeOwned::Owned(text_object.font()),
font_size: text_object.unscaled_font_size(),
}
}
#[inline]
pub(crate) fn push(&mut self, text: impl ToString, separator: &str) {
if !self.text.ends_with(separator) {
self.text.push_str(separator);
}
self.text.push_str(text.to_string().as_str());
}
#[inline]
pub fn text(&self) -> &str {
self.text.as_str()
}
#[inline]
pub fn font(&self) -> &PdfFont<'_> {
self.font.as_ref()
}
#[inline]
pub fn font_size(&self) -> PdfPoints {
self.font_size
}
#[inline]
pub fn does_match_string_styling(&self, other: &PdfStyledString) -> bool {
self.does_match_raw_styling(other.font_size(), other.font())
}
#[inline]
pub fn does_match_object_styling(&self, other: &PdfPageTextObject) -> bool {
self.does_match_raw_styling(other.unscaled_font_size(), &other.font())
}
pub fn is_bold(&self) -> bool {
let font = self.font();
if font.is_bold_reenforced() {
return true;
}
if let Ok(weight) = font.weight()
&& matches!(
weight,
PdfFontWeight::Weight700Bold | PdfFontWeight::Weight800 | PdfFontWeight::Weight900
)
{
return true;
}
font.family().to_lowercase().contains("bold")
}
pub fn is_italic(&self) -> bool {
let font = self.font();
if font.is_italic() {
return true;
}
let name = font.family().to_lowercase();
name.contains("italic") || name.contains("oblique")
}
pub fn is_monospace(&self) -> bool {
let font = self.font();
if font.is_fixed_pitch() {
return true;
}
let name = font.family().to_lowercase();
const MONOSPACE_PATTERNS: &[&str] = &[
"mono",
"courier",
"consolas",
"menlo",
"source code",
"inconsolata",
"fira code",
"liberation mono",
"lucida console",
"andale mono",
"dejavu sans mono",
"roboto mono",
"noto mono",
"ibm plex mono",
"jetbrains mono",
"cascadia",
"hack",
];
MONOSPACE_PATTERNS.iter().any(|p| name.contains(p))
}
fn does_match_raw_styling(&self, other_font_size: PdfPoints, other_font: &PdfFont) -> bool {
if self.font_size() != other_font_size {
return false;
}
let this_font = self.font();
if this_font.handle() != other_font.handle() {
return false;
}
let this_font_name = this_font.family();
let other_font_name = other_font.family();
if this_font_name.is_empty() && other_font_name.is_empty() {
return true;
}
(!this_font_name.is_empty() || !other_font_name.is_empty()) && this_font_name == other_font_name
}
#[inline]
pub fn as_text_object(&self, document: &PdfDocument<'a>) -> Result<PdfPageTextObject<'a>, PdfiumError> {
PdfPageTextObject::new(document, self.text(), self.font(), self.font_size())
}
}
pub enum PdfParagraphFragment<'a> {
StyledString(PdfStyledString<'a>),
LineBreak {
alignment: PdfLineAlignment,
bottom: PdfPoints,
left: PdfPoints,
},
NonTextObject(FPDF_PAGEOBJECT),
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum PdfParagraphAlignment {
LeftAlign,
RightAlign,
Center,
Justify,
ForceJustify,
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum PdfLineAlignment {
None,
LeftAlign,
RightAlign,
Center,
Justify,
}
pub struct PdfLine<'a> {
pub alignment: PdfLineAlignment,
pub bottom: PdfPoints,
pub left: PdfPoints,
pub width: PdfPoints,
pub fragments: Vec<PdfParagraphFragment<'a>>,
}
impl<'a> PdfLine<'a> {
#[inline]
fn new(
alignment: PdfLineAlignment,
bottom: PdfPoints,
left: PdfPoints,
width: PdfPoints,
fragments: Vec<PdfParagraphFragment<'a>>,
) -> Self {
PdfLine {
alignment,
bottom,
left,
width,
fragments,
}
}
}
pub struct PdfParagraph<'a> {
fragments: Vec<PdfParagraphFragment<'a>>,
bottom: Option<PdfPoints>,
left: Option<PdfPoints>,
max_width: Option<PdfPoints>,
alignment: PdfParagraphAlignment,
}
impl<'a> PdfParagraph<'a> {
pub fn from_objects(objects: &'a [PdfPageObject<'a>]) -> Vec<PdfParagraph<'a>> {
let mut lines = Vec::new();
let mut current_line_fragments = Vec::new();
let mut objects_bottom = None;
let mut objects_top = None;
let mut objects_left = None;
let mut objects_right = None;
let positioned_objects = objects
.iter()
.map(|object| {
let bounds = object.bounds().ok();
let object_bottom = bounds.map(|b| b.bottom()).unwrap_or(PdfPoints::ZERO);
let object_top = bounds.map(|b| b.top()).unwrap_or(PdfPoints::ZERO);
let object_left = bounds.map(|b| b.left()).unwrap_or(PdfPoints::ZERO);
let object_right = bounds.map(|b| b.right()).unwrap_or(PdfPoints::ZERO);
update_min(&mut objects_bottom, object_bottom);
update_max(&mut objects_top, object_top);
update_min(&mut objects_left, object_left);
update_max(&mut objects_right, object_right);
(object_bottom, object_top, object_left, object_right, object)
})
.sorted_by(|a, b| {
let (a_top, a_left) = (a.1, a.2);
let (b_top, b_left) = (b.1, b.2);
match b_top.value.total_cmp(&a_top.value) {
Ordering::Equal => a_left.value.total_cmp(&b_left.value),
other => other,
}
})
.collect::<Vec<_>>();
let positioned_objects: Vec<_> = positioned_objects
.into_iter()
.filter(|(_, _, _, _, object)| object.as_text_object().is_none() || !is_significantly_rotated(object))
.collect();
let paragraph_left = objects_left.unwrap_or(PdfPoints::ZERO);
let paragraph_right = objects_right.unwrap_or(paragraph_left);
let mut current_line_bottom = PdfPoints::ZERO;
let mut current_line_left = PdfPoints::ZERO;
let mut current_line_right = PdfPoints::ZERO;
let mut current_line_alignment = PdfLineAlignment::None;
let mut last_object_bottom = None;
let mut last_object_height = None;
let mut last_object_left = None;
let mut last_object_right = None;
for (bottom, top, left, right, object) in positioned_objects.iter() {
let top = *top;
let bottom = *bottom;
let left = *left;
let right = *right;
if last_object_left.is_none() || left < last_object_left.unwrap() {
let next_line_alignment = Self::guess_line_alignment(
last_object_left,
last_object_right,
left,
right,
paragraph_left,
paragraph_right,
);
if next_line_alignment != current_line_alignment
|| last_object_bottom.unwrap_or(PdfPoints::ZERO) - last_object_height.unwrap_or(PdfPoints::ZERO)
> top
{
lines.push(PdfLine::new(
current_line_alignment,
current_line_bottom,
current_line_left,
right - current_line_left,
current_line_fragments,
));
current_line_fragments = vec![PdfParagraphFragment::LineBreak {
alignment: current_line_alignment,
bottom,
left,
}];
current_line_left = left;
current_line_right = PdfPoints::ZERO;
current_line_bottom = bottom;
current_line_alignment = next_line_alignment;
}
}
last_object_left = Some(left);
last_object_right = Some(right);
last_object_bottom = Some(bottom);
last_object_height = Some(top - bottom);
if let Some(object) = object.as_text_object() {
current_line_right = right;
if let Some(PdfParagraphFragment::StyledString(last_string)) = current_line_fragments.last_mut() {
if last_string.does_match_object_styling(object) {
let separator = if let Ok(bounds) = object.bounds() {
if let Some(last_object_right) = last_object_right {
if last_object_right > bounds.left() { "" } else { " " }
} else {
""
}
} else {
" "
};
last_string.push(object.text(), separator);
} else {
current_line_fragments.push(PdfParagraphFragment::StyledString(
PdfStyledString::from_text_object(object),
));
}
} else {
current_line_fragments.push(PdfParagraphFragment::StyledString(PdfStyledString::from_text_object(
object,
)));
}
} else {
current_line_fragments.push(PdfParagraphFragment::NonTextObject(object.object_handle()));
}
}
lines.push(PdfLine::new(
current_line_alignment,
current_line_bottom,
current_line_left,
current_line_right - current_line_left,
current_line_fragments,
));
let mut paragraphs = Vec::new();
let mut current_paragraph_fragments = Vec::new();
let mut current_paragraph_bottom = None;
let mut current_paragraph_left = None;
let mut current_paragraph_right = None;
let mut last_line_alignment = lines
.first()
.map(|line| line.alignment)
.unwrap_or(PdfLineAlignment::None);
let mut first_line_alignment = last_line_alignment;
for mut line in lines.drain(..) {
if line.alignment != last_line_alignment {
if !current_paragraph_fragments.is_empty() {
paragraphs.push(Self::paragraph_from_lines(
current_paragraph_fragments,
current_paragraph_bottom,
current_paragraph_left,
current_paragraph_right,
first_line_alignment,
last_line_alignment,
));
current_paragraph_fragments = Vec::new();
current_paragraph_bottom = None;
current_paragraph_left = None;
current_paragraph_right = None;
first_line_alignment = last_line_alignment
}
}
current_paragraph_fragments.append(&mut line.fragments);
last_line_alignment = line.alignment;
update_min(&mut current_paragraph_left, line.left);
update_max(&mut current_paragraph_right, line.left + line.width);
update_min(&mut current_paragraph_bottom, line.bottom);
}
paragraphs.push(Self::paragraph_from_lines(
current_paragraph_fragments,
current_paragraph_bottom,
current_paragraph_left,
current_paragraph_right,
first_line_alignment,
last_line_alignment,
));
paragraphs
}
fn paragraph_from_lines(
fragments: Vec<PdfParagraphFragment<'a>>,
bottom: Option<PdfPoints>,
left: Option<PdfPoints>,
right: Option<PdfPoints>,
first_line_alignment: PdfLineAlignment,
last_line_alignment: PdfLineAlignment,
) -> PdfParagraph<'a> {
PdfParagraph {
fragments,
bottom,
left,
max_width: match (left, right) {
(Some(left), Some(right)) => Some(right - left),
_ => None,
},
alignment: if first_line_alignment == last_line_alignment
&& first_line_alignment == PdfLineAlignment::Justify
{
PdfParagraphAlignment::ForceJustify
} else {
match first_line_alignment {
PdfLineAlignment::None | PdfLineAlignment::LeftAlign => PdfParagraphAlignment::LeftAlign,
PdfLineAlignment::RightAlign => PdfParagraphAlignment::RightAlign,
PdfLineAlignment::Center => PdfParagraphAlignment::Center,
PdfLineAlignment::Justify => PdfParagraphAlignment::Justify,
}
},
}
}
fn guess_line_alignment(
previous_line_left: Option<PdfPoints>,
previous_line_right: Option<PdfPoints>,
line_left: PdfPoints,
line_right: PdfPoints,
paragraph_left: PdfPoints,
paragraph_right: PdfPoints,
) -> PdfLineAlignment {
const ALIGNMENT_THRESHOLD: f32 = 2.0;
if let (Some(previous_line_left), Some(previous_line_right)) = (previous_line_left, previous_line_right) {
let is_aligned_left = (previous_line_left.value - line_left.value).abs() < ALIGNMENT_THRESHOLD;
let is_aligned_right = (previous_line_right.value - line_right.value).abs() < ALIGNMENT_THRESHOLD;
match (is_aligned_left, is_aligned_right) {
(true, true) => PdfLineAlignment::Justify,
(true, false) => PdfLineAlignment::LeftAlign,
(false, true) => PdfLineAlignment::RightAlign,
(false, false) => PdfLineAlignment::Center,
}
} else {
let is_aligned_left = (paragraph_left.value - line_left.value).abs() < ALIGNMENT_THRESHOLD;
let is_aligned_right = (paragraph_right.value - line_right.value).abs() < ALIGNMENT_THRESHOLD;
match (is_aligned_left, is_aligned_right) {
(true, true) => PdfLineAlignment::Justify,
(true, false) => PdfLineAlignment::LeftAlign,
(false, true) => PdfLineAlignment::RightAlign,
(false, false) => PdfLineAlignment::Center,
}
}
}
#[inline]
pub fn empty(maximum_width: PdfPoints, alignment: PdfParagraphAlignment) -> Self {
PdfParagraph {
fragments: vec![],
bottom: None,
left: None,
max_width: Some(maximum_width),
alignment,
}
}
#[inline]
pub fn is_empty(&self) -> bool {
self.fragments.is_empty()
}
#[inline]
pub fn fragments(&self) -> &[PdfParagraphFragment<'a>] {
&self.fragments
}
#[inline]
pub fn bottom(&self) -> Option<PdfPoints> {
self.bottom
}
#[inline]
pub fn left(&self) -> Option<PdfPoints> {
self.left
}
#[inline]
pub fn alignment(&self) -> PdfParagraphAlignment {
self.alignment
}
#[inline]
pub fn push(&mut self, string: PdfStyledString<'a>) {
if let Some(PdfParagraphFragment::StyledString(last_string)) = self.fragments.last_mut() {
if last_string.does_match_string_styling(&string) {
last_string.push(string.text(), " ");
} else {
self.fragments.push(PdfParagraphFragment::StyledString(string));
}
} else {
self.fragments.push(PdfParagraphFragment::StyledString(string));
}
}
#[inline]
pub fn maximum_width(&self) -> PdfPoints {
self.max_width.unwrap_or(PdfPoints::ZERO)
}
#[inline]
pub fn set_maximum_width(&mut self, width: PdfPoints) {
self.max_width = Some(width);
}
#[inline]
pub fn text(&self) -> String {
self.fragments
.iter()
.filter_map(|fragment| match fragment {
PdfParagraphFragment::StyledString(string) => Some(string.text.as_str()),
PdfParagraphFragment::LineBreak { .. } => Some("\n"),
_ => None,
})
.collect::<Vec<_>>()
.join("")
}
pub fn text_separated(&self, separator: &str) -> String {
self.fragments
.iter()
.filter_map(|fragment| match fragment {
PdfParagraphFragment::StyledString(string) => Some(string.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join(separator)
}
pub fn into_lines(self) -> Vec<PdfLine<'a>> {
let mut lines: Vec<PdfLine<'a>> = Vec::new();
let mut current_fragments: Vec<PdfParagraphFragment<'a>> = Vec::new();
let mut current_width = PdfPoints::ZERO;
let mut current_bottom = self.bottom.unwrap_or(PdfPoints::ZERO);
let mut current_left = self.left.unwrap_or(PdfPoints::ZERO);
let effective_max_width = self.max_width.unwrap_or(PdfPoints::new(f32::MAX));
for fragment in self.fragments {
match fragment {
PdfParagraphFragment::LineBreak {
alignment,
bottom: line_bottom,
left: line_left,
} => {
if !current_fragments.is_empty() {
lines.push(PdfLine::new(
alignment,
current_bottom,
current_left,
current_width,
std::mem::take(&mut current_fragments),
));
current_width = PdfPoints::ZERO;
current_bottom = line_bottom;
current_left = line_left;
}
}
PdfParagraphFragment::StyledString(ref styled) => {
let estimated_width = PdfPoints::new(styled.text().len() as f32 * styled.font_size().value * 0.5);
if current_width.value + estimated_width.value > effective_max_width.value
&& !current_fragments.is_empty()
{
lines.push(PdfLine::new(
PdfLineAlignment::None,
current_bottom,
current_left,
current_width,
std::mem::take(&mut current_fragments),
));
current_width = PdfPoints::ZERO;
}
current_width = PdfPoints::new(current_width.value + estimated_width.value);
current_fragments.push(fragment);
}
PdfParagraphFragment::NonTextObject(_) => {
current_fragments.push(fragment);
}
}
}
if !current_fragments.is_empty() {
lines.push(PdfLine::new(
PdfLineAlignment::None,
current_bottom,
current_left,
current_width,
current_fragments,
));
}
lines
}
}
fn is_significantly_rotated(object: &PdfPageObject) -> bool {
const ROTATION_THRESHOLD_DEGREES: f32 = 10.0;
let rotation = object.get_rotation_counter_clockwise_degrees().abs();
let normalized = if rotation > 180.0 { 360.0 - rotation } else { rotation };
normalized > ROTATION_THRESHOLD_DEGREES
}
#[cfg(test)]
mod tests {
use crate::pdf::document::page::paragraph::PdfParagraph;
use crate::prelude::*;
use crate::utils::test::{test_bind_to_pdfium, test_fixture_path};
#[test]
fn test_paragraph_construction() -> Result<(), PdfiumError> {
let pdfium = test_bind_to_pdfium();
let document = pdfium.load_pdf_from_file(&test_fixture_path("text-test.pdf"), None)?;
let page = document.pages().get(0)?;
let objects = page.objects().iter().collect::<Vec<_>>();
let paragraphs = PdfParagraph::from_objects(objects.as_slice());
assert!(
!paragraphs.is_empty(),
"Expected at least one paragraph from page objects"
);
for paragraph in paragraphs.iter() {
let text = paragraph.text();
assert!(
!text.trim().is_empty() || paragraph.is_empty(),
"Non-empty paragraph should produce non-empty text"
);
}
for paragraph in paragraphs.iter() {
let separated = paragraph.text_separated(" ");
let plain = paragraph.text();
if !paragraph.is_empty() {
assert!(
!separated.is_empty() || !plain.is_empty(),
"Text extraction should return content for non-empty paragraphs"
);
}
}
Ok(())
}
}