use std::collections::HashSet;
use kurbo::Rect;
use pdfrum_doc::structure::{Kid, StructElement, StructTree};
use pdfrum_object::Resolve;
use crate::ast::{Block, ListMarker};
use crate::heuristics::{join, normalize, strip_bullet};
use crate::lines::{DrawnImage, McidText, Run};
#[derive(Debug, Clone, Copy)]
pub struct Drawn<'a> {
pub text: &'a McidText,
pub images: &'a [DrawnImage],
}
pub fn blocks<R: Resolve>(
tree: &StructTree,
drawn: Drawn<'_>,
r: &R,
) -> (Vec<Block>, HashSet<i64>) {
let mut out = Vec::new();
for (index, element) in tree.elements.iter().enumerate() {
if element.parent.is_none() {
visit(tree, index, 1, drawn, r, &mut out);
}
}
out.retain(|b| !b.text().trim().is_empty() || matches!(b, Block::Image { .. }));
(out, claimed(tree))
}
fn claimed(tree: &StructTree) -> HashSet<i64> {
tree.elements
.iter()
.flat_map(|e| e.kids.iter())
.filter_map(|kid| match kid {
Kid::PageContent { content_id } | Kid::StreamContent { content_id, .. } => {
Some(*content_id)
}
_ => None,
})
.collect()
}
fn visit<R: Resolve>(
tree: &StructTree,
index: usize,
depth: u8,
drawn: Drawn<'_>,
r: &R,
out: &mut Vec<Block>,
) {
let Some(element) = tree.elements.get(index) else {
return;
};
let text_by_mcid = drawn.text;
let kind = String::from_utf8_lossy(&element.kind).to_ascii_uppercase();
match kind.as_str() {
"H1" | "H2" | "H3" | "H4" | "H5" | "H6" => {
let level = kind.as_bytes().get(1).map_or(1, |d| d - b'0');
out.push(Block::Heading {
level: level.clamp(1, 6),
text: text_of(tree, element, text_by_mcid, r),
});
}
"H" | "TITLE" => out.push(Block::Heading {
level: depth.clamp(1, 6),
text: text_of(tree, element, text_by_mcid, r),
}),
"P" | "PARA" | "BLOCKQUOTE" | "CAPTION" | "NOTE" | "INDEX" | "TOCI" => {
out.push(Block::Paragraph(text_of(tree, element, text_by_mcid, r)));
figures_within(tree, element, drawn.images, r, out);
}
"CODE" => out.push(Block::Code(text_of(tree, element, text_by_mcid, r))),
"L" => {
let mut items = Vec::new();
let mut ordered = false;
for kid in &element.kids {
if let Kid::Element {
linked: Some(li), ..
} = kid
&& let Some(item) = tree.elements.get(*li)
{
let label = child_of_kind(tree, item, b"LBL")
.map(|l| text_of(tree, l, text_by_mcid, r))
.unwrap_or_default();
ordered |= label.chars().next().is_some_and(|c| c.is_ascii_digit());
let body = child_of_kind(tree, item, b"LBODY").map_or_else(
|| text_of(tree, item, text_by_mcid, r),
|b| text_of(tree, b, text_by_mcid, r),
);
let body = strip_bullet(&body).to_owned();
if !body.trim().is_empty() {
items.push(body);
}
}
}
if !items.is_empty() {
let marker = if ordered {
ListMarker::Ordered
} else {
ListMarker::Bullet
};
out.push(Block::List { marker, items });
}
}
"TABLE" => {
let mut rows = Vec::new();
collect_rows(tree, element, text_by_mcid, r, &mut rows);
if !rows.is_empty() {
out.push(Block::Table(rows));
}
}
"FIGURE" | "FORMULA" => out.push(figure(tree, element, drawn.images, r)),
_ => {
let mut direct: Vec<Piece> = Vec::new();
for kid in &element.kids {
match kid {
Kid::Element {
linked: Some(i), ..
} => {
flush_direct(&mut direct, out);
visit(tree, *i, depth.saturating_add(1), drawn, r, out);
}
Kid::PageContent { content_id } | Kid::StreamContent { content_id, .. } => {
direct.extend(text_by_mcid.runs(*content_id).iter().map(Piece::drawn));
}
_ => {}
}
}
flush_direct(&mut direct, out);
}
}
}
fn flush_direct(direct: &mut Vec<Piece>, out: &mut Vec<Block>) {
let text = normalize(concat(direct).text.trim());
direct.clear();
if !text.is_empty() {
out.push(Block::Paragraph(text));
}
}
fn figure<R: Resolve>(
tree: &StructTree,
element: &StructElement,
images: &[DrawnImage],
r: &R,
) -> Block {
let alt = element.alt_text(r);
let alt = if alt.is_empty() {
element.actual_text(r)
} else {
alt
};
let mut ids = HashSet::new();
content_ids(tree, element, &mut ids);
let index = images
.iter()
.find(|image| image.mcid.is_some_and(|id| ids.contains(&id)))
.map(|image| image.index);
Block::Image {
alt: normalize(alt.trim()),
index,
}
}
fn content_ids(tree: &StructTree, element: &StructElement, out: &mut HashSet<i64>) {
for kid in &element.kids {
match kid {
Kid::PageContent { content_id } | Kid::StreamContent { content_id, .. } => {
out.insert(*content_id);
}
Kid::Element {
linked: Some(i), ..
} => {
if let Some(child) = tree.elements.get(*i) {
content_ids(tree, child, out);
}
}
_ => {}
}
}
}
fn figures_within<R: Resolve>(
tree: &StructTree,
element: &StructElement,
images: &[DrawnImage],
r: &R,
out: &mut Vec<Block>,
) {
for kid in &element.kids {
let Kid::Element {
linked: Some(i), ..
} = kid
else {
continue;
};
let Some(child) = tree.elements.get(*i) else {
continue;
};
if child.kind.eq_ignore_ascii_case(b"FIGURE") || child.kind.eq_ignore_ascii_case(b"FORMULA")
{
out.push(figure(tree, child, images, r));
} else {
figures_within(tree, child, images, r, out);
}
}
}
fn child_of_kind<'t>(
tree: &'t StructTree,
parent: &StructElement,
kind: &[u8],
) -> Option<&'t StructElement> {
parent.kids.iter().find_map(|kid| match kid {
Kid::Element {
linked: Some(i), ..
} => tree
.elements
.get(*i)
.filter(|e| e.kind.eq_ignore_ascii_case(kind)),
_ => None,
})
}
fn collect_rows<R: Resolve>(
tree: &StructTree,
element: &StructElement,
text_by_mcid: &McidText,
r: &R,
rows: &mut Vec<Vec<String>>,
) {
for kid in &element.kids {
let Kid::Element {
linked: Some(i), ..
} = kid
else {
continue;
};
let Some(child) = tree.elements.get(*i) else {
continue;
};
if child.kind.eq_ignore_ascii_case(b"TR") {
let cells: Vec<String> = child
.kids
.iter()
.filter_map(|k| match k {
Kid::Element {
linked: Some(c), ..
} => tree.elements.get(*c),
_ => None,
})
.filter(|c| {
c.kind.eq_ignore_ascii_case(b"TD") || c.kind.eq_ignore_ascii_case(b"TH")
})
.map(|c| text_of(tree, c, text_by_mcid, r))
.collect();
if !cells.is_empty() {
rows.push(cells);
}
} else {
collect_rows(tree, child, text_by_mcid, r, rows);
}
}
}
#[derive(Debug, Clone, Default)]
struct Piece {
text: String,
lines: Option<(usize, usize)>,
bbox: Option<Rect>,
}
impl Piece {
fn drawn(run: &Run) -> Self {
Self {
text: run.text.clone(),
lines: Some((run.line, run.line)),
bbox: (run.bbox.area() > 0.0).then_some(run.bbox),
}
}
fn repeats(&self, previous: &Self) -> bool {
let said = self.text.trim();
!said.is_empty()
&& said == previous.text.trim()
&& match (self.bbox, previous.bbox) {
(Some(a), Some(b)) => overlaps_by_half(a, b),
_ => true,
}
}
}
fn overlaps_by_half(a: Rect, b: Rect) -> bool {
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);
overlap_x >= a.width().min(b.width()) * 0.5 && overlap_y >= a.height().min(b.height()) * 0.5
}
fn concat(pieces: &[Piece]) -> Piece {
let mut out = Piece::default();
let mut last_said: Option<&Piece> = None;
for piece in pieces {
if piece.text.trim().is_empty() {
out.text.push_str(&piece.text);
continue;
}
if last_said.is_some_and(|previous| piece.repeats(previous)) {
continue;
}
let line_break = match (out.lines, piece.lines) {
(Some((_, last)), Some((first, _))) => last != first,
_ => false,
};
if line_break {
join(&mut out.text, &piece.text);
} else {
out.text.push_str(&piece.text);
}
out.lines = match (out.lines, piece.lines) {
(Some((first, _)), Some((_, last))) => Some((first, last)),
(None, lines) | (lines, None) => lines,
};
out.bbox = match (out.bbox, piece.bbox) {
(Some(a), Some(b)) => Some(a.union(b)),
(None, b) | (b, None) => b,
};
last_said = Some(piece);
}
out
}
fn piece_of<R: Resolve>(
tree: &StructTree,
element: &StructElement,
text_by_mcid: &McidText,
r: &R,
) -> Piece {
let mut pieces: Vec<Piece> = Vec::new();
for kid in &element.kids {
match kid {
Kid::Element {
linked: Some(i), ..
} => {
if let Some(child) = tree.elements.get(*i) {
pieces.push(piece_of(tree, child, text_by_mcid, r));
}
}
Kid::PageContent { content_id } | Kid::StreamContent { content_id, .. } => {
pieces.extend(text_by_mcid.runs(*content_id).iter().map(Piece::drawn));
}
_ => {}
}
}
let drawn = concat(&pieces);
let actual = element.actual_text(r);
if actual.trim().is_empty() {
return drawn;
}
let leading = drawn.text.len() - drawn.text.trim_start().len();
let trailing = drawn.text.len() - drawn.text.trim_end().len();
let mut text = String::with_capacity(actual.len() + leading + trailing);
text.push_str(drawn.text.get(..leading).unwrap_or_default());
text.push_str(actual.trim());
text.push_str(
drawn
.text
.get(drawn.text.len() - trailing..)
.unwrap_or_default(),
);
Piece { text, ..drawn }
}
fn text_of<R: Resolve>(
tree: &StructTree,
element: &StructElement,
text_by_mcid: &McidText,
r: &R,
) -> String {
normalize(piece_of(tree, element, text_by_mcid, r).text.trim())
}
#[cfg(test)]
mod tests {
use super::{Piece, concat};
use kurbo::Rect;
fn on_line(text: &str, line: usize, x: f64) -> Piece {
Piece {
text: text.to_owned(),
lines: Some((line, line)),
bbox: Some(Rect::new(
x,
700.0,
x + 6.0 * f64::from(u16::try_from(text.len()).unwrap_or(u16::MAX)),
710.0,
)),
}
}
fn said(text: &str) -> Piece {
Piece {
text: text.to_owned(),
lines: None,
bbox: None,
}
}
#[test]
fn pieces_on_one_line_keep_the_text_page_spacing() {
let got = concat(&[
on_line("Local d", 0, 73.0),
on_line("ocuments view: list ", 0, 104.0),
on_line("all", 0, 226.0),
]);
assert_eq!(got.text, "Local documents view: list all");
assert_eq!(got.lines, Some((0, 0)));
}
#[test]
fn pieces_on_different_lines_meet_across_the_break() {
let got = concat(&[
on_line("a wrapped para-", 0, 73.0),
on_line("graph of two ", 1, 73.0),
on_line("lines", 1, 160.0),
]);
assert_eq!(got.text, "a wrapped paragraph of two lines");
assert_eq!(got.lines, Some((0, 1)));
}
#[test]
fn a_span_that_repeats_its_neighbour_is_kept_once() {
let got = concat(&[
said("Welcome to Foxit MobilePDF"),
on_line("Welcome to Foxit MobilePDF ", 0, 168.0),
]);
assert_eq!(got.text, "Welcome to Foxit MobilePDF");
let got = concat(&[
on_line("Instructions", 0, 55.0),
on_line("Instructions", 0, 55.4),
on_line(":", 0, 130.0),
]);
assert_eq!(got.text, "Instructions:");
let got = concat(&[on_line("no ", 0, 55.0), on_line("no", 0, 80.0)]);
assert_eq!(got.text, "no no");
}
}