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, Name, Object, String as PdfString};
use std::collections::{HashMap, HashSet};
const MAX_MARKS: usize = 1000;
const MAX_FIELDS: usize = 1000;
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_VALUE_NOTE: &str =
"the document has a form, but no filled-in value could be read from it (the form may be empty)";
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_PUSH_BUTTON: i32 = 1 << 16;
#[derive(Debug, Default)]
pub(crate) struct Marks {
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::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 has_fields = false;
let mut fields_unreadable = false;
if let Some(acroform) = &acroform {
match read_fields(acroform, &walk.widget_pages) {
Some((lines, truncated)) => {
has_fields = true;
if !lines.is_empty() {
marks.fields = Some(format!("Form fields:\n{}", lines.join("\n")));
}
if truncated {
marks
.notes
.push(format!("only the first {MAX_FIELDS} form fields were read"));
}
}
None => fields_unreadable = true,
}
}
if fields_unreadable {
marks.notes.push(FIELDS_UNREADABLE_NOTE.to_string());
} else if marks.fields.is_none() && (has_fields || !walk.widget_pages.is_empty()) {
marks.notes.push(NO_VALUE_NOTE.to_string());
}
marks
}
#[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)
}
fn read_fields(
acroform: &Dict<'_>,
widget_pages: &HashMap<DictId, usize>,
) -> Option<(Vec<String>, bool)> {
let fields = acroform.get::<Array<'_>>(b"Fields")?;
let mut walk = FieldWalk {
widget_pages,
lines: Vec::new(),
visited: HashSet::new(),
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((walk.lines, walk.truncated))
}
struct FieldWalk<'a> {
widget_pages: &'a HashMap<DictId, usize>,
lines: Vec<String>,
visited: HashSet<DictId>,
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 let Some(value) = field_value(field) {
if self.lines.len() == MAX_FIELDS {
self.truncated = true;
return;
}
let page = field_page(field, self.widget_pages);
self.lines.push(render_field(&name, page, &value));
}
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 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}"),
}
}
fn field_value(field: &Dict<'_>) -> Option<String> {
match inherited(field, b"FT")?.into_name()?.as_str() {
"Tx" => text_value(field),
"Btn" => button_value(field),
"Ch" => choice_value(field),
_ => None,
}
}
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 button_value(field: &Dict<'_>) -> Option<String> {
let flags = inherited(field, b"Ff")
.and_then(Object::into_i32)
.unwrap_or(0);
if flags & FIELD_FLAG_PUSH_BUTTON != 0 {
return None;
}
let selected = selected_states(field);
let checked = selected.iter().any(|state| state != "Off");
let mut value = if checked { "checked" } else { "not checked" }.to_string();
let options = option_entries(field);
let states = on_states(field);
if checked && (states.len() >= 2 || options.len() >= 2) {
let names = chosen_names(&selected, &options);
if !names.is_empty() {
value = format!("{value} ({})", names.join(", "));
}
}
Some(value)
}
fn choice_value(field: &Dict<'_>) -> Option<String> {
let selected = selected_strings(field);
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> {
let widget = if is_widget(field) {
field.clone()
} else {
widget_kids(field).into_iter().next()?
};
widget
.get::<Name<'_>>(b"AS")
.map(|state| state.as_str().to_string())
}
fn on_states(field: &Dict<'_>) -> Vec<String> {
let mut states = Vec::new();
collect_on_states(field, &mut states);
for kid in widget_kids(field) {
collect_on_states(&kid, &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 Some(options) = field.get::<Array<'_>>(b"Opt") else {
return Vec::new();
};
let mut entries = Vec::new();
let mut options = options.flex_iter();
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 chosen_names(selected: &[String], options: &[OptionEntry]) -> Vec<String> {
let mut names = Vec::new();
for state in selected {
if state == "Off" {
continue;
}
let display = if options.is_empty() {
(!is_bare_number(state)).then(|| state.clone())
} else {
options
.iter()
.find(|option| option.export == *state)
.map(|option| option.display.clone())
};
if let Some(display) = display {
names.push(one_line(display));
}
}
names
}
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> {
let widget = if is_widget(field) {
field.clone()
} else {
widget_kids(field).into_iter().next()?
};
widget_pages.get(&dict_id(&widget)).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
}
const UNNAMED_FIELD: &str = "unnamed field";
fn render_field(name: &str, page: Option<usize>, value: &str) -> String {
let name = if name.is_empty() { UNNAMED_FIELD } else { name };
match page {
Some(page) => format!("{name} on page {page}: {value}"),
None => format!("{name}: {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> {
let mut fixture = PdfFixture::new();
let (pages, catalog_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(&format!("/Pages {pages_ref}{catalog_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")
);
}
#[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 on page 1: Иван Петров\n\
Subscribe on page 1: checked\n\
Unsubscribe on page 1: not checked\n\
Delivery on page 1: checked (Standard)\n\
City on page 2: Saint Petersburg\nComputed: 42\nNote: two\\nlines"
)
);
assert!(marks.notes.is_empty());
}
#[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_VALUE_NOTE.to_string()]);
}
#[test]
fn a_form_without_values_reports_that_no_value_could_be_read() {
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!(marks.fields.is_none());
assert_eq!(marks.notes, vec![NO_VALUE_NOTE.to_string()]);
}
#[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: alpha\nA.B: 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: 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: 1\nTwo: 2\nunnamed field: 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_VALUE_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 on page 1: one\nPlan on page 1: checked")
);
}
}