#[derive(Debug, Clone, Default, PartialEq)]
pub struct DocumentModel {
pub page_count: u32,
pub path: String,
pub url: String,
pub page_words: Vec<Vec<String>>,
pub info: Vec<(String, String)>,
pub has_info: bool,
pub fields: Vec<FieldModel>,
pub named_destinations: Vec<(String, u32)>,
pub annotations: Vec<AnnotModel>,
pub calculate: bool,
}
impl DocumentModel {
#[must_use]
pub fn empty() -> DocumentModel {
DocumentModel {
calculate: true,
..DocumentModel::default()
}
}
#[must_use]
pub fn field_at(&self, index: usize) -> Option<&FieldModel> {
self.fields.get(index)
}
#[must_use]
pub fn fields_named(&self, name: &str) -> Vec<&FieldModel> {
match self.node_of(name) {
FieldNode::Missing => Vec::new(),
FieldNode::Root => self.fields.iter().collect(),
FieldNode::Named(prefix) => {
let under = format!("{prefix}.");
self.fields
.iter()
.filter(|f| f.name == prefix || f.name.starts_with(&under))
.collect()
}
}
}
#[must_use]
pub fn field_named(&self, name: &str) -> Option<usize> {
match self.node_of(name) {
FieldNode::Missing => None,
FieldNode::Root => (!self.fields.is_empty()).then_some(0),
FieldNode::Named(prefix) => {
if let Some(exact) = self.fields.iter().position(|f| f.name == prefix) {
return Some(exact);
}
let under = format!("{prefix}.");
self.fields.iter().position(|f| f.name.starts_with(&under))
}
}
}
#[must_use]
pub fn count_fields(&self, name: &str) -> usize {
match self.node_of(name) {
FieldNode::Missing => 0,
FieldNode::Root => self.fields.len(),
FieldNode::Named(prefix) => {
let under = format!("{prefix}.");
self.fields
.iter()
.filter(|f| f.name == prefix || f.name.starts_with(&under))
.count()
}
}
}
fn node_of(&self, name: &str) -> FieldNode {
if name.is_empty() {
return FieldNode::Root;
}
let mut reached: Option<String> = None;
for segment in name.split('.') {
if segment.is_empty() {
return FieldNode::of(reached);
}
let candidate = match &reached {
None => segment.to_string(),
Some(prefix) => format!("{prefix}.{segment}"),
};
let under = format!("{candidate}.");
let exists = self
.fields
.iter()
.any(|f| f.name == candidate || f.name.starts_with(&under));
if !exists {
return FieldNode::Missing;
}
reached = Some(candidate);
}
FieldNode::of(reached)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum FieldNode {
Missing,
Root,
Named(String),
}
impl FieldNode {
fn of(reached: Option<String>) -> FieldNode {
match reached {
Some(prefix) => FieldNode::Named(prefix),
None => FieldNode::Root,
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct FieldModel {
pub name: String,
pub kind: FieldModelKind,
pub value: String,
pub default_value: String,
pub value_as_string: Option<String>,
pub options: Vec<(String, String)>,
pub selected: Vec<u32>,
pub flags: FieldModelFlags,
pub display: u32,
pub rect: [f64; 4],
pub pages: Vec<u32>,
pub user_name: String,
pub export_values: Vec<String>,
pub checked: Vec<bool>,
pub default_checked: Vec<bool>,
pub captions: [String; 3],
pub button_position: u32,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum FieldModelKind {
Button,
CheckBox,
RadioButton,
ComboBox,
ListBox,
Text,
Signature,
#[default]
Unknown,
}
impl FieldModelKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
FieldModelKind::Button => "button",
FieldModelKind::CheckBox => "checkbox",
FieldModelKind::RadioButton => "radiobutton",
FieldModelKind::ComboBox => "combobox",
FieldModelKind::ListBox => "listbox",
FieldModelKind::Text => "text",
FieldModelKind::Signature => "signature",
FieldModelKind::Unknown => "unknown",
}
}
#[must_use]
pub fn is_toggle(self) -> bool {
matches!(self, FieldModelKind::CheckBox | FieldModelKind::RadioButton)
}
#[must_use]
pub fn is_choice(self) -> bool {
matches!(self, FieldModelKind::ComboBox | FieldModelKind::ListBox)
}
}
#[allow(
clippy::struct_excessive_bools,
reason = "one field per `/Ff` bit a script can read, named rather than \
numbered. They are eleven independent questions, not a state \
machine, and each is exactly one `Field` property's answer — \
packing them back into the bit word would put the decoding \
inside eleven getters instead of at the one place that reads \
`/Ff`."
)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct FieldModelFlags {
pub read_only: bool,
pub required: bool,
pub no_export: bool,
pub multiline: bool,
pub password: bool,
pub file_select: bool,
pub do_not_spell_check: bool,
pub do_not_scroll: bool,
pub comb: bool,
pub rich_text: bool,
pub editable: bool,
pub multiple_selection: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AnnotModel {
pub page: u32,
pub name: String,
pub kind: String,
pub hidden: bool,
}
#[must_use]
pub fn read<R: pdfrum_object::Resolve>(
catalog: &pdfrum_object::Dict,
info: Option<&pdfrum_object::Dict>,
pages: &[pdfrum_object::Dict],
path: &str,
r: &R,
) -> DocumentModel {
let page_count = u32::try_from(pages.len()).unwrap_or(u32::MAX);
let (limits, mut diags) = (
pdfrum_common::Limits::default(),
pdfrum_common::Diagnostics::default(),
);
let mut model = DocumentModel {
page_count,
path: if path.starts_with('/') {
path.to_string()
} else {
format!("/{path}")
},
url: path.to_string(),
..DocumentModel::empty()
};
if path.is_empty() {
model.path.clear();
}
model.has_info = info.is_some();
if let Some(info) = info {
model.info = info
.iter()
.map(|(key, _)| {
let text = info
.get(key, r)
.map(|value| value.get().to_text())
.unwrap_or_default();
(String::from_utf8_lossy(key.as_bytes()).into_owned(), text)
})
.collect();
}
let widget_pages = widget_pages(pages, r);
if let Some(form) = pdfrum_doc::form::Form::load(catalog, r, &limits, &mut diags) {
model.fields = form
.fields
.iter()
.map(|field| read_field(field, &widget_pages, r))
.collect();
}
model.annotations = read_annotations(pages, r);
model.named_destinations = read_destinations(catalog, r, &limits, &mut diags);
model
}
fn widget_pages<R: pdfrum_object::Resolve>(
pages: &[pdfrum_object::Dict],
r: &R,
) -> Vec<(pdfrum_object::Dict, u32)> {
let mut out = Vec::new();
for (index, page) in pages.iter().enumerate() {
let Some(annots) = page.array(pdfrum_object::names::ANNOTS, r) else {
continue;
};
for at in 0..annots.len() {
if let Some(dict) = annots.dict_at(at, r) {
out.push((dict, u32::try_from(index).unwrap_or(u32::MAX)));
}
}
}
out
}
fn read_annotations<R: pdfrum_object::Resolve>(
pages: &[pdfrum_object::Dict],
r: &R,
) -> Vec<AnnotModel> {
const NM: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"NM");
let mut out = Vec::new();
for (index, page) in pages.iter().enumerate() {
let Some(annots) = page.array(pdfrum_object::names::ANNOTS, r) else {
continue;
};
for at in 0..annots.len() {
let Some(dict) = annots.dict_at(at, r) else {
continue;
};
let subtype = dict
.byte_string(pdfrum_object::names::SUBTYPE, r)
.unwrap_or_default();
if subtype == b"Popup" || subtype == b"Widget" {
continue;
}
out.push(AnnotModel {
page: u32::try_from(index).unwrap_or(u32::MAX),
name: dict
.get(NM, r)
.map(|value| value.get().to_text())
.unwrap_or_default(),
kind: String::from_utf8_lossy(&subtype).into_owned(),
hidden: dict.int(pdfrum_object::names::F, r).unwrap_or(0) & 0b10 != 0,
});
}
}
out
}
fn read_destinations<R: pdfrum_object::Resolve>(
catalog: &pdfrum_object::Dict,
r: &R,
limits: &pdfrum_common::Limits,
diags: &mut pdfrum_common::Diagnostics,
) -> Vec<(String, u32)> {
const DESTS: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"Dests");
let mut out = Vec::new();
if let Some(tree) = pdfrum_doc::nav::NameTree::open(catalog, DESTS, r) {
let count = tree.count(r, limits, diags);
for index in 0..count {
let Some((name, value)) = tree.lookup_by_index(index, r, limits, diags) else {
continue;
};
let usable = matches!(
value,
pdfrum_object::Object::Array(_) | pdfrum_object::Object::Dict(_)
);
if usable {
out.push((name, 0));
}
}
}
if let Some(dests) = catalog.dict(DESTS, r) {
for (key, _) in dests.iter() {
out.push((String::from_utf8_lossy(key.as_bytes()).into_owned(), 0));
}
}
out
}
fn toggle_value<R: pdfrum_object::Resolve>(
kind: FieldModelKind,
value: String,
field: &pdfrum_doc::form::Field,
r: &R,
) -> String {
if kind.is_toggle() && !field.is_checked(None, r) {
return "Off".to_string();
}
value
}
fn value_as_string_of<R: pdfrum_object::Resolve>(
kind: FieldModelKind,
field: &pdfrum_doc::form::Field,
selected: usize,
r: &R,
) -> Option<String> {
match kind {
FieldModelKind::CheckBox => Some(
if field.is_checked(None, r) {
"Yes"
} else {
"Off"
}
.to_string(),
),
FieldModelKind::ListBox if selected > 1 => Some(String::new()),
_ => None,
}
}
fn read_field<R: pdfrum_object::Resolve>(
field: &pdfrum_doc::form::Field,
widget_pages: &[(pdfrum_object::Dict, u32)],
r: &R,
) -> FieldModel {
use pdfrum_doc::form::FieldKind;
let flags = field.flags;
let kind = match field.kind {
FieldKind::Text => FieldModelKind::Text,
FieldKind::Check => FieldModelKind::CheckBox,
FieldKind::Radio => FieldModelKind::RadioButton,
FieldKind::Button => FieldModelKind::Button,
FieldKind::Combo => FieldModelKind::ComboBox,
FieldKind::List => FieldModelKind::ListBox,
FieldKind::Signature => FieldModelKind::Signature,
};
let value = field.value(None, r);
let options: Vec<(String, String)> = pdfrum_doc::ap::field_body::options(&field.dict, r)
.into_iter()
.map(|choice| (choice.value, choice.label))
.collect();
let option_values: Vec<String> = options.iter().map(|(value, _)| value.clone()).collect();
let selected =
pdfrum_doc::form::selected_indices_for_interaction(&field.dict, &option_values, r)
.into_iter()
.map(|index| u32::try_from(index).unwrap_or(u32::MAX))
.collect::<Vec<u32>>();
let value = toggle_value(kind, value, field, r);
let value_as_string = value_as_string_of(kind, field, selected.len(), r);
let rect = field
.widgets
.first()
.map(|widget| widget.dict.rect(pdfrum_object::names::RECT, r))
.map_or([0.0; 4], |rect| {
[
rect.x0.min(rect.x1),
rect.y0.max(rect.y1),
rect.x0.max(rect.x1),
rect.y0.min(rect.y1),
]
});
let checked: Vec<bool> = field
.widgets
.iter()
.map(|_| field.is_checked(None, r))
.collect();
FieldModel {
name: field.name.clone(),
kind,
value,
default_value: field.default_value(r),
value_as_string,
options,
selected,
flags: FieldModelFlags {
read_only: flags.is_read_only(),
required: flags.is_required(),
no_export: flags.bits() & (1 << 2) != 0,
multiline: flags.is_multiline(),
password: flags.is_password(),
file_select: flags.bits() & (1 << 20) != 0,
do_not_spell_check: !flags.spell_checks(),
do_not_scroll: !flags.scrolls(),
comb: flags.is_comb(),
rich_text: flags.bits() & (1 << 25) != 0,
editable: flags.is_editable_combo(),
multiple_selection: flags.is_multi_select(),
},
display: display_of(field, r),
rect,
pages: field
.widgets
.iter()
.filter_map(|widget| {
widget_pages
.iter()
.find(|(dict, _)| *dict == widget.dict)
.map(|(_, page)| *page)
})
.collect(),
user_name: field.tooltip(r).unwrap_or_default(),
export_values: if matches!(kind, FieldModelKind::CheckBox | FieldModelKind::RadioButton) {
field
.widgets
.iter()
.map(|widget| on_state_of(&widget.dict, r))
.collect()
} else {
option_values
},
default_checked: checked.iter().map(|_| false).collect(),
checked,
captions: caption_of(field, r),
button_position: button_position_of(field, r),
}
}
fn on_state_of<R: pdfrum_object::Resolve>(widget: &pdfrum_object::Dict, r: &R) -> String {
const N: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"N");
const YES: &str = "Yes";
widget
.dict(pdfrum_object::names::AP, r)
.and_then(|ap| ap.dict(N, r))
.and_then(|normal| {
normal
.iter()
.map(|(key, _)| String::from_utf8_lossy(key.as_bytes()).into_owned())
.find(|key| key != "Off")
})
.unwrap_or_else(|| YES.to_string())
}
fn button_position_of<R: pdfrum_object::Resolve>(field: &pdfrum_doc::form::Field, r: &R) -> u32 {
const MK: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"MK");
const TP: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"TP");
field
.widgets
.first()
.and_then(|widget| widget.dict.dict(MK, r))
.and_then(|mk| mk.int(TP, r))
.and_then(|value| u32::try_from(value).ok())
.filter(|value| *value <= 6)
.unwrap_or(0)
}
fn display_of<R: pdfrum_object::Resolve>(field: &pdfrum_doc::form::Field, r: &R) -> u32 {
let Some(widget) = field.widgets.first() else {
return 0;
};
let flags = widget.dict.int(pdfrum_object::names::F, r).unwrap_or(0);
let hidden = flags & 0b10 != 0;
let print = flags & 0b100 != 0;
let no_view = flags & 0b10_0000 != 0;
if hidden {
return 1;
}
match (print, no_view) {
(true, false) => 0,
(false, false) => 2,
(_, true) => 3,
}
}
fn caption_of<R: pdfrum_object::Resolve>(field: &pdfrum_doc::form::Field, r: &R) -> [String; 3] {
const MK: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"MK");
const CA: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"CA");
const AC: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"AC");
const RC: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"RC");
let Some(mk) = field
.widgets
.first()
.and_then(|widget| widget.dict.dict(MK, r))
else {
return [String::new(), String::new(), String::new()];
};
let text = |key: &pdfrum_object::Name| -> String {
mk.get(key, r)
.map(|value| value.get().to_text())
.unwrap_or_default()
};
[text(CA), text(AC), text(RC)]
}