use super::parser::{
Syntax, destination_page, exact_name, exact_reference, exact_unsigned, parse_object_head,
};
use super::{FragmentObject, ObjectTail, Reader};
use crate::fallible::reserve_exact;
use crate::pdf::PdfRef;
use crate::{Cancellation, Error, ErrorKind, Limits, RangedSource, Result};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum LinkRepairKind {
Link,
ScalarDestination,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum LinkRepairTarget {
DirectPage(PdfRef),
IndirectArray(PdfRef),
AbsentAppearance { missing: PdfRef, page: PdfRef },
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct LinkRepairCandidate {
pub object: PdfRef,
pub kind: LinkRepairKind,
pub target: LinkRepairTarget,
pub retains_destination_reference: bool,
pub replacement: Vec<u8>,
}
fn is_link_with_destination(head: &super::parser::ObjectHead) -> bool {
let Some(dictionary) = head.dictionary.as_ref() else {
return false;
};
dictionary
.value(b"Subtype")
.and_then(exact_name)
.is_some_and(|name| name == b"Link")
&& dictionary.value(b"Dest").is_some()
&& dictionary.value(b"A").is_none()
&& dictionary
.value(b"Type")
.is_none_or(|value| exact_name(value).is_some_and(|name| name == b"Annot"))
}
pub(crate) fn inspect_link_destination_candidate<S: RangedSource, C: Cancellation>(
source: &mut S,
fragment: FragmentObject,
limits: &Limits,
cancellation: &C,
) -> Result<Option<LinkRepairCandidate>> {
inspect_link_candidate(source, fragment, None, limits, cancellation)
}
pub(crate) fn inspect_link_missing_target_candidate<S: RangedSource, C: Cancellation>(
source: &mut S,
fragment: FragmentObject,
missing: PdfRef,
limits: &Limits,
cancellation: &C,
) -> Result<Option<LinkRepairCandidate>> {
inspect_link_candidate(source, fragment, Some(missing), limits, cancellation)
}
fn inspect_link_candidate<S: RangedSource, C: Cancellation>(
source: &mut S,
fragment: FragmentObject,
missing_appearance: Option<PdfRef>,
limits: &Limits,
cancellation: &C,
) -> Result<Option<LinkRepairCandidate>> {
limits.validate()?;
let range = fragment.range;
let reference = fragment.reference;
if reference.generation != 0 {
return Ok(None);
}
let end = range.end().ok_or(Error::pdf(
ErrorKind::Malformed,
range.offset,
Some((reference.number, reference.generation)),
"link object source range overflows",
))?;
if end > source.size() {
return Err(Error::truncated(
range.offset,
range.length,
source.size().saturating_sub(range.offset),
));
}
let mut reader = Reader::new(source, range, limits, cancellation)?;
limits
.check_input_size(range.length)
.map_err(reader.locator(0, Some(reference)))?;
let head = reader.load_head(0, Some(reference))?;
let ObjectTail::EndObject { end } = head.tail else {
return Ok(None);
};
let mut rest = end as u64;
reader.skip_space(&mut rest)?;
if rest != range.length {
return Err(reader.malformed(
rest,
Some(reference),
"link repair object has trailing non-whitespace bytes",
));
}
let likely_link = is_link_with_destination(&head);
let array_page = head
.scalar
.as_ref()
.and_then(|span| destination_page(&head.bytes[span.clone()]));
if !likely_link && array_page.is_none() {
return Ok(None);
}
let maximum = reader.syntax_limit();
if range.length > maximum {
return Err(
Error::limit("PDF link repair object bytes", maximum, range.length)
.at(range.offset)
.in_pdf(Some((reference.number, reference.generation))),
);
}
let length = usize::try_from(range.length).expect("object syntax limit fits usize");
let complete = reader.bytes(0, length)?;
if !complete.starts_with(&head.bytes) {
return Err(reader.malformed(
0,
Some(reference),
"link repair source changed while reading",
));
}
let complete = parse_object_head(complete)
.map_err(|issue| reader.parse_issue(0, Some(reference), issue))?;
debug_assert_eq!(complete.reference, reference);
debug_assert!(matches!(complete.tail, ObjectTail::EndObject { end: tail } if tail == end));
if !Syntax::new(&complete.bytes[end..]).at_end() {
return Err(reader.malformed(
end as u64,
Some(reference),
"link repair object has trailing non-whitespace bytes",
));
}
debug_assert_eq!(is_link_with_destination(&complete), likely_link);
if likely_link {
let dictionary = complete.dictionary.as_ref().expect("classified dictionary");
reader.reject_duplicate_names(dictionary, 0, Some(reference))?;
let destination = dictionary.entry(b"Dest").expect("classified destination");
let appearance = missing_appearance.and_then(|missing| {
let page = destination_page(destination.value(&dictionary.bytes))?;
let appearance = dictionary.entry(b"AP")?;
if missing.generation != 0
|| single_entry(appearance.value(&dictionary.bytes), b"N").and_then(exact_reference)
!= Some(missing)
|| dictionary
.value(b"BS")
.and_then(|value| single_entry(value, b"W"))
.and_then(exact_unsigned)
!= Some(0)
|| complete
.references
.iter()
.filter(|target| **target == missing)
.count()
!= 1
{
return None;
}
Some((
appearance,
LinkRepairTarget::AbsentAppearance { missing, page },
))
});
let (removed, target) = if let Some(appearance) = appearance {
appearance
} else {
let target = if let Some(page) = destination_page(destination.value(&dictionary.bytes))
{
LinkRepairTarget::DirectPage(page)
} else if let Some(array) = exact_reference(destination.value(&dictionary.bytes)) {
LinkRepairTarget::IndirectArray(array)
} else {
return Ok(None);
};
(destination, target)
};
let retains_destination_reference = match target {
LinkRepairTarget::IndirectArray(array) => {
complete
.references
.iter()
.filter(|reference| **reference == array)
.count()
!= 1
}
LinkRepairTarget::DirectPage(_) | LinkRepairTarget::AbsentAppearance { .. } => false,
};
let dictionary_start = complete
.dictionary_start
.expect("classified dictionary offset");
let pair_start = dictionary_start + removed.pair.start;
let pair_end = dictionary_start + removed.pair.end;
debug_assert!(pair_start < pair_end && pair_end <= complete.bytes.len());
let mut replacement = Vec::new();
let refused = Error::limit(
"PDF link repair allocation",
limits.max_allocation_bytes,
complete.bytes.len() as u64,
)
.at(range.offset)
.in_pdf(Some((reference.number, reference.generation)));
let additional = complete.bytes.len() - (pair_end - pair_start);
reserve_exact(&mut replacement, additional, refused)?;
replacement.extend_from_slice(&complete.bytes[..pair_start]);
replacement.extend_from_slice(&complete.bytes[pair_end..]);
return Ok(Some(LinkRepairCandidate {
object: reference,
kind: LinkRepairKind::Link,
target,
retains_destination_reference,
replacement,
}));
}
let page = array_page.expect("classified destination array");
let replacement = format!("{} 0 obj\nnull\nendobj\n", reference.number).into_bytes();
debug_assert!(replacement.len() as u64 <= limits.max_allocation_bytes);
Ok(Some(LinkRepairCandidate {
object: reference,
kind: LinkRepairKind::ScalarDestination,
target: LinkRepairTarget::DirectPage(page),
retains_destination_reference: false,
replacement,
}))
}
fn single_entry<'a>(bytes: &'a [u8], key: &[u8]) -> Option<&'a [u8]> {
let mut syntax = Syntax::new(bytes);
let entries = syntax.dictionary(0).ok()?;
if entries.len() != 1 || entries[0].name() != key || !syntax.at_end() {
return None;
}
Some(entries[0].value(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Context;
use crate::NeverCancel;
use crate::pdf::PdfRange;
struct Source {
first: Vec<u8>,
second: Vec<u8>,
reads: usize,
starts: usize,
switch: fn(usize, usize) -> bool,
largest_request: usize,
}
impl Source {
fn new(bytes: Vec<u8>) -> Self {
Self::switching(bytes, Vec::new(), |_, _| false)
}
fn switching(first: Vec<u8>, second: Vec<u8>, switch: fn(usize, usize) -> bool) -> Self {
Self {
first,
second,
reads: 0,
starts: 0,
switch,
largest_request: 0,
}
}
}
impl RangedSource for Source {
fn size(&self) -> u64 {
self.first.len() as u64
}
fn read_at(&mut self, offset: u64, destination: &mut [u8]) -> Result<usize> {
self.largest_request = self.largest_request.max(destination.len());
if offset == 0 {
self.starts += 1;
}
let bytes = if (self.switch)(self.reads, self.starts) {
&self.second
} else {
&self.first
};
let start = offset as usize;
let count = destination.len().min(bytes.len().saturating_sub(start));
destination[..count].copy_from_slice(&bytes[start..start + count]);
self.reads += 1;
Ok(count)
}
}
fn inspect(bytes: &[u8]) -> Result<Option<LinkRepairCandidate>> {
let mut source = Source::new(bytes.to_vec());
let fragment = FragmentObject {
reference: PdfRef {
number: 9,
generation: 0,
},
range: PdfRange {
offset: 0,
length: bytes.len() as u64,
},
};
inspect_link_destination_candidate(&mut source, fragment, &Limits::default(), &NeverCancel)
}
#[test]
fn absent_appearance_reuses_bounded_cancellable_source_checked_reads() {
use crate::test_support::CancelAfter;
let bytes =
b"9 0 obj\n<</Subtype/Link/BS<</W 0>>/Dest[6 0 R/Fit]/AP<</N 99 0 R>>>>\nendobj\n";
let fragment = FragmentObject {
reference: PdfRef {
number: 9,
generation: 0,
},
range: PdfRange {
offset: 0,
length: bytes.len() as u64,
},
};
let missing = PdfRef {
number: 99,
generation: 0,
};
let limits = Limits {
io_chunk_bytes: 1,
..Limits::default()
};
let mut source = Source::new(bytes.to_vec());
let signal = CancelAfter::never();
let candidate =
inspect_link_missing_target_candidate(&mut source, fragment, missing, &limits, &signal)
.unwrap()
.unwrap();
assert_eq!(
candidate.target,
LinkRepairTarget::AbsentAppearance {
missing,
page: PdfRef {
number: 6,
generation: 0
},
}
);
assert!(source.largest_request <= 1);
assert!(
candidate
.replacement
.windows(16)
.any(|part| part == b"/Dest[6 0 R/Fit]")
);
assert!(!candidate.replacement.windows(3).any(|part| part == b"/AP"));
for allowed in 0..signal.queries() {
let mut source = Source::new(bytes.to_vec());
assert!(matches!(
inspect_link_missing_target_candidate(
&mut source,
fragment,
missing,
&limits,
&CancelAfter::new(allowed),
),
Err(Error {
kind: ErrorKind::Cancelled,
..
})
));
}
let changed = String::from_utf8(bytes.to_vec())
.unwrap()
.replace("99 0 R", "98 0 R");
let mut source = Source::switching(bytes.to_vec(), changed.into_bytes(), |_, starts| {
starts >= 2
});
assert!(
inspect_link_missing_target_candidate(
&mut source,
fragment,
missing,
&Limits::default(),
&NeverCancel,
)
.is_err()
);
let mut oversized = bytes.to_vec();
oversized.extend(std::iter::repeat_n(b' ', 256));
let mut source = Source::new(oversized);
let mut fragment = fragment;
fragment.range.length = source.size();
let limits = Limits {
max_allocation_bytes: 4096,
..limits
};
assert!(matches!(
inspect_link_missing_target_candidate(
&mut source,
fragment,
missing,
&limits,
&NeverCancel,
),
Err(Error {
kind: ErrorKind::LimitExceeded { .. },
..
})
));
}
#[test]
fn removes_only_a_direct_link_destination_pair() {
let bytes = b"9 0 obj\n<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /AP << /Dest (appearance) >> /Dest [6 0 R /Fit] /Border [0 0 0] >>\nendobj\n";
let candidate = inspect(bytes).unwrap().unwrap();
assert_eq!(candidate.kind, LinkRepairKind::Link);
assert_eq!(
candidate.target,
LinkRepairTarget::DirectPage(PdfRef {
number: 6,
generation: 0
})
);
assert_eq!(candidate.object.number, 9);
let replacement = candidate.replacement;
assert!(replacement.windows(5).any(|window| window == b"/Rect"));
assert!(replacement.windows(7).any(|window| window == b"/Border"));
let parsed = parse_object_head(replacement).unwrap();
let dictionary = parsed.dictionary.unwrap();
assert!(dictionary.value(b"Dest").is_none());
assert!(
dictionary
.value(b"AP")
.unwrap()
.windows(5)
.any(|window| window == b"/Dest")
);
}
#[test]
fn recognizes_indirect_links_and_nulls_scalar_arrays() {
let link = inspect(b"9 0 obj\n<</Subtype/Link /Dest 42 0 R /Rect [0 0 2 2]>>\nendobj\n")
.unwrap()
.unwrap();
assert_eq!(
link.target,
LinkRepairTarget::IndirectArray(PdfRef {
number: 42,
generation: 0
})
);
assert!(link.replacement.windows(5).any(|window| window == b"/Rect"));
let array = inspect(b"9 0 obj\n[6 0 R /XYZ null null 1]\nendobj\n")
.unwrap()
.unwrap();
assert_eq!(array.kind, LinkRepairKind::ScalarDestination);
assert_eq!(
array.target,
LinkRepairTarget::DirectPage(PdfRef {
number: 6,
generation: 0
})
);
assert_eq!(array.replacement, b"9 0 obj\nnull\nendobj\n");
}
#[test]
fn ignores_streams_strings_and_unrelated_dictionaries() {
assert!(inspect(b"9 0 obj\n<</Subtype/Link /Dest [6 0 R /Fit] /Length 0>>\nstream\n\nendstream\nendobj\n").unwrap().is_none());
assert!(
inspect(b"9 0 obj\n(Note /Subtype /Link /Dest [6 0 R /Fit])\nendobj\n")
.unwrap()
.is_none()
);
assert!(
inspect(b"9 0 obj\n<</Subtype/Widget /Dest [6 0 R /Fit]>>\nendobj\n")
.unwrap()
.is_none()
);
assert!(
inspect(b"9 0 obj\n<</Subtype/Link /Dest (named)>>\nendobj\n")
.unwrap()
.is_none()
);
assert!(inspect(b"9 0 obj\n<</Subtype/Link /Dest [6 0 R /Fit] /A <</S/URI /URI (example)>> >>\nendobj\n").unwrap().is_none());
}
#[test]
fn rejects_duplicate_destination_and_wrong_object_header() {
let duplicate =
b"9 0 obj\n<</Subtype/Link /Dest [6 0 R /Fit] /Dest [7 0 R /Fit]>>\nendobj\n";
assert!(matches!(
inspect(duplicate),
Err(Error {
kind: ErrorKind::Malformed,
context: Context::Pdf { repair: true, .. },
..
})
));
let wrong = b"10 0 obj\n[6 0 R /Fit]\nendobj\n";
assert!(matches!(
inspect(wrong),
Err(Error {
kind: _,
offset: Some(0),
context: Context::Pdf { .. },
..
})
));
}
#[test]
fn small_io_chunks_bound_reads() {
let bytes = b"9 0 obj\n[6 0 R /Fit]\nendobj\n";
let mut source = Source::new(bytes.to_vec());
let fragment = FragmentObject {
reference: PdfRef {
number: 9,
generation: 0,
},
range: PdfRange {
offset: 0,
length: bytes.len() as u64,
},
};
let limits = Limits {
io_chunk_bytes: 3,
..Limits::default()
};
let candidate =
inspect_link_destination_candidate(&mut source, fragment, &limits, &NeverCancel)
.unwrap()
.unwrap();
assert_eq!(candidate.kind, LinkRepairKind::ScalarDestination);
assert!(source.largest_request <= 3);
}
#[test]
fn rejects_a_large_candidate_before_buffering_its_full_span() {
let mut bytes = b"9 0 obj\n<</Subtype/Link /Dest [6 0 R /Fit]>>\nendobj\n".to_vec();
bytes.extend(std::iter::repeat_n(b' ', 256));
let mut source = Source::new(bytes);
let fragment = FragmentObject {
reference: PdfRef {
number: 9,
generation: 0,
},
range: PdfRange {
offset: 0,
length: source.size(),
},
};
let limits = Limits {
io_chunk_bytes: 8,
max_allocation_bytes: 4096,
..Limits::default()
};
assert!(matches!(
inspect_link_destination_candidate(&mut source, fragment, &limits, &NeverCancel),
Err(Error {
kind: ErrorKind::LimitExceeded {
resource: "PDF link repair object bytes",
..
},
context: Context::Pdf { .. },
..
})
));
assert!(source.largest_request <= 8);
}
#[test]
fn candidate_span_is_checked_against_the_input_limit() {
let bytes = b"9 0 obj\n[6 0 R /Fit]\nendobj\n";
let mut source = Source::new(bytes.to_vec());
let fragment = FragmentObject {
reference: PdfRef {
number: 9,
generation: 0,
},
range: PdfRange {
offset: 0,
length: bytes.len() as u64,
},
};
let limits = Limits {
max_input_bytes: 8,
..Limits::default()
};
let error =
inspect_link_destination_candidate(&mut source, fragment, &limits, &NeverCancel)
.expect_err("an oversized candidate span was read");
assert!(matches!(
error,
Error { kind: ErrorKind::LimitExceeded { resource: "input bytes", limit: 8, attempted }, offset: Some(0), context: Context::Pdf { object: Some((9, 0)), .. }, .. } if attempted == bytes.len() as u64
));
assert_eq!(source.largest_request, 0);
}
#[test]
fn rejects_a_source_that_changes_between_candidate_reads() {
let bytes = b"9 0 obj\n<</Subtype/Link /Dest [6 0 R /Fit]>>\nendobj\n";
let mut changed = bytes.to_vec();
let digit = changed
.windows(5)
.position(|window| window == b"6 0 R")
.unwrap();
changed[digit] = b'7';
let mut source = Source::switching(bytes.to_vec(), changed, |reads, _| reads > 0);
let fragment = FragmentObject {
reference: PdfRef {
number: 9,
generation: 0,
},
range: PdfRange {
offset: 0,
length: bytes.len() as u64,
},
};
assert!(matches!(
inspect_link_destination_candidate(
&mut source,
fragment,
&Limits::default(),
&NeverCancel
),
Err(Error {
kind: _,
context: Context::Pdf { .. },
reason: "link repair source changed while reading",
..
})
));
}
#[test]
fn truncated_located_range_is_rejected_before_reading() {
let prefix = b"CAJ!!";
let object = b"9 0 obj\n<</Subtype/Link /Dest [6 0 R /Fit]>>\nendobj\n";
let mut bytes = prefix.to_vec();
bytes.extend_from_slice(object);
let mut source = Source::new(bytes);
let fragment = FragmentObject {
reference: PdfRef {
number: 9,
generation: 0,
},
range: PdfRange {
offset: prefix.len() as u64,
length: object.len() as u64 + 3,
},
};
let error = inspect_link_destination_candidate(
&mut source,
fragment,
&Limits::default(),
&NeverCancel,
)
.expect_err("truncated link object was accepted");
assert!(matches!(
error,
Error { kind: ErrorKind::Truncated { expected, available }, offset: Some(offset), .. } if offset == prefix.len() as u64
&& expected == object.len() as u64 + 3
&& available == object.len() as u64
));
assert_eq!(source.largest_request, 0);
}
#[test]
fn nonzero_generation_is_excluded_without_reading() {
let object = b"9 0 obj\n<</Subtype/Link /Dest [6 0 R /Fit]>>\nendobj\n";
let mut source = Source::new(object.to_vec());
let fragment = FragmentObject {
reference: PdfRef {
number: 9,
generation: 1,
},
range: PdfRange {
offset: 0,
length: object.len() as u64,
},
};
assert!(
inspect_link_destination_candidate(
&mut source,
fragment,
&Limits::default(),
&NeverCancel,
)
.unwrap()
.is_none()
);
assert_eq!(source.largest_request, 0);
}
#[test]
fn rejects_non_whitespace_after_a_link_object_with_absolute_offset() {
let prefix = b"CAJ!!";
let object = b"9 0 obj\n<</Subtype/Link /Dest [6 0 R /Fit]>>\nendobj";
let tail = b"\r\n<container-tail>";
let mut bytes = prefix.to_vec();
bytes.extend_from_slice(object);
bytes.extend_from_slice(tail);
let mut source = Source::new(bytes);
let fragment = FragmentObject {
reference: PdfRef {
number: 9,
generation: 0,
},
range: PdfRange {
offset: prefix.len() as u64,
length: (object.len() + tail.len()) as u64,
},
};
let error = inspect_link_destination_candidate(
&mut source,
fragment,
&Limits::default(),
&NeverCancel,
)
.expect_err("object span swallowed the container tail");
assert!(matches!(
error,
Error { kind: ErrorKind::Malformed, offset: Some(offset), context: Context::Pdf { object: Some((9, 0)), repair: false }, reason: "link repair object has trailing non-whitespace bytes", .. } if offset == (prefix.len() + object.len() + 2) as u64
));
}
#[test]
fn rejects_changed_link_form_between_head_and_full_read() {
let first = b"9 0 obj\n<</Subtype/Link /Dest [6 0 R /Fit]>>\nendobj\n".to_vec();
let mut second = first.clone();
let subtype = second
.windows(5)
.position(|window| window == b"/Link")
.unwrap();
second[subtype..subtype + 5].copy_from_slice(b"/Null");
let mut source = Source::switching(first, second, |reads, _| reads > 0);
let fragment = FragmentObject {
reference: PdfRef {
number: 9,
generation: 0,
},
range: PdfRange {
offset: 0,
length: source.size(),
},
};
assert!(matches!(
inspect_link_destination_candidate(
&mut source,
fragment,
&Limits::default(),
&NeverCancel,
),
Err(Error {
kind: ErrorKind::Malformed,
context: Context::Pdf { repair: false, .. },
reason: "link repair source changed while reading",
..
})
));
assert!(source.reads >= 2);
}
#[test]
fn rejects_trailing_bytes_that_appear_after_the_bounded_head_read() {
let object = b"9 0 obj\n[6 0 R /Fit]\nendobj";
let mut first = object.to_vec();
first.extend(std::iter::repeat_n(b' ', 1024));
let mut second = first.clone();
second[800] = b'x';
let mut source = Source::switching(first, second, |_, starts| starts >= 2);
let fragment = FragmentObject {
reference: PdfRef {
number: 9,
generation: 0,
},
range: PdfRange {
offset: 0,
length: source.size(),
},
};
let error = inspect_link_destination_candidate(
&mut source,
fragment,
&Limits::default(),
&NeverCancel,
)
.expect_err("changed trailing bytes were accepted");
assert!(matches!(
error,
Error { kind: ErrorKind::Malformed, offset: Some(offset), context: Context::Pdf { object: Some((9, 0)), repair: false }, reason: "link repair object has trailing non-whitespace bytes", .. } if offset == object.len() as u64
));
}
#[test]
fn identifies_an_indirect_destination_used_by_another_link_field() {
let ordinary = inspect(b"9 0 obj\n<</Subtype/Link /Dest 42 0 R /AP 43 0 R>>\nendobj\n")
.unwrap()
.unwrap();
assert!(!ordinary.retains_destination_reference);
let shared = inspect(b"9 0 obj\n<</Subtype/Link /Dest 42 0 R /AP 42 0 R>>\nendobj\n")
.unwrap()
.unwrap();
assert_eq!(
shared.target,
LinkRepairTarget::IndirectArray(PdfRef {
number: 42,
generation: 0
})
);
assert!(shared.retains_destination_reference);
assert!(
shared
.replacement
.windows(10)
.any(|window| window == b"/AP 42 0 R")
);
}
#[test]
fn rejects_a_keyword_glued_to_endobj_after_the_bounded_head_read() {
let mut object = b"9 0 obj\n[6 0 R /Fit".to_vec();
object.resize(512 - b"]\nendobj".len(), b' ');
object.extend_from_slice(b"]\nendobj");
assert_eq!(object.len(), 512);
let mut first = object.clone();
first.extend_from_slice(&[b' '; 16]);
let mut second = first.clone();
second[512] = b'x';
let mut source = Source::switching(first, second, |_, starts| starts >= 2);
let fragment = FragmentObject {
reference: PdfRef {
number: 9,
generation: 0,
},
range: PdfRange {
offset: 0,
length: source.size(),
},
};
let error = inspect_link_destination_candidate(
&mut source,
fragment,
&Limits::default(),
&NeverCancel,
)
.expect_err("a glued endobj keyword was accepted");
assert!(matches!(
error,
Error {
kind: ErrorKind::Malformed,
offset: Some(512),
context: Context::Pdf {
object: Some((9, 0)),
repair: false
},
reason: "PDF keyword lacks a delimiter",
..
}
));
}
}