use crate::reader_output::{Unshown, UnshownKind};
use crate::util::html::decode_html_entities;
use crate::util::one_line;
use hayro::hayro_syntax::Pdf;
use hayro::hayro_syntax::object::dict::Dict;
use hayro::hayro_syntax::object::{Array, DateTime, Name, Object, String as PdfString};
use std::collections::{HashMap, HashSet};
const MAX_MARKS: usize = 1000;
const MAX_FIELDS: usize = 1000;
const MAX_FIELD_OPTIONS: usize = 100;
const MAX_FIELD_DEPTH: usize = 32;
type DictId = (usize, usize);
fn dict_id(dict: &Dict<'_>) -> DictId {
let data = dict.data();
(data.as_ptr().addr(), data.len())
}
const NO_FILLABLE_FIELDS_NOTE: &str = "the document has a form, but no field that can be filled";
const FIELDS_UNREADABLE_NOTE: &str = "the document has a form, but its fields could not be read";
const ANNOT_FLAG_HIDDEN: i32 = 2;
const ANNOT_FLAG_NO_VIEW: i32 = 32;
const FIELD_FLAG_RADIO: i32 = 1 << 15;
const FIELD_FLAG_PUSH_BUTTON: i32 = 1 << 16;
const FIELD_FLAG_COMBO: i32 = 1 << 17;
#[derive(Debug, Default)]
pub(crate) struct Marks {
pub(crate) description: Option<String>,
pub(crate) annotations: Option<String>,
pub(crate) fields: Option<String>,
pub(crate) notes: Vec<String>,
pub(crate) unshown: Unshown,
}
#[must_use]
pub(crate) fn read(pdf: &Pdf) -> Marks {
crate::shutdown::contain_panics(|| read_inner(pdf)).unwrap_or_default()
}
fn read_inner(pdf: &Pdf) -> Marks {
let mut marks = Marks {
description: read_description(pdf),
..Marks::default()
};
let walk = read_annotations(pdf);
if !walk.lines.is_empty() {
marks.annotations = Some(format!("Annotations:\n{}", walk.lines.join("\n")));
}
marks.unshown = walk.unshown;
if walk.truncated {
marks
.notes
.push(format!("only the first {MAX_MARKS} annotations were read"));
}
let catalog = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id());
let acroform = catalog.and_then(|catalog| catalog.get::<Dict<'_>>(b"AcroForm"));
let mut fields_unreadable = false;
if let Some(acroform) = &acroform {
match read_fields(acroform, &walk.widget_pages) {
Some(fields) => {
if !fields.lines.is_empty() {
marks.fields = Some(format!(
"{}:\n{}",
crate::docgen::pdf_form_words().fields,
fields.lines.join("\n")
));
}
if fields.truncated {
marks
.notes
.push(format!("only the first {MAX_FIELDS} form fields were read"));
}
if fields.options_truncated {
marks.notes.push(format!(
"only the first {MAX_FIELD_OPTIONS} options of a form field were listed"
));
}
}
None => fields_unreadable = true,
}
}
if fields_unreadable {
marks.notes.push(FIELDS_UNREADABLE_NOTE.to_string());
} else if acroform.is_some() && marks.fields.is_none() {
marks.notes.push(NO_FILLABLE_FIELDS_NOTE.to_string());
}
marks
}
fn read_description(pdf: &Pdf) -> Option<String> {
let metadata = pdf.metadata();
let mut entries: Vec<(&str, String)> = Vec::new();
for field in crate::docgen::pdf_metadata_fields() {
let value = match field.key.as_str() {
"created" => metadata.creation_date.map(format_date),
"modified" => metadata.modification_date.map(format_date),
"title" => metadata.title.as_deref().and_then(description_text),
"author" => metadata.author.as_deref().and_then(description_text),
"subject" => metadata.subject.as_deref().and_then(description_text),
"keywords" => metadata.keywords.as_deref().and_then(description_text),
"creator" => metadata.creator.as_deref().and_then(description_text),
"producer" => metadata.producer.as_deref().and_then(description_text),
_ => None,
};
if let Some(value) = value {
entries.push((field.key.as_str(), value));
}
}
(!entries.is_empty()).then(|| {
let body = entries
.into_iter()
.map(|(label, value)| format!("{label}: {value}"))
.collect::<Vec<_>>()
.join("\n");
crate::reader_output::labeled_block("Description", &body)
})
}
fn description_text(bytes: &[u8]) -> Option<String> {
let text = one_line(decode_text_string(bytes).trim().to_string());
(!text.is_empty()).then_some(text)
}
fn format_date(date: DateTime) -> String {
let calendar = format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
date.year, date.month, date.day, date.hour, date.minute, date.second
);
if date.utc_offset_hour == 0 && date.utc_offset_minute == 0 {
format!("{calendar}Z")
} else {
format!(
"{calendar}{:+03}:{:02}",
date.utc_offset_hour, date.utc_offset_minute
)
}
}
#[derive(Default)]
struct AnnotationWalk {
lines: Vec<String>,
widget_pages: HashMap<DictId, usize>,
truncated: bool,
unshown: Unshown,
}
fn read_annotations(pdf: &Pdf) -> AnnotationWalk {
let mut walk = AnnotationWalk::default();
let mut reported: HashSet<DictId> = HashSet::new();
'pages: for (index, page) in pdf.pages().iter().enumerate() {
let Some(annots) = page.raw().get::<Array<'_>>(b"Annots") else {
continue;
};
let mut annots = annots.flex_iter();
while let Some(object) = annots.next::<Object<'_>>() {
let Some(dict) = object.into_dict() else {
continue;
};
if is_widget(&dict) {
walk.widget_pages.entry(dict_id(&dict)).or_insert(index + 1);
}
if !reported.insert(dict_id(&dict)) {
continue;
}
let Some(mark) = mark_of(&dict, index + 1) else {
if !hidden(&dict)
&& let Some(kind) = annotation_content(&dict)
{
walk.unshown.add(kind, 1);
}
continue;
};
if walk.lines.len() == MAX_MARKS {
walk.truncated = true;
break 'pages;
}
walk.lines.push(mark);
}
}
walk
}
fn mark_of(dict: &Dict<'_>, page_number: usize) -> Option<String> {
let kind = mark_kind(dict)?;
if hidden(dict) {
return None;
}
let mut line = match mark_author(dict) {
Some(author) => format!("{kind} ({author}) on page {page_number}"),
None => format!("{kind} on page {page_number}"),
};
if let Some(text) = mark_text(dict) {
line.push(':');
for text_line in text.lines() {
line.push_str("\n ");
line.push_str(text_line);
}
}
Some(line)
}
fn mark_kind(dict: &Dict<'_>) -> Option<&'static str> {
let subtype = dict.get::<Name<'_>>(b"Subtype")?;
match subtype.as_str() {
"Text" => Some("note"),
"FreeText" => Some("free text"),
"Highlight" => Some("highlight"),
"Underline" => Some("underline"),
"Squiggly" => Some("squiggly underline"),
"StrikeOut" => Some("strikeout"),
"Ink" => Some("ink"),
"Square" => Some("square"),
"Circle" => Some("circle"),
"Polygon" => Some("polygon"),
"PolyLine" => Some("polyline"),
"Line" => Some("line"),
"Caret" => Some("caret"),
"Stamp" => Some("stamp"),
"Widget" if is_signature(dict) => Some("signature"),
_ => None,
}
}
fn annotation_content(dict: &Dict<'_>) -> Option<UnshownKind> {
let (content, kind) = match dict.get::<Name<'_>>(b"Subtype")?.as_str() {
"FileAttachment" => (b"FS".as_slice(), UnshownKind::Attachment),
"Sound" => (b"Sound".as_slice(), UnshownKind::MediaAnnotation),
"Movie" => (b"Movie".as_slice(), UnshownKind::MediaAnnotation),
"RichMedia" => (b"RichMediaContent".as_slice(), UnshownKind::MediaAnnotation),
"3D" => (b"3DD".as_slice(), UnshownKind::MediaAnnotation),
"Screen" => return plays_media(dict).then_some(UnshownKind::MediaAnnotation),
_ => return None,
};
dict.contains_key(content).then_some(kind)
}
fn plays_media(dict: &Dict<'_>) -> bool {
if dict.contains_key(b"Movie") {
return true;
}
if dict
.get::<Object<'_>>(b"A")
.is_some_and(|action| plays_media_action(&action))
{
return true;
}
dict.get::<Dict<'_>>(b"AA").is_some_and(|actions| {
actions.keys().any(|trigger| {
actions
.get::<Object<'_>>(trigger)
.is_some_and(|action| plays_media_action(&action))
})
})
}
fn plays_media_action(object: &Object<'_>) -> bool {
match object {
Object::Array(array) => {
let mut actions = array.flex_iter();
while let Some(action) = actions.next::<Object<'_>>() {
if plays_media_action(&action) {
return true;
}
}
false
}
Object::Dict(action) => {
let plays = action
.get::<Name<'_>>(b"S")
.is_some_and(|kind| matches!(kind.as_str(), "Rendition" | "Movie"));
plays
|| action.contains_key(b"R")
|| action.contains_key(b"Movie")
|| action
.get::<Object<'_>>(b"Next")
.is_some_and(|next| plays_media_action(&next))
}
_ => false,
}
}
fn hidden(dict: &Dict<'_>) -> bool {
dict.get::<i32>(b"F")
.is_some_and(|flags| flags & (ANNOT_FLAG_HIDDEN | ANNOT_FLAG_NO_VIEW) != 0)
}
fn is_signature(dict: &Dict<'_>) -> bool {
inherited(dict, b"FT")
.and_then(Object::into_name)
.is_some_and(|name| name.as_str() == "Sig")
}
fn mark_author(dict: &Dict<'_>) -> Option<String> {
if is_signature(dict) {
return None;
}
let author = decode_text_string(dict.get::<PdfString>(b"T")?.as_bytes());
let author = author.trim();
(!author.is_empty()).then(|| one_line(author.to_string()))
}
fn mark_text(dict: &Dict<'_>) -> Option<String> {
let contents = dict
.get::<PdfString>(b"Contents")
.map(|contents| decode_text_string(contents.as_bytes()));
if let Some(contents) = contents {
let contents = contents.trim();
if !contents.is_empty() {
return Some(contents.to_string());
}
}
let rich = dict
.get::<PdfString>(b"RC")
.map(|rich| decode_text_string(rich.as_bytes()))?;
let text = strip_markup(&rich);
let text = text.trim();
(!text.is_empty()).then(|| text.to_string())
}
fn strip_markup(markup: &str) -> String {
let mut text = String::with_capacity(markup.len());
let mut in_tag = false;
for character in markup.chars() {
match character {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => text.push(character),
_ => {}
}
}
decode_html_entities(&text)
}
struct FieldLines {
lines: Vec<String>,
truncated: bool,
options_truncated: bool,
}
fn read_fields(acroform: &Dict<'_>, widget_pages: &HashMap<DictId, usize>) -> Option<FieldLines> {
let fields = acroform.get::<Array<'_>>(b"Fields")?;
let mut walk = FieldWalk {
widget_pages,
lines: Vec::new(),
visited: HashSet::new(),
truncated: false,
options_truncated: false,
};
let mut fields = fields.flex_iter();
while let Some(object) = fields.next::<Object<'_>>() {
let Some(field) = object.into_dict() else {
continue;
};
walk.visit(&field, "", 0);
if walk.truncated {
break;
}
}
Some(FieldLines {
lines: walk.lines,
truncated: walk.truncated,
options_truncated: walk.options_truncated,
})
}
struct FieldWalk<'a> {
widget_pages: &'a HashMap<DictId, usize>,
lines: Vec<String>,
visited: HashSet<DictId>,
truncated: bool,
options_truncated: bool,
}
impl FieldWalk<'_> {
fn visit(&mut self, field: &Dict<'_>, prefix: &str, depth: usize) {
if depth > MAX_FIELD_DEPTH {
return;
}
if !self.visited.insert(dict_id(field)) {
return;
}
let name = field_name(field, prefix);
if !is_non_terminal(field)
&& let Some(kind) = field_kind(field)
{
if self.lines.len() == MAX_FIELDS {
self.truncated = true;
return;
}
let page = field_page(field, self.widget_pages);
let options = field_options(field, kind);
if options.len() > MAX_FIELD_OPTIONS {
self.options_truncated = true;
}
let value = field_value(field, kind, &options);
let fillable = !name.is_empty() && field_is_fillable(field, kind, &options);
self.lines.push(render_field(
&name,
kind,
&options,
page,
value.as_deref(),
fillable,
));
}
let Some(kids) = field.get::<Array<'_>>(b"Kids") else {
return;
};
let mut kids = kids.flex_iter();
while let Some(object) = kids.next::<Object<'_>>() {
let Some(kid) = object.into_dict() else {
continue;
};
if !kid.contains_key(b"T") {
continue;
}
self.visit(&kid, &name, depth + 1);
if self.truncated {
return;
}
}
}
}
fn is_non_terminal(field: &Dict<'_>) -> bool {
let Some(kids) = field.get::<Array<'_>>(b"Kids") else {
return false;
};
let mut kids = kids.flex_iter();
while let Some(object) = kids.next::<Object<'_>>() {
if object.into_dict().is_some_and(|kid| kid.contains_key(b"T")) {
return true;
}
}
false
}
fn field_name(field: &Dict<'_>, prefix: &str) -> String {
let own = field
.get::<PdfString>(b"T")
.map(|name| one_line(decode_text_string(name.as_bytes())))
.filter(|own| !own.is_empty());
match own {
None => prefix.to_string(),
Some(own) if prefix.is_empty() => own,
Some(own) => format!("{prefix}.{own}"),
}
}
#[derive(Clone, Copy)]
enum FieldKind {
Text,
Checkbox,
Radio,
Choice,
}
impl FieldKind {
fn word(self) -> &'static str {
let kinds = crate::docgen::pdf_field_kinds();
match self {
Self::Text => kinds.text.as_str(),
Self::Checkbox => kinds.checkbox.as_str(),
Self::Radio => kinds.radio.as_str(),
Self::Choice => kinds.choice.as_str(),
}
}
}
fn field_kind(field: &Dict<'_>) -> Option<FieldKind> {
match inherited(field, b"FT")?.into_name()?.as_str() {
"Tx" => Some(FieldKind::Text),
"Ch" => Some(FieldKind::Choice),
"Btn" => {
let flags = field_flags(field);
if flags & FIELD_FLAG_PUSH_BUTTON != 0 {
None
} else if flags & FIELD_FLAG_RADIO != 0 {
Some(FieldKind::Radio)
} else {
Some(FieldKind::Checkbox)
}
}
_ => None,
}
}
fn field_flags(field: &Dict<'_>) -> i32 {
inherited(field, b"Ff")
.and_then(Object::into_i32)
.unwrap_or(0)
}
fn field_value(field: &Dict<'_>, kind: FieldKind, options: &[String]) -> Option<String> {
match kind {
FieldKind::Text => text_value(field),
FieldKind::Choice => choice_value(field),
FieldKind::Checkbox => Some(checkbox_value(field)),
FieldKind::Radio => Some(radio_value(field, options)),
}
}
fn field_options(field: &Dict<'_>, kind: FieldKind) -> Vec<String> {
match kind {
FieldKind::Radio => radio_options(field),
FieldKind::Choice => choice_options(field),
FieldKind::Text | FieldKind::Checkbox => Vec::new(),
}
}
fn field_is_fillable(field: &Dict<'_>, kind: FieldKind, options: &[String]) -> bool {
match kind {
FieldKind::Text | FieldKind::Checkbox => true,
FieldKind::Radio => !options.is_empty(),
FieldKind::Choice => !options.is_empty() || field_flags(field) & FIELD_FLAG_COMBO != 0,
}
}
fn text_value(field: &Dict<'_>) -> Option<String> {
let value = inherited(field, b"V")?.into_string()?;
let text = decode_text_string(value.as_bytes());
let text = text.trim();
(!text.is_empty()).then(|| one_line(text.to_string()))
}
fn checkbox_value(field: &Dict<'_>) -> String {
let selected = selected_states(field);
let checked = selected.iter().any(|state| state != "Off");
let mut value = checked_word(checked);
if !checked {
return value;
}
let states = on_states(field);
if states.len() > 1
&& let Some(state) = selected.iter().find(|state| *state != "Off")
&& !is_bare_number(state)
{
value = format!("{value} ({state})");
}
value
}
fn radio_value(field: &Dict<'_>, options: &[String]) -> String {
let selected = selected_states(field);
let checked = selected.iter().any(|state| state != "Off");
let mut value = checked_word(checked);
if !checked {
return value;
}
let states = on_states(field);
if (options.len() > 1 || states.len() > 1)
&& let Some(state) = selected.iter().find(|state| *state != "Off")
&& let Some(name) = radio_fill_value(state, options, &states)
{
value = format!("{value} ({name})");
}
value
}
fn checked_word(checked: bool) -> String {
if checked { "checked" } else { "not checked" }.to_string()
}
fn radio_fill_value(state: &str, options: &[String], states: &[String]) -> Option<String> {
if let Some(option) = options.iter().find(|option| *option == state) {
return Some(option.clone());
}
let position = states.iter().position(|known| known == state)?;
options.get(position).cloned()
}
fn choice_value(field: &Dict<'_>) -> Option<String> {
let mut selected = selected_strings(field);
selected.retain(|value| !value.trim().is_empty());
if selected.is_empty() {
return None;
}
let options = option_entries(field);
let names = selected
.iter()
.map(|value| {
options
.iter()
.find(|option| option.export == *value || option.display == *value)
.map_or_else(|| value.clone(), |option| option.display.clone())
})
.collect::<Vec<_>>();
Some(one_line(names.join(", ")))
}
fn selected_states(field: &Dict<'_>) -> Vec<String> {
match inherited(field, b"V") {
Some(Object::Name(name)) => vec![name.as_str().to_string()],
Some(Object::Array(array)) => name_strings(&array),
_ => first_widget_state(field).into_iter().collect(),
}
}
fn selected_strings(field: &Dict<'_>) -> Vec<String> {
match inherited(field, b"V") {
Some(Object::String(value)) => vec![decode_text_string(value.as_bytes())],
Some(Object::Array(array)) => string_items(&array),
_ => Vec::new(),
}
}
fn name_strings(array: &Array<'_>) -> Vec<String> {
let mut names = Vec::new();
let mut items = array.flex_iter();
while let Some(object) = items.next::<Object<'_>>() {
if let Object::Name(name) = object {
names.push(name.as_str().to_string());
}
}
names
}
fn string_items(array: &Array<'_>) -> Vec<String> {
let mut items = Vec::new();
let mut strings = array.flex_iter();
while let Some(object) = strings.next::<Object<'_>>() {
if let Object::String(text) = object {
items.push(decode_text_string(text.as_bytes()));
}
}
items
}
fn first_widget_state(field: &Dict<'_>) -> Option<String> {
field_widget(field)?
.get::<Name<'_>>(b"AS")
.map(|state| state.as_str().to_string())
}
fn field_widget<'a>(field: &Dict<'a>) -> Option<Dict<'a>> {
if is_widget(field) {
Some(field.clone())
} else {
widget_kids(field).into_iter().next()
}
}
fn on_states(field: &Dict<'_>) -> Vec<String> {
let mut states = Vec::new();
let widgets = widget_kids(field);
if widgets.is_empty() {
collect_on_states(field, &mut states);
} else {
for widget in &widgets {
collect_on_states(widget, &mut states);
}
}
states
}
fn collect_on_states(dict: &Dict<'_>, states: &mut Vec<String>) {
let Some(appearance) = dict
.get::<Dict<'_>>(b"AP")
.and_then(|appearance| appearance.get::<Dict<'_>>(b"N"))
else {
return;
};
for name in appearance.keys() {
let state = name.as_str();
if state != "Off" && !states.iter().any(|known| known == state) {
states.push(state.to_string());
}
}
}
struct OptionEntry {
export: String,
display: String,
}
fn option_entries(field: &Dict<'_>) -> Vec<OptionEntry> {
let mut entries = Vec::new();
let mut options = match field.get::<Object<'_>>(b"Opt") {
Some(Object::String(text)) => {
let text = decode_text_string(text.as_bytes());
entries.push(OptionEntry {
export: text.clone(),
display: text,
});
return entries;
}
Some(Object::Array(array)) => array.flex_iter(),
_ => return entries,
};
while let Some(object) = options.next::<Object<'_>>() {
match object {
Object::String(text) => {
let text = decode_text_string(text.as_bytes());
entries.push(OptionEntry {
export: text.clone(),
display: text,
});
}
Object::Array(pair) => {
let parts = string_items(&pair);
match parts.len() {
0 => {}
1 => entries.push(OptionEntry {
export: parts[0].clone(),
display: parts[0].clone(),
}),
_ => entries.push(OptionEntry {
export: parts[0].clone(),
display: parts[1].clone(),
}),
}
}
_ => {}
}
}
entries
}
fn radio_options(field: &Dict<'_>) -> Vec<String> {
match field.get::<Object<'_>>(b"Opt") {
Some(Object::String(text)) => vec![one_line(decode_text_string(text.as_bytes()))],
Some(Object::Array(array)) => string_items(&array)
.into_iter()
.map(one_line)
.collect::<Vec<_>>(),
Some(_) => Vec::new(),
None => on_states(field),
}
}
fn choice_options(field: &Dict<'_>) -> Vec<String> {
option_entries(field)
.into_iter()
.map(|option| one_line(option.display))
.collect()
}
fn is_bare_number(state: &str) -> bool {
!state.is_empty() && state.bytes().all(|byte| byte.is_ascii_digit())
}
fn field_page(field: &Dict<'_>, widget_pages: &HashMap<DictId, usize>) -> Option<usize> {
widget_pages.get(&dict_id(&field_widget(field)?)).copied()
}
fn widget_kids<'a>(field: &Dict<'a>) -> Vec<Dict<'a>> {
let Some(kids) = field.get::<Array<'a>>(b"Kids") else {
return Vec::new();
};
let mut widgets = Vec::new();
let mut kids = kids.flex_iter();
while let Some(object) = kids.next::<Object<'a>>() {
if let Some(kid) = object.into_dict()
&& is_widget(&kid)
{
widgets.push(kid);
}
}
widgets
}
fn is_widget(dict: &Dict<'_>) -> bool {
matches!(dict.get::<Name<'_>>(b"Subtype").as_deref(), Some(b"Widget"))
}
fn inherited<'a>(field: &Dict<'a>, key: &[u8]) -> Option<Object<'a>> {
let mut current = field.clone();
for _ in 0..MAX_FIELD_DEPTH {
if let Some(value) = current.get::<Object<'a>>(key) {
return Some(value);
}
current = current.get::<Dict<'a>>(b"Parent")?;
}
None
}
fn render_field(
name: &str,
kind: FieldKind,
options: &[String],
page: Option<usize>,
value: Option<&str>,
fillable: bool,
) -> String {
let words = crate::docgen::pdf_form_words();
let mut label = kind.word().to_string();
if !options.is_empty() {
label.push_str("; options: ");
let shown = &options[..options.len().min(MAX_FIELD_OPTIONS)];
label.push_str(&shown.join(", "));
if options.len() > MAX_FIELD_OPTIONS {
label.push_str(", …");
}
}
if !fillable {
label.push_str("; ");
label.push_str(&words.not_fillable);
}
let name = if name.is_empty() {
words.unnamed.as_str()
} else {
name
};
let value = value.unwrap_or(words.no_value.as_str());
match page {
Some(page) => format!("{name} ({label}) on page {page}: {value}"),
None => format!("{name} ({label}): {value}"),
}
}
fn decode_text_string(bytes: &[u8]) -> String {
if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
let units = rest
.as_chunks::<2>()
.0
.iter()
.map(|pair| u16::from_be_bytes(*pair));
return String::from_utf16_lossy(&units.collect::<Vec<_>>());
}
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
return String::from_utf8_lossy(rest).into_owned();
}
bytes.iter().copied().map(pdf_doc_encoding).collect()
}
const PDF_DOC_ENCODING_ACCENTS: [char; 8] = [
'\u{02D8}', '\u{02C7}', '\u{02C6}', '\u{02D9}', '\u{02DD}', '\u{02DB}', '\u{02DA}', '\u{02DC}',
];
const PDF_DOC_ENCODING_SPECIAL: [char; 33] = [
'\u{2022}', '\u{2020}', '\u{2021}', '\u{2026}', '\u{2014}', '\u{2013}', '\u{0192}', '\u{2044}',
'\u{2039}', '\u{203A}', '\u{2212}', '\u{2030}', '\u{201E}', '\u{201C}', '\u{201D}', '\u{2018}',
'\u{2019}', '\u{201A}', '\u{2122}', '\u{FB01}', '\u{FB02}', '\u{0141}', '\u{0152}', '\u{0160}',
'\u{0178}', '\u{017D}', '\u{0131}', '\u{0142}', '\u{0153}', '\u{0161}', '\u{017E}', '\u{FFFD}',
'\u{20AC}',
];
fn pdf_doc_encoding(byte: u8) -> char {
match byte {
0x18..=0x1F => PDF_DOC_ENCODING_ACCENTS[usize::from(byte - 0x18)],
0x80..=0xA0 => PDF_DOC_ENCODING_SPECIAL[usize::from(byte - 0x80)],
_ => char::from(byte),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::test_fixtures::{PdfFixture, pdf_text_string};
fn pdf_with(build: impl FnOnce(&mut PdfFixture) -> (Vec<Vec<String>>, String)) -> Vec<u8> {
pdf_with_trailer(|fixture| {
let (pages, catalog_extra) = build(fixture);
(pages, catalog_extra, String::new())
})
}
fn pdf_with_trailer(
build: impl FnOnce(&mut PdfFixture) -> (Vec<Vec<String>>, String, String),
) -> Vec<u8> {
let mut fixture = PdfFixture::new();
let (pages, catalog_extra, trailer_extra) = build(&mut fixture);
let page_refs: Vec<String> = pages.iter().map(|_| fixture.reserve()).collect();
let pages_ref = fixture.push(format!(
"<< /Type /Pages /Kids [{}] /Count {} >>",
page_refs.join(" "),
page_refs.len()
));
for (page_ref, annotations) in page_refs.iter().zip(&pages) {
let annots = if annotations.is_empty() {
String::new()
} else {
format!(" /Annots [{}]", annotations.join(" "))
};
fixture.set(
page_ref,
format!("<< /Type /Page /Parent {pages_ref} /MediaBox [0 0 612 792]{annots} >>"),
);
}
fixture.build_with_trailer(
&format!("/Pages {pages_ref}{catalog_extra}"),
&trailer_extra,
)
}
fn read_pdf(bytes: Vec<u8>) -> Marks {
let pdf = Pdf::new(bytes).expect("fixture PDF must parse");
read(&pdf)
}
#[test]
fn every_readable_mark_kind_is_reported_on_its_page() {
let marks = read_pdf(pdf_with(|fixture| {
let note = fixture.push("<< /Subtype /Text /Contents (please verify) >>".to_string());
let highlight = fixture.push(format!(
"<< /Subtype /Highlight /T {} /Contents {} >>",
pdf_text_string("Иван Петров"),
pdf_text_string("проверьте третью фигуру")
));
let stamp = fixture.push("<< /Subtype /Stamp >>".to_string());
let square = fixture.push("<< /Subtype /Square >>".to_string());
let free_text =
fixture.push("<< /Subtype /FreeText /RC (<body>Approved</body>) >>".to_string());
(
vec![
vec![note],
vec![highlight],
vec![stamp],
vec![square],
vec![free_text],
],
String::new(),
)
}));
assert_eq!(
marks.annotations.as_deref(),
Some(
"Annotations:\nnote on page 1:\n please verify\n\
highlight (Иван Петров) on page 2:\n проверьте третью фигуру\n\
stamp on page 3\nsquare on page 4\n\
free text on page 5:\n Approved"
)
);
assert!(marks.fields.is_none());
assert!(marks.notes.is_empty());
}
#[test]
fn service_only_annotations_are_not_marks() {
let marks = read_pdf(pdf_with(|fixture| {
let link = fixture.push("<< /Subtype /Link /Rect [0 0 0 0] >>".to_string());
let popup = fixture.push("<< /Subtype /Popup /Rect [0 0 0 0] >>".to_string());
let widget = fixture.push("<< /Subtype /Widget /FT /Tx /T (field) >>".to_string());
let hidden = fixture.push("<< /Subtype /Text /Contents (hidden) /F 2 >>".to_string());
let no_view = fixture.push("<< /Subtype /Text /Contents (noview) /F 32 >>".to_string());
let visible = fixture.push("<< /Subtype /Text /Contents (visible) >>".to_string());
(
vec![vec![link, popup, widget, hidden, no_view, visible]],
String::new(),
)
}));
assert_eq!(
marks.annotations.as_deref(),
Some("Annotations:\nnote on page 1:\n visible")
);
assert!(
marks.notes.is_empty(),
"a widget without an /AcroForm is no unreadable form: {marks:?}"
);
}
#[test]
fn marks_report_attachments_and_media_they_do_not_show() {
let marks = read_pdf(pdf_with(|fixture| {
let attachment = fixture.push(
"<< /Subtype /FileAttachment /FS << /Type /Filespec /F (notes.txt) >> >>"
.to_string(),
);
let sound = fixture.push("<< /Subtype /Sound /Sound << >> >>".to_string());
let empty_screen = fixture.push("<< /Subtype /Screen >>".to_string());
let screen = fixture.push("<< /Subtype /Screen /A << /S /Rendition >> >>".to_string());
let navigating =
fixture.push("<< /Subtype /Screen /A << /S /GoTo /D [0 0 0 0] >> >>".to_string());
let playing =
fixture.push("<< /Subtype /Screen /AA << /PO << /S /Movie >> >> >>".to_string());
let note = fixture.push("<< /Subtype /Text /Contents (please verify) >>".to_string());
let hidden_movie = fixture.push("<< /Subtype /Movie /F 2 /Movie << >> >>".to_string());
(
vec![vec![
attachment.clone(),
attachment,
sound,
empty_screen,
screen,
navigating,
playing,
note,
hidden_movie,
]],
String::new(),
)
}));
assert_eq!(
marks.unshown.lines(),
[
"1 attached file(s) not shown",
"3 media annotation(s) not shown",
]
);
assert_eq!(
marks.annotations.as_deref(),
Some("Annotations:\nnote on page 1:\n please verify")
);
assert!(marks.notes.is_empty());
}
#[test]
fn a_markups_popup_child_does_not_add_a_second_entry() {
let marks = read_pdf(pdf_with(|fixture| {
let markup = fixture.reserve();
let popup = fixture.push(format!(
"<< /Subtype /Popup /Parent {markup} /Rect [0 0 0 0] >>"
));
fixture.set(
&markup,
format!("<< /Subtype /Highlight /Contents (marked) /Popup {popup} >>"),
);
(vec![vec![markup, popup]], String::new())
}));
assert_eq!(
marks.annotations.as_deref(),
Some("Annotations:\nhighlight on page 1:\n marked")
);
}
#[test]
fn an_annotation_listed_on_two_pages_is_reported_once() {
let marks = read_pdf(pdf_with(|fixture| {
let note = fixture.push("<< /Subtype /Text /Contents (shared) >>".to_string());
(vec![vec![note.clone()], vec![note]], String::new())
}));
assert_eq!(
marks.annotations.as_deref(),
Some("Annotations:\nnote on page 1:\n shared")
);
}
#[test]
fn filled_field_values_are_reported_by_kind_and_page() {
let marks = read_pdf(pdf_with(|fixture| {
let appearance =
fixture.push("<< /Type /XObject /Subtype /Form /BBox [0 0 1 1] >>".to_string());
let name = fixture.push(format!(
"<< /Subtype /Widget /FT /Tx /T (Full name) /V {} >>",
pdf_text_string("Иван Петров")
));
let subscribe = fixture.push(format!(
"<< /Subtype /Widget /FT /Btn /T (Subscribe) /V /Yes \
/AP << /N << /Yes {appearance} /Off {appearance} >> >> /AS /Yes >>"
));
let unsubscribe = fixture.push(format!(
"<< /Subtype /Widget /FT /Btn /T (Unsubscribe) /V /Off \
/AP << /N << /Yes {appearance} /Off {appearance} >> >> /AS /Off >>"
));
let empty = fixture.push("<< /Subtype /Widget /FT /Tx /T (Empty) >>".to_string());
let delivery = fixture.reserve();
let first = fixture.push(format!(
"<< /Subtype /Widget /Parent {delivery} \
/AP << /N << /exp {appearance} /Off {appearance} >> >> /AS /Off >>"
));
let second = fixture.push(format!(
"<< /Subtype /Widget /Parent {delivery} \
/AP << /N << /std {appearance} /Off {appearance} >> >> /AS /std >>"
));
fixture.set(
&delivery,
format!(
"<< /FT /Btn /T (Delivery) /V /std /Ff 32768 \
/Opt [[(exp) (Express)] [(std) (Standard)]] /Kids [{first} {second}] >>"
),
);
let city = fixture.push(
"<< /Subtype /Widget /FT /Ch /T (City) /V (spb) \
/Opt [[(msk) (Moscow)] [(spb) (Saint Petersburg)]] >>"
.to_string(),
);
let computed = fixture.push("<< /FT /Tx /T (Computed) /V (42) >>".to_string());
let note = fixture.push("<< /FT /Tx /T (Note) /V <74776f0a6c696e6573> >>".to_string());
let acroform = fixture.push(format!(
"<< /Fields [{name} {subscribe} {unsubscribe} {delivery} {city} {computed} {note} \
{empty}] >>"
));
(
vec![
vec![name, subscribe, unsubscribe, empty, first, second],
vec![city],
],
format!(" /AcroForm {acroform}"),
)
}));
assert_eq!(
marks.fields.as_deref(),
Some(
"Form fields:\nFull name (text) on page 1: Иван Петров\n\
Subscribe (checkbox) on page 1: checked\n\
Unsubscribe (checkbox) on page 1: not checked\n\
Delivery (radio; not fillable) on page 1: checked\n\
City (choice; options: Moscow, Saint Petersburg) on page 2: Saint Petersburg\n\
Computed (text): 42\nNote (text): two\\nlines\n\
Empty (text) on page 1: (no value)"
)
);
assert!(marks.notes.is_empty());
}
#[test]
fn a_multi_state_checkbox_names_the_state_it_is_in() {
let marks = read_pdf(pdf_with(|fixture| {
let appearance =
fixture.push("<< /Type /XObject /Subtype /Form /BBox [0 0 1 1] >>".to_string());
let answer = fixture.push(format!(
"<< /Subtype /Widget /FT /Btn /T (Answer) /V /No \
/AP << /N << /Yes {appearance} /No {appearance} /Off {appearance} >> >> >>"
));
let acroform = fixture.push(format!("<< /Fields [{answer}] >>"));
(vec![vec![answer]], format!(" /AcroForm {acroform}"))
}));
assert_eq!(
marks.fields.as_deref(),
Some("Form fields:\nAnswer (checkbox) on page 1: checked (No)")
);
}
#[test]
fn a_radio_with_pair_form_options_is_not_fillable_and_prints_no_state() {
let marks = read_pdf(pdf_with(|fixture| {
let appearance =
fixture.push("<< /Type /XObject /Subtype /Form /BBox [0 0 1 1] >>".to_string());
let group = fixture.reserve();
let first = fixture.push(format!(
"<< /Subtype /Widget /Parent {group} \
/AP << /N << /exp {appearance} /Off {appearance} >> >> /AS /Off >>"
));
let second = fixture.push(format!(
"<< /Subtype /Widget /Parent {group} \
/AP << /N << /std {appearance} /Off {appearance} >> >> /AS /std >>"
));
fixture.set(
&group,
format!(
"<< /FT /Btn /T (Group) /V /std /Ff 32768 \
/Opt [[(exp) (Express)] [(std) (Standard)]] /Kids [{first} {second}] >>"
),
);
let acroform = fixture.push(format!("<< /Fields [{group}] >>"));
(vec![vec![first, second]], format!(" /AcroForm {acroform}"))
}));
assert_eq!(
marks.fields.as_deref(),
Some("Form fields:\nGroup (radio; not fillable) on page 1: checked")
);
}
#[test]
fn a_choice_with_more_options_than_the_cap_lists_the_first_ones() {
let options = (0..=MAX_FIELD_OPTIONS)
.map(|index| format!("[(o{index}) (Option {index})]"))
.collect::<Vec<_>>()
.join(" ");
let marks = read_pdf(pdf_with(|fixture| {
let field = fixture.push(format!(
"<< /Subtype /Widget /FT /Ch /T (Long) /V () /Opt [{options}] >>"
));
let acroform = fixture.push(format!("<< /Fields [{field}] >>"));
(vec![vec![field]], format!(" /AcroForm {acroform}"))
}));
let listed = (0..MAX_FIELD_OPTIONS)
.map(|index| format!("Option {index}"))
.collect::<Vec<_>>()
.join(", ");
let expected =
format!("Form fields:\nLong (choice; options: {listed}, …) on page 1: (no value)");
assert_eq!(marks.fields.as_deref(), Some(expected.as_str()));
assert_eq!(
marks.notes,
vec![format!(
"only the first {MAX_FIELD_OPTIONS} options of a form field were listed"
)]
);
}
#[test]
fn a_signature_widget_is_a_mark_and_not_a_field_value() {
let marks = read_pdf(pdf_with(|fixture| {
let signature = fixture
.push("<< /Subtype /Widget /FT /Sig /T (Approval) /Rect [0 0 0 0] >>".to_string());
let acroform = fixture.push(format!("<< /Fields [{signature}] >>"));
(vec![vec![signature]], format!(" /AcroForm {acroform}"))
}));
assert_eq!(
marks.annotations.as_deref(),
Some("Annotations:\nsignature on page 1")
);
assert!(marks.fields.is_none());
assert_eq!(marks.notes, vec![NO_FILLABLE_FIELDS_NOTE.to_string()]);
}
#[test]
fn a_form_without_values_lists_every_field() {
let marks = read_pdf(pdf_with(|fixture| {
let first = fixture.push("<< /Subtype /Widget /FT /Tx /T (One) >>".to_string());
let second = fixture.push("<< /Subtype /Widget /FT /Tx /T (Two) >>".to_string());
let acroform = fixture.push(format!("<< /Fields [{first} {second}] >>"));
(vec![vec![first, second]], format!(" /AcroForm {acroform}"))
}));
assert!(marks.annotations.is_none());
assert_eq!(
marks.fields.as_deref(),
Some(
"Form fields:\nOne (text) on page 1: (no value)\n\
Two (text) on page 1: (no value)"
)
);
assert!(marks.notes.is_empty());
}
#[test]
fn an_acroform_without_readable_fields_reports_that() {
let marks = read_pdf(pdf_with(|fixture| {
let acroform = fixture.push("<< /X 1 >>".to_string());
(vec![vec![]], format!(" /AcroForm {acroform}"))
}));
assert!(marks.fields.is_none());
assert_eq!(marks.notes, vec![FIELDS_UNREADABLE_NOTE.to_string()]);
}
#[test]
fn a_field_tree_that_points_at_an_ancestor_terminates() {
let marks = read_pdf(pdf_with(|fixture| {
let parent = fixture.reserve();
let child = fixture.reserve();
fixture.set(
&parent,
format!("<< /FT /Tx /T (A) /V (alpha) /Kids [{child}] >>"),
);
fixture.set(
&child,
format!("<< /FT /Tx /T (B) /V (beta) /Kids [{parent}] /Parent {parent} >>"),
);
let acroform = fixture.push(format!("<< /Fields [{parent}] >>"));
(vec![vec![]], format!(" /AcroForm {acroform}"))
}));
assert_eq!(
marks.fields.as_deref(),
Some("Form fields:\nA.B (text): beta")
);
}
#[test]
fn unknown_array_entries_do_not_end_the_walk() {
let marks = read_pdf(pdf_with(|fixture| {
let named_contents =
fixture.push("<< /Subtype /Stamp /Contents /NotAString >>".to_string());
let note = fixture.push("<< /Subtype /Text /Contents (kept) >>".to_string());
let field = fixture.push("<< /FT /Tx /T (Name) /V (value) >>".to_string());
let acroform = fixture.push(format!("<< /Fields [42 {field}] >>"));
(
vec![vec![
named_contents,
"7".to_string(),
"/Foo".to_string(),
note,
]],
format!(" /AcroForm {acroform}"),
)
}));
assert_eq!(
marks.annotations.as_deref(),
Some("Annotations:\nstamp on page 1\nnote on page 1:\n kept")
);
assert_eq!(
marks.fields.as_deref(),
Some("Form fields:\nName (text): value")
);
}
#[test]
fn direct_dictionaries_are_read_as_their_own_marks_and_fields() {
let marks = read_pdf(pdf_with(|_fixture| {
(
vec![vec![
"<< /Subtype /Text /Contents (first) >>".to_string(),
"<< /Subtype /Text /Contents (second) >>".to_string(),
]],
" /AcroForm << /Fields [<< /FT /Tx /T (One) /V (1) >> \
<< /FT /Tx /T (Two) /V (2) >> << /FT /Tx /V (unnamed) >>] >>"
.to_string(),
)
}));
assert_eq!(
marks.annotations.as_deref(),
Some("Annotations:\nnote on page 1:\n first\nnote on page 1:\n second")
);
assert_eq!(
marks.fields.as_deref(),
Some(
"Form fields:\nOne (text): 1\nTwo (text): 2\n\
unnamed field (text; not fillable): unnamed"
)
);
}
#[test]
fn a_signature_field_split_from_its_widget_is_still_a_mark() {
let marks = read_pdf(pdf_with(|fixture| {
let field = fixture.reserve();
let widget = fixture.push(format!(
"<< /Subtype /Widget /Parent {field} /Rect [0 0 0 0] >>"
));
fixture.set(
&field,
format!("<< /FT /Sig /T (Approval) /Kids [{widget}] >>"),
);
let acroform = fixture.push(format!("<< /Fields [{field}] >>"));
(vec![vec![widget]], format!(" /AcroForm {acroform}"))
}));
assert_eq!(
marks.annotations.as_deref(),
Some("Annotations:\nsignature on page 1")
);
assert!(marks.fields.is_none());
assert_eq!(marks.notes, vec![NO_FILLABLE_FIELDS_NOTE.to_string()]);
}
#[test]
fn self_contained_widget_kids_are_read_as_their_own_fields() {
let marks = read_pdf(pdf_with(|fixture| {
let appearance =
fixture.push("<< /Type /XObject /Subtype /Form /BBox [0 0 1 1] >>".to_string());
let group = fixture.reserve();
let first = fixture.push(format!(
"<< /Subtype /Widget /Parent {group} /FT /Tx /T (First) /V (one) \
/Rect [0 0 0 0] >>"
));
let plan = fixture.push(format!(
"<< /Subtype /Widget /FT /Btn /T (Plan) /V /1 /Opt [[(one) (One)] [(two) (Two)]] \
/AP << /N << /1 {appearance} /2 {appearance} /Off {appearance} >> >> /AS /1 >>"
));
fixture.set(&group, format!("<< /T (Group) /Kids [{first}] >>"));
let acroform = fixture.push(format!("<< /Fields [{group} {plan}] >>"));
(vec![vec![first, plan]], format!(" /AcroForm {acroform}"))
}));
assert_eq!(
marks.fields.as_deref(),
Some(
"Form fields:\nGroup.First (text) on page 1: one\n\
Plan (checkbox) on page 1: checked"
)
);
}
#[test]
fn a_documents_description_shows_only_what_it_carries() {
let marks = read_pdf(pdf_with_trailer(|fixture| {
let info = fixture.push(format!(
"<< /Title {} /Author {} /Subject {} /Keywords {} /Creator {} /Producer {} \
/CreationDate (D:20240102030405+03'30') /ModDate (D:20230607080910Z) >>",
pdf_text_string("Annual report"),
pdf_text_string("Иван Петров"),
pdf_text_string("Figures and notes"),
pdf_text_string("report, figures"),
pdf_text_string("Writer"),
pdf_text_string("MahBot"),
));
(vec![vec![]], String::new(), format!(" /Info {info}"))
}));
let description = marks
.description
.as_deref()
.expect("the file carries a description");
assert_eq!(
description,
"Description:\n title: Annual report\n author: Иван Петров\n \
subject: Figures and notes\n keywords: report, figures\n \
creator: Writer\n producer: MahBot\n \
created: 2024-01-02T03:04:05+03:30\n modified: 2023-06-07T08:09:10Z"
);
for field in crate::docgen::pdf_metadata_fields() {
assert!(
description.contains(&format!("{}: ", field.key)),
"the description must print every rules field, missing {:?}: {description}",
field.key
);
}
}
#[test]
fn a_blank_form_lists_every_fillable_field() {
let marks = read_pdf(pdf_with(|fixture| {
let appearance =
fixture.push("<< /Type /XObject /Subtype /Form /BBox [0 0 1 1] >>".to_string());
let name = fixture.push("<< /Subtype /Widget /FT /Tx /T (Name) >>".to_string());
let agree =
fixture.push("<< /Subtype /Widget /FT /Btn /T (Agree) /V /Off >>".to_string());
let plan = fixture.reserve();
let basic = fixture.push(format!(
"<< /Subtype /Widget /Parent {plan} \
/AP << /N << /basic {appearance} /Off {appearance} >> >> /AS /Off >>"
));
let pro = fixture.push(format!(
"<< /Subtype /Widget /Parent {plan} \
/AP << /N << /pro {appearance} /Off {appearance} >> >> /AS /Off >>"
));
fixture.set(
&plan,
format!("<< /FT /Btn /Ff 32768 /T (Plan) /V /Off /Kids [{basic} {pro}] >>"),
);
let city = fixture.push(
"<< /Subtype /Widget /FT /Ch /T (City) \
/Opt [[(msk) (Moscow)] [(spb) (Saint Petersburg)]] >>"
.to_string(),
);
let acroform = fixture.push(format!("<< /Fields [{name} {agree} {plan} {city}] >>"));
(
vec![vec![name, agree, basic, pro, city]],
format!(" /AcroForm {acroform}"),
)
}));
assert_eq!(
marks.fields.as_deref(),
Some(
"Form fields:\nName (text) on page 1: (no value)\n\
Agree (checkbox) on page 1: not checked\n\
Plan (radio; options: basic, pro) on page 1: not checked\n\
City (choice; options: Moscow, Saint Petersburg) on page 1: (no value)"
)
);
assert!(marks.notes.is_empty());
}
#[test]
fn a_pushbutton_is_not_a_field_and_a_signature_stays_a_mark() {
let marks = read_pdf(pdf_with(|fixture| {
let button = fixture.push(
"<< /Subtype /Widget /FT /Btn /Ff 65536 /T (Submit) /Rect [0 0 0 0] >>".to_string(),
);
let signature = fixture
.push("<< /Subtype /Widget /FT /Sig /T (Approval) /Rect [0 0 0 0] >>".to_string());
let acroform = fixture.push(format!("<< /Fields [{button} {signature}] >>"));
(
vec![vec![button, signature]],
format!(" /AcroForm {acroform}"),
)
}));
assert_eq!(
marks.annotations.as_deref(),
Some("Annotations:\nsignature on page 1")
);
assert!(marks.fields.is_none());
assert_eq!(marks.notes, vec![NO_FILLABLE_FIELDS_NOTE.to_string()]);
}
#[test]
fn a_non_terminal_field_is_not_listed_but_its_kids_are() {
let marks = read_pdf(pdf_with(|fixture| {
let group = fixture.reserve();
let child = fixture.push(format!(
"<< /Subtype /Widget /Parent {group} /FT /Tx /T (Child) /V (value) >>"
));
fixture.set(&group, format!("<< /FT /Tx /T (Group) /Kids [{child}] >>"));
let acroform = fixture.push(format!("<< /Fields [{group}] >>"));
(vec![vec![child]], format!(" /AcroForm {acroform}"))
}));
assert_eq!(
marks.fields.as_deref(),
Some("Form fields:\nGroup.Child (text) on page 1: value")
);
}
#[test]
fn an_unnamed_field_is_listed_and_marked_not_fillable() {
let marks = read_pdf(pdf_with(|fixture| {
let field = fixture.push("<< /Subtype /Widget /FT /Tx /V (secret) >>".to_string());
let acroform = fixture.push(format!("<< /Fields [{field}] >>"));
(vec![vec![field]], format!(" /AcroForm {acroform}"))
}));
assert_eq!(
marks.fields.as_deref(),
Some("Form fields:\nunnamed field (text; not fillable) on page 1: secret")
);
}
}