use super::content::ContentInterpreter;
use super::flate::decompress_stream;
use super::font::PdfFont;
use super::object::{ObjectId, PdfObject};
use super::xref::XRefTable;
use crate::image::{self, ExtractedImageData};
use crate::table::{RawSpan, TableDetector};
use crate::types::*;
use std::collections::{BTreeMap, HashMap, HashSet};
#[inline]
fn get_elapsed_ms(#[allow(unused)] start: Option<std::time::Instant>) -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
start
.map(|s| s.elapsed().as_secs_f64() * 1000.0)
.unwrap_or(0.0)
}
#[cfg(target_arch = "wasm32")]
{
0.0
}
}
#[inline]
fn now_instant() -> Option<std::time::Instant> {
#[cfg(not(target_arch = "wasm32"))]
{
Some(std::time::Instant::now())
}
#[cfg(target_arch = "wasm32")]
{
None
}
}
pub const MAX_OBJECT_RESOLVE_DEPTH: usize = 64;
pub const MAX_PAGE_TREE_DEPTH: usize = 32;
pub const MAX_PAGES_LIMIT: usize = 100_000;
pub const MAX_PAGE_CONTENTS_BYTES: usize = 64 * 1024 * 1024; pub const MAX_CONTENTS_STREAMS: usize = 256;
pub const MAX_FORM_XOBJECTS_PER_PAGE: usize = 128;
pub const MAX_FONTS_PER_PAGE: usize = 1_000;
pub struct PurePdfDoc<'a> {
data: &'a [u8],
xref: XRefTable,
obj_cache: HashMap<u32, BTreeMap<u32, PdfObject>>,
pages: Vec<ObjectId>,
}
pub struct PageInfo {
pub width: f32,
pub height: f32,
pub rotation: i32,
pub fonts: HashMap<String, PdfFont>,
pub contents: Vec<u8>,
pub raw_page_obj: PdfObject,
pub media_box: Option<[f32; 4]>,
pub crop_box: Option<[f32; 4]>,
pub origin_x: f32,
pub origin_y: f32,
pub max_y: f32,
}
impl<'a> PurePdfDoc<'a> {
pub fn from_bytes(data: &'a [u8]) -> Result<Self, String> {
let xref = XRefTable::parse(data)?;
let mut doc = Self {
data,
xref,
obj_cache: HashMap::new(),
pages: Vec::new(),
};
doc.collect_pages()?;
Ok(doc)
}
pub fn page_count(&self) -> usize {
self.pages.len()
}
pub fn resolve(&mut self, obj: &PdfObject) -> Result<PdfObject, String> {
self.resolve_depth(obj, 0)
}
pub fn resolve_depth(&mut self, obj: &PdfObject, depth: usize) -> Result<PdfObject, String> {
let mut visited = HashSet::new();
self.resolve_internal(obj, depth, &mut visited)
}
fn resolve_internal(
&mut self,
obj: &PdfObject,
depth: usize,
visited: &mut HashSet<ObjectId>,
) -> Result<PdfObject, String> {
if depth >= MAX_OBJECT_RESOLVE_DEPTH {
return Err(
"Maximum object resolution depth (64) exceeded (possible cycle)".to_string(),
);
}
match obj {
PdfObject::Reference(id) => {
if !visited.insert(*id) {
return Err(format!("Cyclic object reference detected at {}", id));
}
let resolved = self.xref.get_object(self.data, *id, &mut self.obj_cache)?;
self.resolve_internal(&resolved, depth + 1, visited)
}
_ => Ok(obj.clone()),
}
}
fn collect_pages(&mut self) -> Result<(), String> {
let root_ref = self
.xref
.trailer
.get("Root")
.and_then(|r| r.as_reference())
.ok_or("Missing /Root in PDF trailer")?;
let catalog = self
.xref
.get_object(self.data, root_ref, &mut self.obj_cache)?;
let pages_ref = catalog
.dict_get("Pages")
.and_then(|p| p.as_reference())
.ok_or("Missing /Pages in PDF Catalog")?;
let mut visited = HashSet::new();
self.traverse_pages_node(pages_ref, 0, &mut visited)?;
Ok(())
}
fn traverse_pages_node(
&mut self,
node_ref: ObjectId,
depth: usize,
visited: &mut HashSet<ObjectId>,
) -> Result<(), String> {
if depth >= MAX_PAGE_TREE_DEPTH {
return Err("Maximum page tree depth (32) exceeded (possible cycle)".to_string());
}
if !visited.insert(node_ref) {
return Err(format!("Cyclic page tree node detected at {}", node_ref));
}
if self.pages.len() >= MAX_PAGES_LIMIT {
return Err(format!(
"Exceeded maximum page count limit ({})",
MAX_PAGES_LIMIT
));
}
let node = self
.xref
.get_object(self.data, node_ref, &mut self.obj_cache)?;
let node_type = node
.dict_get("Type")
.and_then(|t| t.as_name())
.unwrap_or("");
if node_type == "Pages" || (node_type.is_empty() && node.dict_get("Kids").is_some()) {
if let Some(kids_obj) = node.dict_get("Kids") {
let resolved_kids = self.resolve(kids_obj)?;
if let Some(kids_arr) = resolved_kids.as_array() {
let kid_refs: Vec<ObjectId> =
kids_arr.iter().filter_map(|k| k.as_reference()).collect();
for kid in kid_refs {
self.traverse_pages_node(kid, depth + 1, visited)?;
}
}
}
} else if node_type == "Page"
|| node.dict_get("MediaBox").is_some()
|| node.dict_get("Contents").is_some()
{
self.pages.push(node_ref);
}
Ok(())
}
pub fn load_page(&mut self, page_idx: usize) -> Result<PageInfo, String> {
if page_idx >= self.pages.len() {
return Err(format!("Page index {} out of range", page_idx));
}
let page_ref = self.pages[page_idx];
let page_obj = self
.xref
.get_object(self.data, page_ref, &mut self.obj_cache)?;
let media_box_obj = self.get_inherited_page_attribute(&page_obj, "MediaBox")?;
let crop_box_obj = self.get_inherited_page_attribute(&page_obj, "CropBox")?;
let rotate_obj = self.get_inherited_page_attribute(&page_obj, "Rotate")?;
let media_box = self.parse_box_array(media_box_obj.as_ref())?;
let crop_box = self.parse_box_array(crop_box_obj.as_ref())?;
let rotation = rotate_obj.and_then(|r| r.as_i64()).unwrap_or(0) as i32;
let effective_box = crop_box.or(media_box).unwrap_or([0.0, 0.0, 595.28, 841.89]);
let min_x = effective_box[0].min(effective_box[2]);
let max_x = effective_box[0].max(effective_box[2]);
let min_y = effective_box[1].min(effective_box[3]);
let max_y = effective_box[1].max(effective_box[3]);
let width = if (max_x - min_x).is_finite() && (max_x - min_x) > 0.0 {
max_x - min_x
} else {
595.28
};
let height = if (max_y - min_y).is_finite() && (max_y - min_y) > 0.0 {
max_y - min_y
} else {
841.89
};
let origin_x = if min_x.is_finite() { min_x } else { 0.0 };
let origin_y = if min_y.is_finite() { min_y } else { 0.0 };
let max_y = if max_y.is_finite() {
max_y
} else {
origin_y + height
};
let fonts = self.load_page_fonts(&page_obj)?;
let contents = self.load_page_contents(&page_obj)?;
Ok(PageInfo {
width,
height,
rotation,
fonts,
contents,
raw_page_obj: page_obj,
media_box,
crop_box,
origin_x,
origin_y,
max_y,
})
}
fn get_inherited_page_attribute(
&mut self,
page_obj: &PdfObject,
key: &str,
) -> Result<Option<PdfObject>, String> {
let mut curr = page_obj.clone();
let mut visited_parents = HashSet::new();
for _ in 0..MAX_PAGE_TREE_DEPTH {
if let Some(val) = curr.dict_get(key) {
return Ok(Some(self.resolve(val)?));
}
if let Some(parent_ref) = curr.dict_get("Parent") {
if let Some(id) = parent_ref.as_reference() {
if !visited_parents.insert(id) {
return Err(format!("Cyclic /Parent reference detected at {}", id));
}
}
curr = self.resolve(parent_ref)?;
} else {
break;
}
}
Ok(None)
}
fn parse_box_array(&mut self, obj: Option<&PdfObject>) -> Result<Option<[f32; 4]>, String> {
let b = match obj {
Some(o) => o,
None => return Ok(None),
};
let resolved = self.resolve(b)?;
if let Some(arr) = resolved.as_array() {
if arr.len() >= 4 {
let x0 = arr[0].as_f64().filter(|v| v.is_finite()).unwrap_or(0.0) as f32;
let y0 = arr[1].as_f64().filter(|v| v.is_finite()).unwrap_or(0.0) as f32;
let x1 = arr[2].as_f64().filter(|v| v.is_finite()).unwrap_or(595.28) as f32;
let y1 = arr[3].as_f64().filter(|v| v.is_finite()).unwrap_or(841.89) as f32;
return Ok(Some([x0, y0, x1, y1]));
}
}
Ok(None)
}
fn load_page_fonts(
&mut self,
page_obj: &PdfObject,
) -> Result<HashMap<String, PdfFont>, String> {
let mut fonts = HashMap::new();
let res_obj = match page_obj.dict_get("Resources") {
Some(r) => self.resolve(r)?,
None => return Ok(fonts),
};
self.extract_fonts_from_resources(&res_obj, &mut fonts)?;
if let Some(xobj_entry) = res_obj.dict_get("XObject") {
if let Ok(xobjs) = self.resolve(xobj_entry) {
if let Some(xdict) = xobjs.as_dict() {
for entry in xdict.values() {
if let Ok(resolved) = self.resolve(entry) {
if let Some(stream) = resolved.as_stream() {
if let Some(form_res) = stream.dict.get("Resources") {
if let Ok(r_res) = self.resolve(form_res) {
let _ =
self.extract_fonts_from_resources(&r_res, &mut fonts);
}
}
}
}
}
}
}
}
Ok(fonts)
}
fn extract_fonts_from_resources(
&mut self,
res_obj: &PdfObject,
fonts: &mut HashMap<String, PdfFont>,
) -> Result<(), String> {
let font_dict_obj = match res_obj.dict_get("Font") {
Some(f) => self.resolve(f)?,
None => return Ok(()),
};
if let Some(font_map) = font_dict_obj.as_dict() {
for (font_alias, font_entry) in font_map {
if fonts.contains_key(font_alias) {
continue;
}
if let Ok(resolved_font) = self.resolve(font_entry) {
let mut pdf_font = PdfFont::new(font_alias.clone());
if let Some(bf) = resolved_font.dict_get("BaseFont").and_then(|b| b.as_name()) {
pdf_font.set_base_font(bf);
}
if let Some(st) = resolved_font.dict_get("Subtype").and_then(|s| s.as_name()) {
pdf_font.subtype = st.to_string();
}
if let Some(fc) = resolved_font.dict_get("FirstChar").and_then(|c| c.as_i64()) {
pdf_font.first_char = fc as u32;
}
if let Some(lc) = resolved_font.dict_get("LastChar").and_then(|c| c.as_i64()) {
pdf_font.last_char = lc as u32;
}
if let Some(widths_obj) = resolved_font.dict_get("Widths") {
if let Ok(w_resolved) = self.resolve(widths_obj) {
if let Some(w_arr) = w_resolved.as_array() {
pdf_font.widths = w_arr
.iter()
.map(|v| v.as_f64().unwrap_or(1000.0) as f32)
.collect();
}
}
}
if let Some(fd_obj) = resolved_font.dict_get("FontDescriptor") {
if let Ok(fd_resolved) = self.resolve(fd_obj) {
if let Some(ascent) =
fd_resolved.dict_get("Ascent").and_then(|a| a.as_f64())
{
if ascent.abs() > 0.0 {
pdf_font.ascent = (ascent as f32 / 1000.0).abs();
}
}
if let Some(descent) =
fd_resolved.dict_get("Descent").and_then(|d| d.as_f64())
{
if descent.abs() > 0.0 {
pdf_font.descent = -(descent as f32 / 1000.0).abs();
}
}
}
}
if let Some(desc_obj) = resolved_font.dict_get("DescendantFonts") {
if let Ok(desc_resolved) = self.resolve(desc_obj) {
if let Some(desc_arr) = desc_resolved.as_array() {
if let Some(first_desc) = desc_arr.first() {
if let Ok(df_resolved) = self.resolve(first_desc) {
if let Some(dw) =
df_resolved.dict_get("DW").and_then(|d| d.as_f64())
{
pdf_font.default_width = dw as f32;
}
if let Some(w_obj) = df_resolved.dict_get("W") {
if let Ok(w_resolved) = self.resolve(w_obj) {
if let Some(w_arr) = w_resolved.as_array() {
self.parse_cid_font_widths(
&mut pdf_font,
w_arr,
);
}
}
}
}
}
}
}
}
if let Some(to_uni_obj) = resolved_font.dict_get("ToUnicode") {
if let Ok(tu_resolved) = self.resolve(to_uni_obj) {
if let Some(stream) = tu_resolved.as_stream() {
if let Ok(decompressed) =
decompress_stream(&stream.data, &stream.dict)
{
pdf_font.parse_to_unicode(&decompressed);
}
}
}
}
fonts.insert(font_alias.clone(), pdf_font);
}
}
}
Ok(())
}
fn parse_cid_font_widths(&mut self, font: &mut PdfFont, w_arr: &[PdfObject]) {
let mut i = 0;
while i < w_arr.len() {
let start_cid = match self.resolve(&w_arr[i]).ok().and_then(|o| o.as_i64()) {
Some(sc) if (0..=65535).contains(&sc) => sc as u32,
_ => {
i += 1;
continue;
}
};
i += 1;
if i >= w_arr.len() {
break;
}
let next_resolved = match self.resolve(&w_arr[i]) {
Ok(nr) => nr,
Err(_) => {
i += 1;
continue;
}
};
if let Some(arr) = next_resolved.as_array() {
let widths: Vec<f32> = arr
.iter()
.filter_map(|w| {
self.resolve(w)
.ok()
.and_then(|v| v.as_f64())
.map(|v| v as f32)
})
.collect();
font.add_cid_width_entry(start_cid, &widths);
i += 1;
} else if let Some(end_cid) = next_resolved.as_i64() {
i += 1;
if i < w_arr.len() && end_cid >= (start_cid as i64) && end_cid <= 65535 {
if let Some(width) = self.resolve(&w_arr[i]).ok().and_then(|w| w.as_f64()) {
font.add_cid_width_range(start_cid, end_cid as u32, width as f32);
}
i += 1;
}
} else {
i += 1;
}
}
}
fn load_page_contents(&mut self, page_obj: &PdfObject) -> Result<Vec<u8>, String> {
let mut full_contents = Vec::new();
let mut visited_streams = HashSet::new();
if let Some(contents_obj) = page_obj.dict_get("Contents") {
if let Ok(c) = self.resolve(contents_obj) {
match c {
PdfObject::Stream(s) => {
if let Ok(decompressed) = decompress_stream(&s.data, &s.dict) {
if decompressed.len() <= MAX_PAGE_CONTENTS_BYTES {
full_contents.extend_from_slice(&decompressed);
full_contents.push(b'\n');
} else {
full_contents
.extend_from_slice(&decompressed[..MAX_PAGE_CONTENTS_BYTES]);
full_contents.push(b'\n');
}
}
}
PdfObject::Array(arr) => {
let stream_count = arr.len().min(MAX_CONTENTS_STREAMS);
for item in &arr[..stream_count] {
if let Some(id) = item.as_reference() {
if !visited_streams.insert(id) {
continue;
}
}
if let Ok(resolved_stream) = self.resolve(item) {
if let Some(s) = resolved_stream.as_stream() {
if let Ok(decompressed) = decompress_stream(&s.data, &s.dict) {
let current_len = full_contents.len();
if current_len >= MAX_PAGE_CONTENTS_BYTES {
break;
}
let allowed = MAX_PAGE_CONTENTS_BYTES - current_len;
if decompressed.len() <= allowed {
full_contents.extend_from_slice(&decompressed);
} else {
full_contents
.extend_from_slice(&decompressed[..allowed]);
}
full_contents.push(b'\n');
}
}
}
}
}
_ => {}
}
}
}
if full_contents.len() < MAX_PAGE_CONTENTS_BYTES {
if let Some(res_obj) = page_obj.dict_get("Resources") {
if let Ok(res) = self.resolve(res_obj) {
if let Some(xobj_entry) = res.dict_get("XObject") {
if let Ok(xobjs) = self.resolve(xobj_entry) {
if let Some(xdict) = xobjs.as_dict() {
let mut names: Vec<_> = xdict.keys().cloned().collect();
names.sort();
let mut forms_processed = 0;
for name in names {
if forms_processed >= MAX_FORM_XOBJECTS_PER_PAGE
|| full_contents.len() >= MAX_PAGE_CONTENTS_BYTES
{
break;
}
let entry = &xdict[&name];
if let Some(id) = entry.as_reference() {
if !visited_streams.insert(id) {
continue;
}
}
if let Ok(resolved) = self.resolve(entry) {
if let Some(stream) = resolved.as_stream() {
let subtype = stream
.dict
.get("Subtype")
.and_then(|s| s.as_name())
.unwrap_or("");
if subtype == "Form" {
forms_processed += 1;
if let Ok(decompressed) =
decompress_stream(&stream.data, &stream.dict)
{
let current_len = full_contents.len();
let allowed = MAX_PAGE_CONTENTS_BYTES
.saturating_sub(current_len);
if allowed > 0 {
if decompressed.len() <= allowed {
full_contents
.extend_from_slice(&decompressed);
} else {
full_contents.extend_from_slice(
&decompressed[..allowed],
);
}
full_contents.push(b'\n');
}
}
}
}
}
}
}
}
}
}
}
}
Ok(full_contents)
}
pub fn parse_raw(&mut self, file_name: &str) -> Result<RawSpatialDocument, String> {
let start = now_instant();
let total_pages = self.page_count();
let mut all_spans = Vec::new();
for p in 0..total_pages {
let page_info = self.load_page(p)?;
let mut interp = ContentInterpreter::with_viewport(
p + 1,
page_info.origin_x,
page_info.max_y,
page_info.height,
&page_info.fonts,
);
interp.interpret(&page_info.contents)?;
all_spans.extend(interp.spans);
}
let elapsed = get_elapsed_ms(start);
let total_spans = all_spans.len();
Ok(RawSpatialDocument {
file_name: file_name.to_string(),
total_pages,
total_spans,
parse_time_ms: (elapsed * 100.0).round() / 100.0,
spans: all_spans,
})
}
pub fn extract_all_images(&mut self) -> Result<Vec<ExtractedImageData>, String> {
let mut out = Vec::new();
let total_pages = self.page_count();
for p in 0..total_pages {
let page_info = self.load_page(p)?;
let mut interp = ContentInterpreter::with_viewport(
p + 1,
page_info.origin_x,
page_info.max_y,
page_info.height,
&page_info.fonts,
);
let _ = interp.interpret(&page_info.contents);
let res_obj = match page_info.raw_page_obj.dict_get("Resources") {
Some(r) => self.resolve(r)?,
None => continue,
};
let xobj_dict = match res_obj.dict_get("XObject") {
Some(x) => self.resolve(x)?,
None => continue,
};
if let Some(xobjs) = xobj_dict.as_dict() {
let mut names: Vec<_> = xobjs.keys().cloned().collect();
names.sort();
for (idx, name) in names.into_iter().enumerate() {
let entry = &xobjs[&name];
let xref = entry.as_reference().map(|r| r.number as i32).unwrap_or(0);
let resolved = self.resolve(entry)?;
if let Some(stream) = resolved.as_stream() {
let subtype = stream
.dict
.get("Subtype")
.and_then(|s| s.as_name())
.unwrap_or("");
if subtype == "Image" {
let width = stream
.dict
.get("Width")
.and_then(|w| w.as_i64())
.unwrap_or(0) as u32;
let height = stream
.dict
.get("Height")
.and_then(|h| h.as_i64())
.unwrap_or(0) as u32;
let bpc = stream
.dict
.get("BitsPerComponent")
.and_then(|b| b.as_i64())
.map(|b| b as i32);
let cs = stream
.dict
.get("ColorSpace")
.and_then(|c| c.as_name())
.map(|s| s.to_string());
let filter = stream.dict.get("Filter").and_then(|f| {
f.as_name().map(|s| s.to_string()).or_else(|| {
f.as_array().and_then(|a| {
a.last().and_then(|e| e.as_name().map(|s| s.to_string()))
})
})
});
let bbox = interp.image_placements.get(&name).cloned();
let raw_stream_bytes = stream.data.clone();
let has_bytes = !raw_stream_bytes.is_empty();
let byte_len = if has_bytes {
Some(raw_stream_bytes.len())
} else {
None
};
let sha256 = if has_bytes {
Some(image::sha256_hex(&raw_stream_bytes))
} else {
None
};
let mime_type =
image::mime_from_filter(filter.as_deref()).map(str::to_string);
let spatial_img = SpatialImage {
image_id: idx + 1,
name: name.clone(),
xref,
bbox,
pixel_width: width,
pixel_height: height,
bits_per_component: bpc,
color_space: cs,
filter: image::last_filter_name(filter.as_deref()),
mime_type,
byte_length: byte_len,
sha256,
file: None,
};
out.push(ExtractedImageData {
page: p + 1,
image: spatial_img,
bytes: raw_stream_bytes,
});
}
}
}
}
}
Ok(out)
}
pub fn parse_structured(
&mut self,
file_name: &str,
options: &ParserOptions,
) -> Result<SpatialDocument, String> {
let start = now_instant();
let total_pages = self.page_count();
let detector = TableDetector::new(
options.min_table_columns,
options.column_tolerance,
options.row_tolerance,
);
let mut pages = Vec::with_capacity(total_pages.min(MAX_PAGES_LIMIT));
for p in 0..total_pages {
let page_info = self.load_page(p)?;
let mut interp = ContentInterpreter::with_viewport(
p + 1,
page_info.origin_x,
page_info.max_y,
page_info.height,
&page_info.fonts,
);
interp.interpret(&page_info.contents)?;
let raw_coord_spans = interp.spans;
let raw_drawings = interp.drawings;
let drawings = if options.extract_drawings {
let mut d = raw_drawings.clone();
if options.filter_background_fills {
let page_area = (page_info.width * page_info.height).abs();
d.retain(|item| {
if item.drawing_type == "stroke" {
true
} else if page_area > 0.0 {
let area = (item.bbox.width * item.bbox.height).abs();
area < 0.95 * page_area
} else {
true
}
});
}
for (idx, item) in d.iter_mut().enumerate() {
item.drawing_id = idx + 1;
}
d
} else {
Vec::new()
};
let mut raw_spans: Vec<RawSpan> = raw_coord_spans
.iter()
.map(|s| RawSpan {
x0: s.x0,
y0: s.y0,
x1: s.x1,
y1: s.y1,
font_size: s.font_size,
text: s.text.clone(),
})
.collect();
raw_spans.sort_by(|a, b| {
let y_bucket_a = (a.y0 / options.row_tolerance).round() as i32;
let y_bucket_b = (b.y0 / options.row_tolerance).round() as i32;
if y_bucket_a != y_bucket_b {
y_bucket_a.cmp(&y_bucket_b)
} else {
a.x0.partial_cmp(&b.x0).unwrap_or(std::cmp::Ordering::Equal)
}
});
let clustered_spans = cluster_touching_spans(&raw_spans);
let (tables, non_table_spans) = if options.detect_tables {
detector.extract_tables(&clustered_spans, 1)
} else {
(Vec::new(), clustered_spans.clone())
};
let mut text_blocks = Vec::new();
let ordered_non_table_spans = if options.reading_order == "natural" {
recursive_xy_cut(
&non_table_spans,
options.column_tolerance,
options.row_tolerance,
0,
)
} else {
non_table_spans.clone()
};
if !ordered_non_table_spans.is_empty() {
let mut current_block_lines = Vec::new();
let mut current_line_spans = Vec::new();
let mut last_y = ordered_non_table_spans[0].y0;
let mut last_line_y = ordered_non_table_spans[0].y0;
let mut block_counter = 1;
for span in &ordered_non_table_spans {
let is_same_line =
(span.y0 - last_y).abs() <= (options.row_tolerance / 2.0).max(3.0);
if !is_same_line {
if !current_line_spans.is_empty() {
let line = make_spatial_line(¤t_line_spans);
current_block_lines.push(line);
current_line_spans.clear();
}
let is_new_block =
(span.y0 - last_line_y).abs() > 13.5 || span.y0 < last_line_y - 20.0;
if is_new_block && !current_block_lines.is_empty() {
let block_bbox = compute_lines_bbox(¤t_block_lines);
let (is_complex, comp_score) = compute_block_complexity(
&block_bbox,
¤t_block_lines,
&raw_drawings,
page_info.width,
page_info.height,
);
text_blocks.push(SpatialBlock {
block_id: block_counter,
block_type: "text".to_string(),
bbox: block_bbox,
lines: std::mem::take(&mut current_block_lines),
is_complex_layout: is_complex,
complexity_score: Some(comp_score),
});
block_counter += 1;
}
last_line_y = span.y0;
}
current_line_spans.push(span.clone());
last_y = span.y0;
}
if !current_line_spans.is_empty() {
let line = make_spatial_line(¤t_line_spans);
current_block_lines.push(line);
}
if !current_block_lines.is_empty() {
let block_bbox = compute_lines_bbox(¤t_block_lines);
let (is_complex, comp_score) = compute_block_complexity(
&block_bbox,
¤t_block_lines,
&raw_drawings,
page_info.width,
page_info.height,
);
text_blocks.push(SpatialBlock {
block_id: block_counter,
block_type: "text".to_string(),
bbox: block_bbox,
lines: current_block_lines,
is_complex_layout: is_complex,
complexity_score: Some(comp_score),
});
}
}
let mut images = Vec::new();
if options.extract_images {
let res_obj = page_info
.raw_page_obj
.dict_get("Resources")
.and_then(|r| self.resolve(r).ok());
if let Some(res) = res_obj {
if let Some(xobj_dict) =
res.dict_get("XObject").and_then(|x| self.resolve(x).ok())
{
if let Some(xobjs) = xobj_dict.as_dict() {
let mut names: Vec<_> = xobjs.keys().cloned().collect();
names.sort();
for (idx, name) in names.into_iter().enumerate() {
let entry = &xobjs[&name];
let xref =
entry.as_reference().map(|r| r.number as i32).unwrap_or(0);
if let Ok(resolved) = self.resolve(entry) {
if let Some(stream) = resolved.as_stream() {
let subtype = stream
.dict
.get("Subtype")
.and_then(|s| s.as_name())
.unwrap_or("");
if subtype == "Image" {
let width = stream
.dict
.get("Width")
.and_then(|w| w.as_i64())
.unwrap_or(0)
as u32;
let height = stream
.dict
.get("Height")
.and_then(|h| h.as_i64())
.unwrap_or(0)
as u32;
let bpc = stream
.dict
.get("BitsPerComponent")
.and_then(|b| b.as_i64())
.map(|b| b as i32);
let cs = stream
.dict
.get("ColorSpace")
.and_then(|c| c.as_name())
.map(|s| s.to_string());
let filter = stream.dict.get("Filter").and_then(|f| {
f.as_name().map(|s| s.to_string()).or_else(|| {
f.as_array().and_then(|a| {
a.last().and_then(|e| {
e.as_name().map(|s| s.to_string())
})
})
})
});
let bbox = interp.image_placements.get(&name).cloned();
let raw_stream_bytes = stream.data.clone();
let has_bytes = !raw_stream_bytes.is_empty();
let byte_len = if has_bytes {
Some(raw_stream_bytes.len())
} else {
None
};
let sha256 = if has_bytes {
Some(image::sha256_hex(&raw_stream_bytes))
} else {
None
};
let mime_type =
image::mime_from_filter(filter.as_deref())
.map(str::to_string);
images.push(SpatialImage {
image_id: idx + 1,
name: name.clone(),
xref,
bbox,
pixel_width: width,
pixel_height: height,
bits_per_component: bpc,
color_space: cs,
filter: image::last_filter_name(filter.as_deref()),
mime_type,
byte_length: byte_len,
sha256,
file: None,
});
}
}
}
}
}
}
}
}
let is_page_complex = text_blocks.iter().any(|b| b.is_complex_layout);
pages.push(SpatialPage {
page_number: p + 1,
width: page_info.width,
height: page_info.height,
rotation: page_info.rotation,
initial_rotation: page_info.rotation,
coordinate_system: "source".to_string(),
text_blocks,
tables,
raw_span_count: raw_spans.len(),
images,
drawings,
media_box: page_info.media_box,
crop_box: page_info.crop_box,
is_complex_layout: is_page_complex,
});
}
let elapsed = get_elapsed_ms(start);
let mut doc = SpatialDocument {
file_name: file_name.to_string(),
total_pages,
parse_time_ms: (elapsed * 100.0).round() / 100.0,
pages,
};
if options.apply_rotation {
doc = doc.with_applied_rotation();
}
Ok(doc)
}
}
fn make_spatial_line(spans: &[RawSpan]) -> SpatialLine {
let mut x0 = f32::MAX;
let mut y0 = f32::MAX;
let mut x1 = f32::MIN;
let mut y1 = f32::MIN;
let mut full_text = String::new();
let mut spatial_spans = Vec::with_capacity(spans.len());
for (i, span) in spans.iter().enumerate() {
x0 = x0.min(span.x0);
y0 = y0.min(span.y0);
x1 = x1.max(span.x1);
y1 = y1.max(span.y1);
if i > 0 && !full_text.ends_with(' ') && !span.text.starts_with(' ') {
full_text.push(' ');
}
full_text.push_str(&span.text);
spatial_spans.push(SpatialSpan {
text: span.text.clone(),
bbox: BoundingBox::new(span.x0, span.y0, span.x1, span.y1),
font_name: String::new(),
font_size: span.font_size,
char_count: span.text.chars().count(),
});
}
SpatialLine {
text: full_text.trim_end().to_string(),
bbox: BoundingBox::new(x0, y0, x1, y1),
spans: spatial_spans,
}
}
fn compute_lines_bbox(lines: &[SpatialLine]) -> BoundingBox {
let mut x0 = f32::MAX;
let mut y0 = f32::MAX;
let mut x1 = f32::MIN;
let mut y1 = f32::MIN;
for line in lines {
x0 = x0.min(line.bbox.x0);
y0 = y0.min(line.bbox.y0);
x1 = x1.max(line.bbox.x1);
y1 = y1.max(line.bbox.y1);
}
if x0 > x1 || y0 > y1 {
BoundingBox::new(0.0, 0.0, 0.0, 0.0)
} else {
BoundingBox::new(x0, y0, x1, y1)
}
}
fn recursive_xy_cut(
spans: &[RawSpan],
col_tolerance: f32,
row_tolerance: f32,
depth: usize,
) -> Vec<RawSpan> {
if spans.len() <= 1 || depth >= 20 {
let mut leaf = spans.to_vec();
leaf.sort_by(|a, b| {
let row_tol = (row_tolerance / 2.0).max(3.0);
let y_bucket_a = (a.y0 / row_tol).round() as i32;
let y_bucket_b = (b.y0 / row_tol).round() as i32;
if y_bucket_a != y_bucket_b {
y_bucket_a.cmp(&y_bucket_b)
} else {
a.x0.partial_cmp(&b.x0).unwrap_or(std::cmp::Ordering::Equal)
}
});
return leaf;
}
let mut y_intervals: Vec<(f32, f32)> = spans
.iter()
.map(|s| (s.y0.min(s.y1), s.y0.max(s.y1)))
.collect();
y_intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let mut merged_y: Vec<(f32, f32)> = Vec::new();
for (y0, y1) in y_intervals {
if let Some(last) = merged_y.last_mut() {
if y0 <= last.1 + 1.5 {
last.1 = last.1.max(y1);
} else {
merged_y.push((y0, y1));
}
} else {
merged_y.push((y0, y1));
}
}
let min_y_gap = (row_tolerance * 1.8).max(12.0);
let mut best_y_cut: Option<f32> = None;
let mut max_y_gap = 0.0f32;
if merged_y.len() >= 2 {
for i in 0..(merged_y.len() - 1) {
let gap = merged_y[i + 1].0 - merged_y[i].1;
if gap >= min_y_gap && gap > max_y_gap {
max_y_gap = gap;
best_y_cut = Some((merged_y[i].1 + merged_y[i + 1].0) / 2.0);
}
}
}
if let Some(cut_y) = best_y_cut {
let (top, bottom): (Vec<RawSpan>, Vec<RawSpan>) =
spans.iter().cloned().partition(|s| s.y0 < cut_y);
if !top.is_empty() && !bottom.is_empty() {
let mut ordered = recursive_xy_cut(&top, col_tolerance, row_tolerance, depth + 1);
ordered.extend(recursive_xy_cut(
&bottom,
col_tolerance,
row_tolerance,
depth + 1,
));
return ordered;
}
}
let mut x_intervals: Vec<(f32, f32)> = spans
.iter()
.map(|s| (s.x0.min(s.x1), s.x0.max(s.x1)))
.collect();
x_intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let mut merged_x: Vec<(f32, f32)> = Vec::new();
for (x0, x1) in x_intervals {
if let Some(last) = merged_x.last_mut() {
if x0 <= last.1 + 1.0 {
last.1 = last.1.max(x1);
} else {
merged_x.push((x0, x1));
}
} else {
merged_x.push((x0, x1));
}
}
let min_x_gap = col_tolerance.max(12.0);
let mut best_x_cut: Option<f32> = None;
let mut max_x_gap = 0.0f32;
if merged_x.len() >= 2 {
for i in 0..(merged_x.len() - 1) {
let gap = merged_x[i + 1].0 - merged_x[i].1;
if gap >= min_x_gap && gap > max_x_gap {
max_x_gap = gap;
best_x_cut = Some((merged_x[i].1 + merged_x[i + 1].0) / 2.0);
}
}
}
if let Some(cut_x) = best_x_cut {
let (left, right): (Vec<RawSpan>, Vec<RawSpan>) = spans
.iter()
.cloned()
.partition(|s| (s.x0 + s.x1) / 2.0 < cut_x);
if !left.is_empty() && !right.is_empty() {
let mut ordered = recursive_xy_cut(&left, col_tolerance, row_tolerance, depth + 1);
ordered.extend(recursive_xy_cut(
&right,
col_tolerance,
row_tolerance,
depth + 1,
));
return ordered;
}
}
let mut leaf = spans.to_vec();
leaf.sort_by(|a, b| {
let row_tol = (row_tolerance / 2.0).max(3.0);
let y_bucket_a = (a.y0 / row_tol).round() as i32;
let y_bucket_b = (b.y0 / row_tol).round() as i32;
if y_bucket_a != y_bucket_b {
y_bucket_a.cmp(&y_bucket_b)
} else {
a.x0.partial_cmp(&b.x0).unwrap_or(std::cmp::Ordering::Equal)
}
});
leaf
}
fn compute_block_complexity(
bbox: &BoundingBox,
lines: &[SpatialLine],
drawings: &[crate::types::SpatialDrawing],
page_width: f32,
page_height: f32,
) -> (bool, f32) {
let mut score = 0.0f32;
let mut intersecting_drawings = 0usize;
for d in drawings {
if d.bbox.width >= page_width * 0.90 && d.bbox.height >= page_height * 0.90 {
continue;
}
let overlaps = bbox.x0 < d.bbox.x1
&& bbox.x1 > d.bbox.x0
&& bbox.y0 < d.bbox.y1
&& bbox.y1 > d.bbox.y0;
if overlaps {
intersecting_drawings += 1;
}
}
if intersecting_drawings > 0 {
score += (0.25 * (intersecting_drawings as f32)).min(0.55);
}
let mut collisions = 0usize;
for line in lines {
if line.spans.len() >= 2 {
for i in 0..(line.spans.len() - 1) {
let s1 = &line.spans[i].bbox;
let s2 = &line.spans[i + 1].bbox;
let overlap_x = (s1.x1.min(s2.x1) - s1.x0.max(s2.x0)).max(0.0);
let overlap_y = (s1.y1.min(s2.y1) - s1.y0.max(s2.y0)).max(0.0);
if overlap_x > 2.0 && overlap_y > 2.0 {
collisions += 1;
}
}
}
}
if collisions > 0 {
score += (0.20 * (collisions as f32)).min(0.40);
}
let mut fragmented_lines = 0usize;
for line in lines {
if line.spans.len() >= 4 {
fragmented_lines += 1;
}
}
if fragmented_lines >= 2 {
score += 0.25;
}
let clamped = score.clamp(0.0, 1.0);
let is_complex = clamped >= 0.45;
let rounded_score = (clamped * 100.0).round() / 100.0;
(is_complex, rounded_score)
}
fn cluster_touching_spans(spans: &[RawSpan]) -> Vec<RawSpan> {
if spans.is_empty() {
return Vec::new();
}
let mut result = Vec::with_capacity(spans.len());
let mut current = spans[0].clone();
for next_span in &spans[1..] {
let same_baseline = (next_span.y0 - current.y0).abs() <= 3.0;
let gap = next_span.x0 - current.x1;
let char_spacing_limit = (current.font_size * 0.10).clamp(0.4, 0.85);
let ends_with_hyphen = current.text.ends_with('-') || current.text.ends_with('/');
let allowed_limit = if ends_with_hyphen {
char_spacing_limit.max(1.2)
} else {
char_spacing_limit
};
let has_explicit_space = current.text.ends_with(' ') || next_span.text.starts_with(' ');
if same_baseline && !has_explicit_space && gap >= -1.0 && gap <= allowed_limit {
current.text.push_str(&next_span.text);
current.x1 = current.x1.max(next_span.x1);
current.y0 = current.y0.min(next_span.y0);
current.y1 = current.y1.max(next_span.y1);
} else {
result.push(current);
current = next_span.clone();
}
}
result.push(current);
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_pdf_document() {
let mut pdf = Vec::new();
pdf.extend_from_slice(b"%PDF-1.4\n");
let o1 = pdf.len();
pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
let o2 = pdf.len();
pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [ 3 0 R ] /Count 1 >>\nendobj\n");
let o3 = pdf.len();
pdf.extend_from_slice(b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 612 792 ] /Contents 4 0 R >>\nendobj\n");
let o4 = pdf.len();
pdf.extend_from_slice(b"4 0 obj\n<< /Length 44 >> stream\nBT\n/F1 12 Tf\n72 700 Td\n(Hello Pure Rust PDF) Tj\nET\nendstream\nendobj\n");
let xref_pos = pdf.len();
pdf.extend_from_slice(
format!(
"xref\n0 5\n0000000000 65535 f \n{:010} 00000 n \n{:010} 00000 n \n{:010} 00000 n \n{:010} 00000 n \ntrailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{}\n%%EOF",
o1, o2, o3, o4, xref_pos
)
.as_bytes(),
);
let mut doc = PurePdfDoc::from_bytes(&pdf).unwrap();
assert_eq!(doc.page_count(), 1);
let raw_doc = doc.parse_raw("test.pdf").unwrap();
assert_eq!(raw_doc.total_pages, 1);
assert_eq!(raw_doc.total_spans, 1);
assert_eq!(raw_doc.spans[0].text, "Hello Pure Rust PDF");
assert_eq!(raw_doc.spans[0].x0, 72.0);
}
#[test]
fn test_parse_real_fixture_text_only() {
let path = std::path::Path::new("tests/fixtures/text_only.pdf");
if !path.exists() {
return;
}
let bytes = std::fs::read(path).unwrap();
let mut doc = PurePdfDoc::from_bytes(&bytes).unwrap();
assert_eq!(doc.page_count(), 1);
let raw_doc = doc.parse_raw("text_only.pdf").unwrap();
assert_eq!(raw_doc.total_pages, 1);
assert!(raw_doc.total_spans > 0, "Must extract spans from real PDF");
println!(
"Extracted {} spans from text_only.pdf: {:?}",
raw_doc.total_spans, raw_doc.spans
);
}
#[test]
fn test_parse_all_benchmark_pdfs() {
let pdf_files = [
"tests/fixtures/text_only.pdf",
"tests/fixtures/mixed.pdf",
"tests/fixtures/rotated.pdf",
"tests/fixtures/image_only.pdf",
"docs/sample.pdf",
];
for path_str in &pdf_files {
let path = std::path::Path::new(path_str);
if !path.exists() {
continue;
}
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => {
println!("Skip {}: {}", path_str, e);
continue;
}
};
let start = std::time::Instant::now();
match PurePdfDoc::from_bytes(&bytes) {
Ok(mut doc) => {
let page_count = doc.page_count();
let raw = doc.parse_raw(path_str).unwrap();
let elapsed = start.elapsed();
println!(
"⚡ [BENCHMARK] {}: {} page(s), {} spans, parsed in {:?}",
path_str, page_count, raw.total_spans, elapsed
);
let limit = if path_str.contains("sample.pdf") {
100
} else {
3
};
for span in raw.spans.iter().take(limit) {
println!(
" ↳ span: [{:.1}, {:.1}, {:.1}, {:.1}] font={} size={} text={:?}",
span.x0,
span.y0,
span.x1,
span.y1,
span.font_name,
span.font_size,
span.text
);
}
}
Err(err) => {
println!("❌ [ERROR] {}: {}", path_str, err);
panic!("Failed to parse {}: {}", path_str, err);
}
}
}
}
#[test]
fn test_benchmark_pypdf_corpus() {
let pdf_files = [
"../benchmarks/pdfs/2201.00200.pdf",
"../benchmarks/pdfs/2201.00029.pdf",
"../benchmarks/pdfs/1601.03642.pdf",
"../benchmarks/pdfs/2201.00021.pdf",
];
for path_str in &pdf_files {
let path = std::path::Path::new(path_str);
if !path.exists() {
println!("File not found: {}", path_str);
continue;
}
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => {
println!("Could not read {}: {}", path_str, e);
continue;
}
};
let start = std::time::Instant::now();
match PurePdfDoc::from_bytes(&bytes) {
Ok(mut doc) => {
let page_count = doc.page_count();
let raw = doc.parse_raw(path_str).unwrap();
let elapsed = start.elapsed();
let ms_per_page = if page_count > 0 {
(elapsed.as_secs_f64() * 1000.0) / (page_count as f64)
} else {
0.0
};
println!(
"🚀 [PURE RUST vs PYPDF CORPUS] {}: {} pages, {} spans, parsed in {:?} ({:.2} ms/page)",
path_str, page_count, raw.total_spans, elapsed, ms_per_page
);
}
Err(e) => {
println!("⚠️ [PARSE INFO] {}: {}", path_str, e);
}
}
}
}
#[test]
fn test_cpu_consumption_benchmark() {
let path = std::path::Path::new("../benchmarks/pdfs/2201.00200.pdf");
if !path.exists() {
return;
}
let bytes = std::fs::read(path).unwrap();
let runs = 100;
let start = std::time::Instant::now();
let mut total_spans = 0;
for _ in 0..runs {
let mut doc = PurePdfDoc::from_bytes(&bytes).unwrap();
let raw = doc.parse_raw("2201.00200.pdf").unwrap();
total_spans += raw.total_spans;
}
let elapsed = start.elapsed();
let total_pages = runs * 7;
let throughput = (total_pages as f64) / elapsed.as_secs_f64();
println!(
"🔥 [PURE RUST CPU BENCHMARK] Processed {} pages ({} spans) in {:?} (Throughput: {:.1} pages/sec)",
total_pages, total_spans, elapsed, throughput
);
}
#[test]
fn test_parse_structured_tables_and_blocks() {
let path = std::path::Path::new("docs/sample.pdf");
if !path.exists() {
return;
}
let bytes = std::fs::read(path).unwrap();
let mut doc = PurePdfDoc::from_bytes(&bytes).unwrap();
let options = ParserOptions {
detect_tables: true,
min_table_columns: 3,
column_tolerance: 16.0,
row_tolerance: 6.0,
..Default::default()
};
let structured = doc.parse_structured("sample.pdf", &options).unwrap();
assert_eq!(structured.total_pages, 1);
let page = &structured.pages[0];
assert_eq!(
page.tables.len(),
1,
"Deveria ter detectado a tabela de 4 colunas"
);
let table = &page.tables[0];
assert_eq!(table.col_count, 4);
assert_eq!(table.headers, vec!["Item", "X pt", "Y pt", "Type"]);
assert_eq!(table.rows.len(), 4);
assert!(
!page.text_blocks.is_empty(),
"Deveria ter blocos de texto fora da tabela"
);
}
#[test]
fn test_extract_all_images_pure_rust() {
let path = std::path::Path::new("tests/fixtures/mixed.pdf");
if !path.exists() {
return;
}
let bytes = std::fs::read(path).unwrap();
let mut doc = PurePdfDoc::from_bytes(&bytes).unwrap();
let images = doc.extract_all_images().unwrap();
assert_eq!(images.len(), 1);
let img = &images[0];
assert_eq!(img.page, 1);
assert_eq!(img.image.name, "Im0");
assert_eq!((img.image.pixel_width, img.image.pixel_height), (400, 300));
assert_eq!(img.image.filter.as_deref(), Some("DCTDecode"));
assert_eq!(img.image.mime_type.as_deref(), Some("image/jpeg"));
assert_eq!(
&img.bytes[..3],
[0xFF, 0xD8, 0xFF],
"Assinatura JPEG original"
);
assert!(img.image.bbox.is_some(), "Deveria ter posicionado a imagem");
let bbox = img.image.bbox.as_ref().unwrap();
assert!((bbox.x0 - 330.0).abs() <= 2.0);
assert!((bbox.x1 - 530.0).abs() <= 2.0);
}
#[test]
fn test_cyclic_reference_does_not_crash() {
let mut pdf = Vec::new();
pdf.extend_from_slice(b"%PDF-1.4\n");
let o1 = pdf.len();
pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
let o2 = pdf.len();
pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [ 3 0 R ] /Count 1 >>\nendobj\n");
let o3 = pdf.len();
pdf.extend_from_slice(b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 612 792 ] /Contents 4 0 R >>\nendobj\n");
let o4 = pdf.len();
pdf.extend_from_slice(b"4 0 obj\n<< /Length 10 >> stream\nET\nendstream\nendobj\n");
let o5 = pdf.len();
pdf.extend_from_slice(b"5 0 obj\n6 0 R\nendobj\n");
let o6 = pdf.len();
pdf.extend_from_slice(b"6 0 obj\n5 0 R\nendobj\n");
let xref_pos = pdf.len();
pdf.extend_from_slice(
format!(
"xref\n0 7\n0000000000 65535 f \n{:010} 00000 n \n{:010} 00000 n \n{:010} 00000 n \n{:010} 00000 n \n{:010} 00000 n \n{:010} 00000 n \ntrailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n{}\n%%EOF",
o1, o2, o3, o4, o5, o6, xref_pos
)
.as_bytes(),
);
let mut doc = PurePdfDoc::from_bytes(&pdf).unwrap();
let res = doc.resolve(&PdfObject::Reference(ObjectId::new(5, 0)));
assert!(
res.is_err(),
"Cyclic reference resolution must return Err without overflowing stack"
);
}
#[test]
fn test_cyclic_pages_node_does_not_crash() {
let mut pdf = Vec::new();
pdf.extend_from_slice(b"%PDF-1.4\n");
let o1 = pdf.len();
pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
let o2 = pdf.len();
pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [ 2 0 R ] /Count 1 >>\nendobj\n");
let xref_pos = pdf.len();
pdf.extend_from_slice(
format!(
"xref\n0 3\n0000000000 65535 f \n{:010} 00000 n \n{:010} 00000 n \ntrailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n{}\n%%EOF",
o1, o2, xref_pos
)
.as_bytes(),
);
let res = PurePdfDoc::from_bytes(&pdf);
assert!(
res.is_err(),
"Cyclic page tree must return Err without overflowing stack"
);
}
#[test]
fn test_cyclic_parent_page_attribute_does_not_loop() {
let mut pdf = Vec::new();
pdf.extend_from_slice(b"%PDF-1.4\n");
let o1 = pdf.len();
pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
let o2 = pdf.len();
pdf.extend_from_slice(
b"2 0 obj\n<< /Type /Pages /Kids [ 3 0 R ] /Count 1 /Parent 3 0 R >>\nendobj\n",
);
let o3 = pdf.len();
pdf.extend_from_slice(
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 612 792 ] >>\nendobj\n",
);
let xref_pos = pdf.len();
pdf.extend_from_slice(
format!(
"xref\n0 4\n0000000000 65535 f \n{:010} 00000 n \n{:010} 00000 n \n{:010} 00000 n \ntrailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n{}\n%%EOF",
o1, o2, o3, xref_pos
)
.as_bytes(),
);
let mut doc = PurePdfDoc::from_bytes(&pdf).unwrap();
let page_obj = doc
.xref
.get_object(doc.data, ObjectId::new(3, 0), &mut doc.obj_cache)
.unwrap();
let res = doc.get_inherited_page_attribute(&page_obj, "NonExistentKey");
assert!(
res.is_err(),
"Cyclic /Parent chain must be detected and return Err"
);
}
#[test]
fn test_load_page_contents_capped_and_deduplicated() {
let mut pdf = Vec::new();
pdf.extend_from_slice(b"%PDF-1.4\n");
let o1 = pdf.len();
pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
let o2 = pdf.len();
pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [ 3 0 R ] /Count 1 >>\nendobj\n");
let o3 = pdf.len();
pdf.extend_from_slice(b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 612 792 ] /Contents [ 4 0 R 4 0 R 4 0 R 4 0 R 4 0 R ] >>\nendobj\n");
let o4 = pdf.len();
pdf.extend_from_slice(
b"4 0 obj\n<< /Length 12 >> stream\nq 1 0 0 1 Q\n\nendstream\nendobj\n",
);
let xref_pos = pdf.len();
pdf.extend_from_slice(
format!(
"xref\n0 5\n0000000000 65535 f \n{:010} 00000 n \n{:010} 00000 n \n{:010} 00000 n \n{:010} 00000 n \ntrailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{}\n%%EOF",
o1, o2, o3, o4, xref_pos
)
.as_bytes(),
);
let mut doc = PurePdfDoc::from_bytes(&pdf).unwrap();
let page_info = doc.load_page(0).unwrap();
let stream_text = std::str::from_utf8(&page_info.contents).unwrap();
let occurrences = stream_text.matches("q 1 0 0 1 Q").count();
assert_eq!(
occurrences, 1,
"Duplicate content stream references in array must be deduplicated"
);
}
#[test]
fn test_box_array_nan_and_infinite_sanitization() {
let mut doc = PurePdfDoc {
data: b"",
xref: XRefTable::new(),
obj_cache: HashMap::new(),
pages: Vec::new(),
};
let bad_array = PdfObject::Array(vec![
PdfObject::Real(f64::NAN),
PdfObject::Real(f64::INFINITY),
PdfObject::Real(612.0),
PdfObject::Real(792.0),
]);
let parsed = doc.parse_box_array(Some(&bad_array)).unwrap().unwrap();
assert!(parsed[0].is_finite());
assert!(parsed[1].is_finite());
assert_eq!(parsed[0], 0.0);
assert_eq!(parsed[1], 0.0);
assert_eq!(parsed[2], 612.0);
assert_eq!(parsed[3], 792.0);
}
}