use std::collections::HashMap;
use kurbo::Rect;
use pdfrum_page::TextRenderMode;
use pdfrum_page::{Page, PageObject};
use pdfrum_text::{CharBox, CharIndex, CharType, TextPage};
#[derive(Debug, Clone, PartialEq)]
pub struct Line {
pub text: String,
pub bbox: Rect,
pub font_size: f32,
pub bold: bool,
pub bold_prefix: usize,
pub mono: bool,
pub mcids: Vec<i64>,
pub segments: Vec<Segment>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Segment {
pub mcid: Option<i64>,
pub text: String,
pub bbox: Rect,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DrawnImage {
pub index: usize,
pub mcid: Option<i64>,
pub bbox: Rect,
}
#[derive(Debug, Clone, Default)]
pub struct ObjectFacts {
by_index: HashMap<u32, Facts>,
images: Vec<DrawnImage>,
}
#[derive(Debug, Clone, Copy, Default)]
struct Facts {
bold: bool,
mono: bool,
mcid: Option<i64>,
}
impl ObjectFacts {
#[must_use]
pub fn from_page(page: &Page) -> Self {
let mut facts = Self::default();
let mut next = 0u32;
collect(&page.objects, &mut next, &mut facts);
facts
}
#[must_use]
pub fn images(&self) -> &[DrawnImage] {
&self.images
}
fn get(&self, index: u32) -> Facts {
self.by_index.get(&index).copied().unwrap_or_default()
}
}
fn collect(objects: &[PageObject], next: &mut u32, out: &mut ObjectFacts) {
for object in objects {
match object {
PageObject::Text(content) => {
let name = content
.object
.font
.as_ref()
.map(|(font, _)| {
String::from_utf8_lossy(font.base_font_name()).to_ascii_lowercase()
})
.unwrap_or_default();
let faux_bold = matches!(
content.object.render_mode,
TextRenderMode::FillStroke | TextRenderMode::FillStrokeClip
);
out.by_index.insert(
*next,
Facts {
bold: faux_bold || name_says_bold(&name),
mono: name_says_mono(&name),
mcid: content.marks.content_id(),
},
);
*next += 1;
}
PageObject::Form(content) => {
*next += 1;
collect(&content.object.objects, next, out);
}
PageObject::Image(content) => {
out.images.push(DrawnImage {
index: out.images.len(),
mcid: content.marks.content_id(),
bbox: content
.object
.matrix
.transform_rect_bbox(Rect::new(0.0, 0.0, 1.0, 1.0)),
});
*next += 1;
}
PageObject::Path(_) | PageObject::Shading(_) => {
*next += 1;
}
}
}
}
fn name_says_bold(lower: &str) -> bool {
[
"bold",
"black",
"heavy",
"semibold",
"demibold",
"extrabold",
"ultrabold",
]
.iter()
.any(|w| lower.contains(w))
}
fn name_says_mono(lower: &str) -> bool {
[
"courier",
"mono",
"consolas",
"menlo",
"monaco",
"inconsolata",
"sourcecodepro",
"firacode",
]
.iter()
.any(|w| lower.contains(w))
}
#[must_use]
pub fn lines(text: &TextPage, facts: &ObjectFacts) -> Vec<Line> {
let mut lines = Vec::new();
let mut current = LineBuilder::default();
for i in 0..text.char_count() {
let Ok(c) = text.char(CharIndex::from(i)) else {
continue;
};
let unicode = char::from_u32(c.unicode).unwrap_or('\u{fffd}');
if c.char_type == CharType::Generated {
if unicode == '\n' || unicode == '\r' {
lines.extend(current.finish());
current = LineBuilder::default();
} else if unicode == ' ' {
current.push_space();
}
continue;
}
if current.already_drawn(unicode, c.char_box) {
continue;
}
let object = c.object.map(|o| o.0);
current.push(
unicode,
c.char_box,
drawn_size(c),
object.map(|o| facts.get(o)),
);
}
lines.extend(current.finish());
lines
}
fn drawn_size(c: &CharBox) -> f32 {
let [_, _, skew, scale_y, _, _] = c.matrix.as_coeffs();
let scale = skew.hypot(scale_y);
if scale > 0.0 {
#[expect(
clippy::cast_possible_truncation,
reason = "a point size; f32 is what the text page carries"
)]
let size = (f64::from(c.font_size) * scale) as f32;
size
} else {
c.font_size
}
}
fn same_place(a: Rect, b: Rect) -> bool {
let within_a_point = (a.x0 - b.x0).abs() < 1.0 && (a.y0 - b.y0).abs() < 1.0;
let overlap_x = a.x1.min(b.x1) - a.x0.max(b.x0);
let overlap_y = a.y1.min(b.y1) - a.y0.max(b.y0);
let width = a.width().min(b.width());
let height = a.height().min(b.height());
let x_ok = if width > 0.0 {
overlap_x >= width * 0.5
} else {
(a.x0 - b.x0).abs() < 1.0
};
let y_ok = if height > 0.0 {
overlap_y >= height * 0.5
} else {
(a.y0 - b.y0).abs() < 1.0
};
within_a_point || (x_ok && y_ok)
}
#[derive(Default)]
struct LineBuilder {
text: String,
bbox: Option<Rect>,
sizes: Vec<f32>,
bold_votes: usize,
mono_votes: usize,
voters: usize,
bold_prefix: usize,
head_over: bool,
mcids: Vec<i64>,
segments: Vec<Segment>,
drawn: Vec<(char, Rect)>,
pending_space: bool,
}
impl LineBuilder {
fn push_space(&mut self) {
if !self.text.is_empty() {
self.pending_space = true;
}
}
fn already_drawn(&self, ch: char, char_box: Rect) -> bool {
!ch.is_whitespace()
&& self
.drawn
.iter()
.any(|(other, b)| *other == ch && same_place(*b, char_box))
}
fn push(&mut self, ch: char, char_box: Rect, font_size: f32, facts: Option<Facts>) {
if self.pending_space {
self.text.push(' ');
if let Some(segment) = self.segments.last_mut() {
segment.text.push(' ');
}
self.pending_space = false;
}
self.text.push(ch);
self.drawn.push((ch, char_box));
let has_area = char_box.width() > 0.0 && char_box.height() > 0.0;
let mcid = facts.and_then(|f| f.mcid);
match self.segments.last_mut() {
Some(segment) if segment.mcid == mcid => {
segment.text.push(ch);
if has_area {
segment.bbox = if segment.bbox.area() > 0.0 {
segment.bbox.union(char_box)
} else {
char_box
};
}
}
_ => self.segments.push(Segment {
mcid,
text: ch.to_string(),
bbox: if has_area { char_box } else { Rect::ZERO },
}),
}
if char_box.width() > 0.0 || char_box.height() > 0.0 {
self.bbox = Some(self.bbox.map_or(char_box, |b| b.union(char_box)));
}
if font_size > 0.0 {
self.sizes.push(font_size);
}
if !self.head_over {
if facts.is_some_and(|f| f.bold) {
self.bold_prefix = self.text.len();
} else {
self.head_over = true;
}
}
if let Some(f) = facts {
self.voters += 1;
self.bold_votes += usize::from(f.bold);
self.mono_votes += usize::from(f.mono);
if let Some(mcid) = f.mcid
&& self.mcids.last() != Some(&mcid)
{
self.mcids.push(mcid);
}
}
}
fn finish(mut self) -> Option<Line> {
let text = std::mem::take(&mut self.text);
let text = text.trim_end().to_owned();
if text.trim().is_empty() {
return None;
}
self.sizes.sort_by(f32::total_cmp);
let font_size = self.sizes.get(self.sizes.len() / 2).copied().unwrap_or(0.0);
Some(Line {
text,
bbox: self.bbox.unwrap_or(Rect::ZERO),
font_size,
bold: self.voters > 0 && self.bold_votes * 2 > self.voters,
bold_prefix: self.bold_prefix,
mono: self.voters > 0 && self.mono_votes * 2 > self.voters,
mcids: self.mcids,
segments: self.segments,
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Run {
pub line: usize,
pub text: String,
pub bbox: Rect,
}
#[derive(Debug, Clone, Default)]
pub struct McidText(HashMap<i64, Vec<Run>>);
impl McidText {
#[must_use]
pub fn runs(&self, id: i64) -> &[Run] {
self.0.get(&id).map_or(&[], Vec::as_slice)
}
}
#[must_use]
pub fn text_by_mcid(lines: &[Line]) -> McidText {
let mut out: HashMap<i64, Vec<Run>> = HashMap::new();
for (index, line) in lines.iter().enumerate() {
for segment in &line.segments {
let Some(mcid) = segment.mcid else {
continue;
};
if segment.text.is_empty() {
continue;
}
out.entry(mcid).or_default().push(Run {
line: index,
text: segment.text.clone(),
bbox: segment.bbox,
});
}
}
McidText(out)
}
#[cfg(test)]
mod tests {
use super::{Facts, ObjectFacts, lines};
use kurbo::{Affine, Point, Rect};
use pdfrum_text::{CharBox, CharType, ObjectIndex, TextPage};
fn drawn(ch: char, x: f64, y: f64) -> CharBox {
let char_box = Rect::new(x, y, x + 5.0, y + 7.0);
CharBox {
char_type: CharType::Normal,
unicode: u32::from(ch),
code: None,
origin: Point::new(x, y),
char_box,
loose_char_box: char_box,
matrix: Affine::IDENTITY,
object: None,
font_size: 10.0,
angle: 0.0,
}
}
fn generated(ch: char) -> CharBox {
CharBox {
char_type: CharType::Generated,
..drawn(ch, 0.0, 0.0)
}
}
fn word(text: &str, x: f64, y: f64) -> Vec<CharBox> {
text.chars()
.enumerate()
.map(|(i, ch)| drawn(ch, x + 6.0 * f64::from(u8::try_from(i).unwrap()), y))
.collect()
}
#[test]
fn a_run_drawn_again_a_fraction_of_a_point_away_is_kept_once() {
let mut chars = word("Welcome", 100.0, 700.0);
chars.extend(word("Welcome", 100.4, 700.3));
chars.push(generated('\r'));
chars.push(generated('\n'));
chars.extend(word("Welcome", 100.0, 680.0));
let page = TextPage {
chars,
..TextPage::default()
};
let got = lines(&page, &ObjectFacts::default());
let texts: Vec<&str> = got.iter().map(|l| l.text.as_str()).collect();
assert_eq!(texts, ["Welcome", "Welcome"]);
}
#[test]
fn a_bold_lead_in_is_measured_to_its_last_bold_character() {
let mut chars: Vec<CharBox> = word("Redaction", 100.0, 700.0)
.into_iter()
.map(|c| CharBox {
object: Some(ObjectIndex(0)),
..c
})
.collect();
chars.push(generated(' '));
chars.extend(
word("- Lets you", 160.0, 700.0)
.into_iter()
.map(|c| CharBox {
object: Some(ObjectIndex(1)),
..c
}),
);
let mut facts = ObjectFacts::default();
facts.by_index.insert(
0,
Facts {
bold: true,
..Facts::default()
},
);
facts.by_index.insert(1, Facts::default());
let page = TextPage {
chars,
..TextPage::default()
};
let got = lines(&page, &facts);
assert_eq!(got[0].text, "Redaction - Lets you");
assert_eq!(got[0].bold_prefix, "Redaction".len());
assert!(!got[0].bold);
let plain = lines(&page, &ObjectFacts::default());
assert_eq!(plain[0].bold_prefix, 0);
}
#[test]
fn a_leader_of_dots_is_not_a_repeat() {
let page = TextPage {
chars: word(".....", 100.0, 700.0),
..TextPage::default()
};
let got = lines(&page, &ObjectFacts::default());
assert_eq!(got[0].text, ".....");
}
#[test]
fn a_generated_space_is_kept_where_the_text_page_put_it() {
let mut chars = word("Wi-Fi", 100.0, 700.0);
chars.push(generated(' '));
chars.extend(word("file", 140.0, 700.0));
let page = TextPage {
chars,
..TextPage::default()
};
let got = lines(&page, &ObjectFacts::default());
assert_eq!(got[0].text, "Wi-Fi file");
}
}