use std::fmt::Write as _;
use pdfrum_common::PageIndex;
use pdfrum_object::{Dict, Object, Resolve};
use crate::names;
use crate::structure::{StructTree, element};
#[must_use]
pub fn render<R: Resolve>(
tree: Option<&StructTree>,
page_index: impl Into<PageIndex>,
r: &R,
) -> String {
let page_index = page_index.into();
let Some(tree) = tree else {
return String::new();
};
let mut out = format!("Structure Tree for Page {page_index}\n");
for index in tree.top.iter().flatten() {
dump_element(&mut out, tree, *index, 0, r);
}
out.push_str("\n\n");
out
}
fn dump_element<R: Resolve>(
out: &mut String,
tree: &StructTree,
index: usize,
depth: usize,
r: &R,
) {
let Some(element) = tree.elements.get(index) else {
return;
};
let pad = " ".repeat(depth * 2);
if !element.kind.is_empty() {
let _ = writeln!(
out,
"{pad} S: {}",
wide(&String::from_utf8_lossy(&element.kind))
);
}
for (attr_index, attr) in attributes(&element.dict, r).into_iter().enumerate() {
let _ = writeln!(out, "{pad} A[{attr_index}]:");
dump_attribute(out, &attr, depth * 2 + 2, r);
}
for (label, value) in [
("ActualText", element.actual_text(r)),
("AltText", element.alt_text(r)),
] {
if !value.is_empty() {
let _ = writeln!(out, "{pad} {label}: {}", wide(&value));
}
}
if let Some(id) = element.id() {
let _ = writeln!(out, "{pad} ID: {}", wide(&decoded(&id)));
}
if let Some(lang) = element.lang() {
let _ = writeln!(out, "{pad} Lang: {}", wide(&decoded(&lang)));
}
for index in 0..element::marked_content_id_count(&element.dict, r).unwrap_or(0) {
if let Some(id) = element::marked_content_id_at(&element.dict, index, r) {
let _ = writeln!(out, "{pad} MCID{index}: {id}");
}
}
if let Some(parent) = element.parent.and_then(|p| tree.elements.get(p))
&& let Some(id) = parent.id()
{
let _ = writeln!(out, "{pad} Parent ID: {}", wide(&decoded(&id)));
}
let title = element.title(r);
if !title.is_empty() {
let _ = writeln!(out, "{pad} Title: {}", wide(&title));
}
let obj_type = element.obj_type(r);
if !obj_type.is_empty() {
let _ = writeln!(
out,
"{pad} Type: {}",
wide(&String::from_utf8_lossy(&obj_type))
);
}
for kid in &element.kids {
if let crate::structure::Kid::Element {
linked: Some(child),
..
} = kid
{
dump_element(out, tree, *child, depth + 1, r);
}
}
}
fn attributes<R: Resolve>(dict: &Dict, r: &R) -> Vec<Dict> {
match dict.get(names::A, r).map(|a| a.get().clone()) {
Some(Object::Array(array)) => (0..array.len())
.filter_map(|index| array.dict_at(index, r))
.collect(),
Some(Object::Dict(attr)) => vec![attr],
_ => Vec::new(),
}
}
fn dump_attribute<R: Resolve>(out: &mut String, attr: &Dict, indent: usize, r: &R) {
let mut keys: Vec<_> = attr.keys().map(pdfrum_object::Name::as_bytes).collect();
keys.sort_unstable();
for key in keys {
let name = pdfrum_object::Name::new(key.to_vec());
let value = attr.get(&name, r).map(|v| v.get().clone());
dump_value(out, &String::from_utf8_lossy(key), value.as_ref(), indent);
}
}
fn dump_value(out: &mut String, name: &str, value: Option<&Object>, indent: usize) {
let pad = " ".repeat(indent);
match value {
Some(Object::Bool(flag)) => {
let _ = writeln!(out, "{pad} {name}: {}", u8::from(*flag));
}
Some(obj @ (Object::Int(_) | Object::Real(_))) => {
let number = f64::from(obj.number().unwrap_or(0.0));
let _ = writeln!(out, "{pad} {name}: {number:.6}");
}
Some(Object::Str(text)) => {
let _ = writeln!(out, "{pad} {name}: {}", wide(&text.as_text()));
}
Some(Object::Name(named)) => {
let _ = writeln!(out, "{pad} {name}: {}", wide(&named.as_text()));
}
Some(Object::Array(array)) => {
let _ = writeln!(out, "{pad} {name}:");
for index in 0..array.len() {
dump_value(out, name, array.raw_at(index), indent + 2);
}
}
None | Some(Object::Null) => {
let _ = writeln!(out, "{pad} {name}: FPDF_OBJECT_UNKNOWN");
}
Some(other) => {
let _ = writeln!(
out,
"{pad} {name}: NOT_YET_IMPLEMENTED: {}",
type_tag(other)
);
}
}
}
fn type_tag(object: &Object) -> u8 {
match object {
Object::Null => 1,
Object::Bool(_) => 2,
Object::Int(_) | Object::Real(_) => 3,
Object::Str(_) => 4,
Object::Name(_) => 5,
Object::Array(_) => 6,
Object::Dict(_) => 7,
Object::Stream(_) => 9,
Object::Ref(_) => 8,
}
}
fn decoded(bytes: &[u8]) -> String {
pdfrum_object::decode_text(bytes).into_owned()
}
fn wide(text: &str) -> String {
match text.find(['\u{0}', '\u{FFFD}']) {
Some(at) => text.get(..at).unwrap_or_default().to_owned(),
None => text.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::{attributes, dump_attribute, dump_value, render, wide};
use pdfrum_object::{Array, Dict, Name, NoResolve, Object, PdfString};
fn dict(pairs: &[(&str, Object)]) -> Dict {
Dict::from_pairs(
pairs
.iter()
.map(|(k, v)| (Name::from(*k), v.clone()))
.collect::<Vec<_>>(),
)
}
#[test]
fn an_absent_tree_prints_nothing_at_all() {
assert_eq!(render(None, 0, &NoResolve), "");
}
#[test]
fn an_empty_tree_still_prints_its_header_and_two_blank_lines() {
let tree = crate::structure::StructTree::default();
assert_eq!(
render(Some(&tree), 3, &NoResolve),
"Structure Tree for Page 3\n\n\n"
);
}
#[test]
fn attribute_keys_print_alphabetically_whatever_the_file_says() {
let attr = dict(&[
("Scope", Object::Name(Name::from("Row"))),
("O", Object::Name(Name::from("Table"))),
]);
let mut out = String::new();
dump_attribute(&mut out, &attr, 2, &NoResolve);
assert_eq!(out, " O: Table\n Scope: Row\n");
}
#[test]
fn a_number_always_prints_six_decimals() {
let mut out = String::new();
dump_value(&mut out, "ColSpan", Some(&Object::Int(2)), 0);
assert_eq!(out, " ColSpan: 2.000000\n");
}
#[test]
fn a_boolean_prints_as_one_or_zero() {
let mut out = String::new();
dump_value(&mut out, "CurUSD", Some(&Object::Bool(true)), 0);
assert_eq!(out, " CurUSD: 1\n");
}
#[test]
fn an_array_repeats_its_own_name_for_every_child() {
let bbox = Object::Array(Array::of([Object::from(44.0_f32), Object::from(45.0_f32)]));
let mut out = String::new();
dump_value(&mut out, "BBox", Some(&bbox), 0);
assert_eq!(out, " BBox:\n BBox: 44.000000\n BBox: 45.000000\n");
}
#[test]
fn a_reference_child_reports_its_type_tag_rather_than_its_value() {
let array = Object::Array(Array::of([Object::Ref(pdfrum_object::ObjRef::new(9, 0))]));
let mut out = String::new();
dump_value(&mut out, "RowSpan", Some(&array), 0);
assert_eq!(out, " RowSpan:\n RowSpan: NOT_YET_IMPLEMENTED: 8\n");
}
#[test]
fn a_present_but_empty_string_attribute_still_prints_its_label() {
let mut out = String::new();
dump_value(
&mut out,
"Summary",
Some(&Object::Str(PdfString::literal(b""))),
0,
);
assert_eq!(out, " Summary: \n");
}
#[test]
fn a_non_dict_attribute_contributes_no_indices() {
assert!(attributes(&dict(&[("A", Object::Int(4))]), &NoResolve).is_empty());
assert_eq!(
attributes(&dict(&[("A", Object::Dict(Dict::new()))]), &NoResolve).len(),
1
);
}
#[test]
fn a_string_stops_at_the_first_nul_or_unconvertible_unit() {
assert_eq!(wide("plain"), "plain");
assert_eq!(wide("cut\u{FFFD}here"), "cut");
assert_eq!(wide("Hello!\u{0}"), "Hello!");
}
}