Merge origin/main into merge/render-interpret-patches

# Conflicts:
#	crates/render/src/raster/tests.rs
#	crates/render/src/text/tests.rs
#	parity/acrobat-features.toml
#	vendor/README.md
This commit is contained in:
bflatastic committed 2026-11-09 09:03:56 +07:00
commit 9e6854869b
54 files changed
+3243 -126

No files matched your search

Generated
+1
View File
@@ -4699,6 +4699,7 @@ dependencies = [
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-geom",
"pdfcraft-testkit",
"serde",
"skrifa 0.44.0",
"thiserror 2.0.21",
+2 -2
View File
@@ -287,7 +287,7 @@ fn same_output_name(left: &std::ffi::OsStr, right: &std::ffi::OsStr) -> bool {
})
.collect()
}
return key(left) == key(right);
key(left) == key(right)
}
#[cfg(not(windows))]
{
@@ -307,7 +307,7 @@ fn output_probe_name(path: &Path) -> std::io::Result<std::ffi::OsString> {
wide.pop();
}
}
return Ok(std::ffi::OsString::from_wide(&wide));
Ok(std::ffi::OsString::from_wide(&wide))
}
#[cfg(not(windows))]
{
+32
View File
@@ -753,6 +753,38 @@ mod image_tests {
assert_eq!(budget.work_left(), u64::MAX);
}
/// PDFBOX-3932: an image and soft mask declared `/Predictor 15` that their writer never
/// applied, so their rows carry no PNG filter-type byte. Exported, they were "shorter than
/// their size says"; they are read as the samples they are, and tagged rows as before.
#[test]
fn an_image_whose_rows_carry_no_png_tags_decodes_to_its_samples() {
let doc = Document::new_empty();
let samples: Vec<u8> = (0..36u8).map(|i| 200 - i).collect();
let image = |height: Option<i64>, raw: Vec<u8>| {
let mut parms = Dict::new();
parms.set(b"Predictor".to_vec(), Object::Int(15));
parms.set(b"Colors".to_vec(), Object::Int(3));
parms.set(b"Columns".to_vec(), Object::Int(3));
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(3));
if let Some(h) = height {
dict.set(b"Height".to_vec(), Object::Int(h));
}
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
dict.set(b"DecodeParms".to_vec(), Object::Dict(parms));
Stream::from_raw(dict, raw)
};
let untagged = pdfcraft_filters::encode_flate(&samples);
let mut budget = ImageBudget::with_work(u64::MAX);
assert_eq!(budget.decode(&doc, &image(Some(4), untagged.clone()), 1 << 20).unwrap(), samples);
// Without a height to tell, the predictor is undone, as before.
assert_ne!(budget.decode(&doc, &image(None, untagged.clone()), 1 << 20).ok(), Some(samples.clone()));
assert_ne!(budget.decode(&doc, &image(Some(5), untagged), 1 << 20).ok(), Some(samples.clone()));
let p = pdfcraft_filters::Params { predictor: 15, colors: 3, columns: 3, ..pdfcraft_filters::Params::default() };
let tagged = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, &p, &samples).unwrap();
assert_eq!(budget.decode(&doc, &image(Some(4), tagged), 1 << 20).unwrap(), samples);
}
/// The images of a file share its globals, so an image refused doesn't read them: this one's
/// can't be read, which is how to tell.
#[test]
+2
View File
@@ -48,6 +48,8 @@ pub struct Document {
/// `fill_xref_gaps`.
located: Arc<BTreeMap<u32, (u64, u16)>>,
repair_log: Arc<Vec<String>>,
/// See [`Self::rebuilt_unlinked_update`].
unlinked_update: bool,
/// What loading each file object gave, a failure as well: a damaged object is otherwise
/// loaded again on every read, and a member of an object stream that can't be decoded
/// decodes the stream again (`Document::load`).
+15 -1
View File
@@ -90,6 +90,13 @@ impl Document {
&self.repair_log
}
/// Whether the file was rebuilt from its object headers because its last update has no `/Prev`
/// and so cut its page tree off the cross-reference chain (ISO 32000-2 §7.5.6, §7.5.8.2). The
/// renderer rebuilds such a file under the same conditions, so the two read the same objects.
pub fn rebuilt_unlinked_update(&self) -> bool {
self.unlinked_update
}
/// Objects of the file that could not be read, lowest number first, as lines such as
/// "object 11 0 could not be read: syntax error at byte 512: ...". [`Self::get`] reads each
/// as null (ISO 32000-2 §7.3.10), so a document with one opens, but what it held is lost:
@@ -479,7 +486,14 @@ impl Document {
let data = if *work == 0 { Cow::Borrowed(&[][..]) } else { undo(before, work)? };
return pdfcraft_filters::decode_jbig2_within(&data, globals.as_deref().map(Vec::as_slice), columns, height, max, work).map_err(filter);
}
let Some(p) = self.fax_params(s) else { return s.decoded_spending(max, work) };
let Some(p) = self.fax_params(s) else {
// The last filter gives the image's rows: rows without the tags of the PNG predictor
// they declare are read as they are (`pdfcraft_filters::decode_image_tolerant_within`).
return match height {
Some(rows) => s.decoded_image_rows_spending(max, rows, work),
None => s.decoded_spending(max, work),
};
};
// As many rows as the image has: a /Rows below /Height would cut the picture short, and
// rows past /Height aren't drawn.
let p = pdfcraft_filters::CcittParams { rows: 0, ..p };
+101 -11
View File
@@ -21,6 +21,7 @@ impl Document {
xref_oddities: Arc::default(),
located: Arc::default(),
repair_log: Arc::new(Vec::new()),
unlinked_update: false,
cache: Arc::default(),
objstms: Arc::default(),
unreadable: Arc::default(),
@@ -109,6 +110,7 @@ impl Document {
xref_oddities: Arc::default(),
located: Arc::default(),
repair_log: Arc::new(Vec::new()),
unlinked_update: false,
cache: Arc::default(),
objstms: Arc::default(),
unreadable: Arc::default(),
@@ -134,6 +136,8 @@ impl Document {
}
let parsed = doc.read_xref_chain(&mut log);
let mut authenticated = false;
// The newest section cut the page tree off from the chain (see `update_cuts_off_the_pages`).
let mut cut_off = false;
let ok = match parsed {
Ok((entries, trailer, revisions, oddities)) => {
doc.entries = Arc::new(entries);
@@ -147,17 +151,22 @@ impl Document {
doc.authenticate(&enc, &credentials)?;
authenticated = true;
}
doc.root_is_catalog()
let catalog = doc.root_is_catalog();
cut_off = catalog && doc.update_cuts_off_the_pages();
catalog && !cut_off
}
Err(e) => {
log.push(format!("cross-reference data unreadable ({e}); rebuilding from object headers"));
false
}
};
if cut_off {
log.push(UNLINKED_UPDATE.into());
doc.unlinked_update = true;
} else if !ok && !doc.entries.is_empty() {
log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into());
}
if !ok {
if !doc.entries.is_empty() {
log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into());
}
doc.reconstruct(&mut log)?;
}
// Header generations are plaintext, including in encrypted files. Inspect only the
@@ -182,6 +191,24 @@ impl Document {
Ok(doc)
}
/// Whether the chain of cross-reference sections ends at the newest one (no `/Prev`, or one
/// that isn't an offset) although the file holds earlier sections, and the catalog's `/Pages`
/// is not among the objects that one section reaches. ISO 32000-2 §7.5.6 and §7.5.8.2 require
/// the `/Prev`; without it the earlier revisions, page tree and all, are cut off, so the file
/// is rebuilt from its object headers as a damaged one is (the newest definition of each
/// object wins), and the repair log says why. (A file whose `/Root` itself is missing is
/// rebuilt anyway.)
fn update_cuts_off_the_pages(&self) -> bool {
if self.revisions.len() != 1 {
return false;
}
let Some(root) = self.root() else { return false };
let Ok(catalog) = self.try_get_reference(root) else { return false };
let Object::Dict(catalog) = &*catalog else { return false };
// The page tree first: the scan for an earlier `startxref` reads the whole file.
!catalog.get(b"Pages").is_some_and(|pages| matches!(&*self.resolve(pages), Object::Dict(_))) && earlier_sections(&self.data)
}
fn authenticate(&mut self, enc: &Object, credentials: &Credentials<'_>) -> Result<(), CosError> {
// Authentication can change how the same input is decoded.
self.source_identity = Arc::new(());
@@ -203,13 +230,24 @@ impl Document {
// Read here, while nothing is decrypted yet.
let members = self.encryption_members(enc);
let handler = match credentials {
Credentials::Password(password) => Arc::new(pdfcraft_crypt::SecurityHandler::open(params, &id0, *password).map_err(|e| match e {
pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword,
pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword,
other => CosError::Security(other.to_string()),
})?),
// The same encryption as the document whose key this is: that key opens it.
Credentials::Handler { handler, id0: known } if *handler.dict() == params && *known == id0 => Arc::clone(handler),
Credentials::Password(password) => match pdfcraft_crypt::SecurityHandler::open(params.clone(), &id0, *password) {
Ok(handler) => Arc::new(handler),
// No password, or the empty one: what only the embedded files need isn't asked.
Err(pdfcraft_crypt::CryptError::WrongPassword) => match self.without_password(&dict, params) {
Some(handler) if password.is_none_or(str::is_empty) => Arc::new(handler),
_ if password.is_none() => return Err(CosError::NeedsPassword),
_ => return Err(CosError::WrongPassword),
},
Err(other) => return Err(CosError::Security(other.to_string())),
},
// The same encryption as the document whose key this is: that key opens it. A handler
// without the key opens only what it would open by itself.
Credentials::Handler { handler, id0: known } if *handler.dict() == params && *known == id0 => {
if !handler.has_key() && !crate::security::efopen_only(self, &dict) {
return Err(CosError::NeedsPassword);
}
Arc::clone(handler)
}
Credentials::Handler { .. } => return Err(CosError::NeedsPassword),
};
self.security = Some(handler);
@@ -222,6 +260,46 @@ impl Document {
Ok(())
}
/// The handler for a document opened without a password that only its embedded files need
/// (see [`crate::security::efopen_only`]). Not a repair: ISO 32000-2 §7.6.6 has such a
/// document open so; [`Document::embedded_files_locked`] tells it.
fn without_password(&self, dict: &Dict, params: pdfcraft_crypt::EncryptDict) -> Option<pdfcraft_crypt::SecurityHandler> {
crate::security::efopen_only(self, dict).then(|| pdfcraft_crypt::SecurityHandler::without_key(params)).flatten()
}
/// A document opened without the password its embedded files need takes either password
/// later: with the file key its embedded files decrypt, so what was read as stored is read
/// again. Refused while an edited stream holds data read as stored, which the key would
/// encrypt a second time.
fn unlock_embedded_files(&mut self, password: &str) -> Result<(), CosError> {
let Some(current) = self.security.as_deref() else { return Ok(()) };
if self.overlay_entries().any(|(_, _, o)| matches!(o.as_deref(), Some(Object::Stream(s)) if self.reads_as_stored(s))) {
return Err(CosError::Security("an embedded file was edited before the password was given: save or undo that edit first".into()));
}
let handler = pdfcraft_crypt::SecurityHandler::open(current.dict().clone(), &self.first_id(), Some(password)).map_err(|e| match e {
pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword,
other => CosError::Security(other.to_string()),
})?;
self.security = Some(Arc::new(handler));
self.source_identity = Arc::new(());
self.cache = Arc::default();
self.objstms = Arc::default();
Ok(())
}
/// Whether the document opened without the password its embedded files need (`/AuthEvent
/// /EFOpen`): their data is held, and saved, as stored, and isn't to be read as the files.
pub fn embedded_files_locked(&self) -> bool {
self.security.as_deref().is_some_and(|h| !h.has_key())
}
/// Whether `s`, a stream of this document, holds encrypted data that reads as stored because the
/// document opened without the password its embedded files need (see
/// [`Document::embedded_files_locked`]). Such data isn't the file it encrypts.
pub fn reads_as_stored(&self, s: &crate::Stream) -> bool {
self.security.as_deref().is_some_and(|h| crate::security::stream_reads_as_stored(self, h, s))
}
/// The first file identifier, part of the encryption key (empty when there is none).
fn first_id(&self) -> Vec<u8> {
let id = self.trailer.get(b"ID").map(|id| self.resolve(id));
@@ -268,6 +346,9 @@ impl Document {
/// Both passwords unlock the same file key, so nothing read so far is decoded again.
pub fn unlock(&mut self, password: &str) -> Result<(), CosError> {
let Some(current) = self.security.as_deref() else { return Ok(()) };
if !current.has_key() {
return self.unlock_embedded_files(password);
}
if current.auth() == pdfcraft_crypt::Auth::Owner {
return Ok(());
}
@@ -411,3 +492,12 @@ impl Document {
}
}
}
/// The repair-log line for a file rebuilt because its last update has no `/Prev`.
const UNLINKED_UPDATE: &str = "the file's last update has no /Prev link to its earlier revisions, so their objects (the page tree among them) are unreachable through the cross-reference chain; rebuilding from object headers";
/// Whether `data` has more than one `startxref`: an update was appended to it, or it is
/// linearized (whose first-page section links to the main one).
fn earlier_sections(data: &[u8]) -> bool {
find(data, b"startxref", 0, data.len()).and_then(|at| find(data, b"startxref", at.saturating_add(9), data.len())).is_some()
}
+67
View File
@@ -276,6 +276,10 @@ fn fax_images_decode_with_their_parameters() {
let mut understated = parms.clone();
understated.set(b"Rows".to_vec(), Object::Int(1));
assert_eq!(image(&doc, Some(Object::Dict(understated.clone())), 10, 3).unwrap(), bits, "/Height rows, whatever /Rows says");
// More /Rows than /Height (PDFBOX-771: 1,084,975,104), as the renderer reads it too.
let mut overstated = parms.clone();
overstated.set(b"Rows".to_vec(), Object::Int(1_000_000_000));
assert_eq!(image(&doc, Some(Object::Dict(overstated)), 10, 3).unwrap(), bits, "/Height rows, not /Rows past it");
// The parameters as the file gives them, /Rows included, and Table 11's defaults.
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
@@ -2190,6 +2194,69 @@ fn the_editor_cache_charges_a_dictionary_for_the_values_it_holds() {
assert_eq!(cache.owned(), sum, "the kept total is the cache's own");
}
/// The width (`/MediaBox` x1) of each of `pages`, in order.
fn page_widths(pages: &[Dict]) -> Vec<Option<i64>> {
pages.iter().map(|p| p.get(b"MediaBox").and_then(Object::as_array).and_then(|b| b.get(2)).and_then(Object::as_int)).collect()
}
/// The pages of `doc` in page-tree order, as the dictionaries the document holds.
fn page_tree_pages(doc: &Document) -> Vec<Dict> {
let catalog = doc.get(doc.root().expect("root"));
let pages = doc.resolve(catalog.as_dict().and_then(|c| c.get(b"Pages")).expect("/Pages"));
let kids = pages.as_dict().and_then(|p| p.get(b"Kids")).and_then(Object::as_array).cloned().unwrap_or_default();
kids.iter().filter_map(|k| doc.resolve(k).as_dict().cloned()).collect()
}
/// ISO 32000-2 §7.5.6 and §7.5.8.2: an update reaches the revisions before it through `/Prev`.
/// One without it cuts them off, page tree and all. The file is rebuilt from its object headers
/// as a damaged one is, so its page tree is found, the newest definition of each object wins, and
/// the repair log says why; a save rewrites it in full, with every page.
#[test]
fn an_update_without_prev_is_rebuilt_with_its_page_tree_and_a_repair_note() {
let doc = Document::open(Arc::new(pdfcraft_testkit::update_without_prev(false))).expect("the unlinked file opens");
let pages = page_tree_pages(&doc);
assert_eq!(pages.len(), 3);
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page wins: {pages:?}");
assert!(doc.reconstructed() && doc.rebuilt_unlinked_update());
assert!(doc.repair_log().iter().any(|l| l.contains("no /Prev")), "{:?}", doc.repair_log());
let catalog = doc.get(doc.root().expect("root"));
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog wins");
let saved = crate::write_incremental(&doc, &crate::SaveOptions::default()).expect("saves");
let reopened = Document::open(Arc::new(saved)).expect("the saved file opens");
let pages = page_tree_pages(&reopened);
assert_eq!(page_widths(&pages), [Some(200), Some(300), Some(400)], "every page is saved, in order");
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page is saved: {pages:?}");
let catalog = reopened.get(reopened.root().expect("root"));
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog is saved");
assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log());
// The same file with `/Prev` reads through its chain, as before.
let linked = Document::open(Arc::new(pdfcraft_testkit::update_without_prev(true))).expect("the linked file opens");
assert_eq!(page_tree_pages(&linked).len(), 3);
assert!(!linked.reconstructed() && !linked.rebuilt_unlinked_update());
assert_eq!(linked.revisions().len(), 2);
assert!(linked.repair_log().is_empty(), "{:?}", linked.repair_log());
}
/// A hybrid update (ISO 32000-2 §7.5.8.4) without `/Prev`: its `/XRefStm` stream belongs to the
/// update, so the earlier revision is cut off just the same, and the file is rebuilt.
#[test]
fn a_hybrid_update_without_prev_is_rebuilt_too() {
let doc = Document::open(Arc::new(pdfcraft_testkit::hybrid_update_without_prev(false))).expect("the unlinked file opens");
let pages = page_tree_pages(&doc);
assert_eq!(pages.len(), 3);
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page wins: {pages:?}");
assert!(doc.rebuilt_unlinked_update(), "{:?}", doc.repair_log());
assert!(doc.repair_log().iter().any(|l| l.contains("no /Prev")), "{:?}", doc.repair_log());
let catalog = doc.get(doc.root().expect("root"));
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog wins");
let linked = Document::open(Arc::new(pdfcraft_testkit::hybrid_update_without_prev(true))).expect("the linked file opens");
assert_eq!(page_tree_pages(&linked).len(), 3);
assert!(!linked.rebuilt_unlinked_update() && linked.repair_log().is_empty(), "{:?}", linked.repair_log());
assert_eq!(linked.revisions().len(), 2);
}
#[test]
fn find_agrees_with_a_plain_window_search() {
let naive = |hay: &[u8], needle: &[u8], from: usize, to: usize| {
+8
View File
@@ -390,6 +390,14 @@ impl Stream {
pdfcraft_filters::decode_tolerant_within(&chain, &self.raw, max.min(MAX_DECODED), work).map_err(filter)
}
/// [`Stream::decoded_spending`] for the samples of an image `rows` high (its `/Height`):
/// [`pdfcraft_filters::decode_image_tolerant_within`].
pub(crate) fn decoded_image_rows_spending(&self, max: usize, rows: u32, work: &mut u64) -> Result<Vec<u8>, CosError> {
let filter = |e: pdfcraft_filters::FilterError| CosError::Filter(e.to_string());
let chain = self.readable_filters().map_err(filter)?;
pdfcraft_filters::decode_image_tolerant_within(&chain, &self.raw, max.min(MAX_DECODED), rows, work).map_err(filter)
}
/// The stream decoded through its filters by `decode`, or copied when it has none, within
/// `max` and [`MAX_DECODED`].
fn decode_chain(
+29 -6
View File
@@ -502,14 +502,19 @@ fn parse_indirect_in(
let mut after = Lexer::new(data, end);
after.eat_keyword(b"endstream")
};
// A /Length that fits in the file, for a stream whose `endstream` is missing or misspelt.
let fits = declared.and_then(|l| usize::try_from(l).ok()).filter(|l| start.checked_add(*l).is_some_and(|end| end <= data.len()));
let len = match declared {
Some(l) if valid(l) => l as usize,
// Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery).
_ => {
find_endstream(data, start, endstreams)
.ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })?
- start
}
// Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery). With none
// left in the file, a /Length that fits is the stream's extent, "how many bytes of the
// PDF file are used for the stream's data" (ISO 32000-2 §7.3.8.2), as other readers
// take it: a producer that wrote `endstrea` after every stream lost every font and
// every page's content.
_ => match find_endstream(data, start, endstreams) {
Some(end) => end - start,
None => fits.ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })?,
},
};
let range = start..start + len;
let raw = match shared {
@@ -660,6 +665,24 @@ mod tests {
assert_eq!(s.dict.int(b"Length"), Some(5));
}
/// With no `endstream` left in the file, as a producer that writes `endstrea` leaves every
/// stream, a stream is the bytes its /Length gives, "how many bytes of the PDF file are used
/// for the stream's data" (ISO 32000-2 §7.3.8.2): it was an error, and the object null, so
/// such a file lost every font and page content. A /Length past the file, or none, is still
/// no stream.
#[test]
fn indirect_stream_without_endstream_is_its_length() {
for end in [&b"\r\nendstrea\rendobj\r\n"[..], b"\nendobj\n", b""] {
let mut data = b"7 0 obj\n<< /Length 5 >>\nstream\r\nHELLO".to_vec();
data.extend_from_slice(end);
let (_, o) = parse_indirect(&data, 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(&s.raw[..], b"HELLO", "{end:?}");
}
assert!(parse_indirect(b"7 0 obj\n<< /Length 6 >>\nstream\nHELLO", 0, &|_| None).is_err(), "past the file");
assert!(parse_indirect(b"7 0 obj\n<< >>\nstream\nHELLO\nendstrea\nendobj\n", 0, &|_| None).is_err(), "no /Length");
}
#[test]
fn indirect_stream_with_a_space_before_the_line_break() {
// `stream \r\n` as Microsoft Reporting Services writes it: the data starts after the
+40
View File
@@ -128,6 +128,46 @@ pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict {
}
}
/// Whether `d`, an `/Encrypt` dictionary, has crypt filters that encrypt (`/CFM` `V2`, `AESV2` or
/// `AESV3`), each authorised only when an embedded file is opened (`/AuthEvent /EFOpen`, ISO
/// 32000-2 §7.6.6, Table 25): what a document asks for its embedded files alone, which it opens
/// without (§7.6.6: "EFOpen Authorization shall be required when accessing embedded files"). The
/// files use them through `/EFF` or through a `/Crypt` filter of their own. Its strings and streams
/// have to be in the clear too, which [`SecurityHandler::without_key`] checks.
pub(crate) fn efopen_only(doc: &Document, d: &Dict) -> bool {
let name = |d: &Dict, k: &[u8]| d.get(k).and_then(|v| doc.resolve(v).as_name().map(<[u8]>::to_vec));
let Some(cf) = d.get(b"CF").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()) else { return false };
let mut encrypting = Vec::new();
for (k, v) in cf.iter() {
let Some(f) = doc.resolve(v).as_dict().cloned() else { continue };
if !matches!(name(&f, b"CFM").as_deref(), Some(b"V2" | b"AESV2" | b"AESV3")) {
continue;
}
if name(&f, b"AuthEvent").as_deref() != Some(b"EFOpen") {
return false;
}
encrypting.push(k.clone());
}
!encrypting.is_empty()
}
/// Whether `s`, read from a document whose handler has no file key, holds data that handler would
/// decrypt, which therefore reads as stored: still encrypted.
pub(crate) fn stream_reads_as_stored(doc: &Document, h: &SecurityHandler, s: &Stream) -> bool {
if h.has_key() {
return false;
}
let _scope = FollowScope::enter();
let named = named_crypt_filter(doc, &s.dict);
let ty = s.dict.get(b"Type").map(|t| followed(doc, t));
let kind = match (&named, ty.as_deref().and_then(Object::as_name)) {
(Some(n), _) => StreamKind::Named(n),
(None, Some(b"EmbeddedFile")) => StreamKind::EmbeddedFile,
_ => StreamKind::Normal,
};
h.decrypts(kind)
}
/// Whether `f`, an entry of a `/Filter` chain or its only filter, is `/Crypt`.
fn is_crypt(doc: &Document, f: &Object) -> bool {
followed(doc, f).as_name() == Some(b"Crypt")
+27
View File
@@ -166,6 +166,24 @@ fn write_stream(s: &Stream, out: &mut Vec<u8>) {
out.extend_from_slice(b"\nendstream");
}
/// A document opened without the password its embedded files need holds their data as stored and
/// can't encrypt: a save keeps its `/Encrypt` as it is, and writes no edited or added stream that
/// would have to be encrypted (an attached file put in, say), which would be written in the clear.
fn keyless_check(doc: &Document) -> Result<(), CosError> {
if !doc.embedded_files_locked() {
return Ok(());
}
let refuse =
|what: &str| Err(CosError::Security(format!("{what} without the password of the document's embedded files: open it with the password")));
if doc.encryption_changed() || doc.trailer().get(b"Encrypt").is_none() {
return refuse("the document's security can't change");
}
if doc.overlay_entries().any(|(_, _, o)| matches!(o.as_deref(), Some(Object::Stream(s)) if doc.reads_as_stored(s))) {
return refuse("an edited or added embedded file can't be encrypted");
}
Ok(())
}
/// The object as written: encrypted with its (output) number when the document is encrypted,
/// except the `/Encrypt` dictionary itself (`source_num` is its number in the source document).
/// `o` has the document's numbers in its references ([`crate::security::transform`]).
@@ -242,6 +260,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
if doc.stream_limited() {
return Err(CosError::ReadOnlyLimit);
}
keyless_check(doc)?;
// Reconstructed files have no chain to append to; added or removed encryption must
// rewrite every object; redaction must not leave the old revision behind. All need a full save.
if doc.reconstructed() || doc.encryption_changed() || doc.full_save_required() {
@@ -384,6 +403,14 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
if doc.stream_limited() {
return Err(CosError::ReadOnlyLimit);
}
keyless_check(doc)?;
// Renumbering breaks a key that mixes in the object number.
if doc.security().is_some_and(|h| !h.has_key() && !h.survives_renumbering()) {
return Err(CosError::Security(
"the embedded files are encrypted and the document was opened without their password: open it with the password to save it in full"
.into(),
));
}
// A full traversal must not fill the editor's shared caches (which undo snapshots also
// own). Parsed objects and decoded object streams are temporary working data here.
let mut reader = doc.object_reader();
+224
View File
@@ -0,0 +1,224 @@
//! A document that encrypts only its embedded files, authorised when one is opened (`/AuthEvent
//! /EFOpen`, ISO 32000-2 §7.6.6, Table 25): it opens without a password, its pages read as stored,
//! and its embedded files keep their encrypted bytes, flagged as such, through every save.
use std::sync::Arc;
use pdfcraft_cos::{Algorithm, CosError, Document, NewEncryption, ObjRef, Object, SaveOptions, write_full, write_incremental};
use pdfcraft_crypt::StreamKind;
const PAGE: &str = "BT /F1 12 Tf 20 100 Td (Visible page) Tj ET";
const ATTACHED: &[u8] = b"the attached file's own words";
const PASSWORD: &str = "attach pw";
const EMBEDDED: ObjRef = ObjRef { num: 6, generation: 0 };
const CONTENT: ObjRef = ObjRef { num: 4, generation: 0 };
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02X}")).collect()
}
/// One page in the clear and one attachment encrypted with `/StdCF`, whose `/AuthEvent` is
/// `auth_event`: AES-256 (V5, R6) or AES-128 (V4, R4), `/StmF` and `/StrF` `/Identity`.
fn fixture(algorithm: Algorithm, auth_event: &str) -> Vec<u8> {
fixture_with(algorithm, auth_event, false)
}
/// [`fixture`], the attachment naming `/StdCF` in a `/Crypt` filter of its own when `named`, with
/// no `/EFF` (ISO 32000-2 §7.4.10).
fn fixture_with(algorithm: Algorithm, auth_event: &str, named: bool) -> Vec<u8> {
let id0 = [7u8; 16];
let params =
NewEncryption { algorithm, user_password: PASSWORD, owner_password: "owner pw", permissions: -4, encrypt_metadata: true, seed: [3; 32] };
let handler = pdfcraft_crypt::create(&params, &id0).unwrap();
let d = handler.dict();
let kind = if named { StreamKind::Named(b"StdCF") } else { StreamKind::EmbeddedFile };
let cipher = handler.encrypt_stream(EMBEDDED.num, 0, ATTACHED, kind);
let (eff, crypt) = if named { ("", " /Filter [/Crypt] /DecodeParms [<< /Name /StdCF >>]") } else { (" /EFF /StdCF", "") };
let (version, cfm, r6) = match algorithm {
Algorithm::Aes256 => ("/V 5 /R 6", "/AESV3 /Length 32", format!("/OE <{}> /UE <{}> /Perms <{}>", hex(&d.oe), hex(&d.ue), hex(&d.perms))),
_ => ("/V 4 /R 4 /Length 128", "/AESV2 /Length 16", String::new()),
};
let encrypt = format!(
"<< /Filter /Standard {version} /O <{}> /U <{}> {r6} /P {} /CF << /StdCF << /AuthEvent /{auth_event} /CFM {cfm} >> >> /StmF /Identity /StrF /Identity{eff} >>",
hex(&d.o),
hex(&d.u),
d.p
);
let mut objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(note.txt) 5 0 R] >> >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_vec(),
format!("<< /Length {} >>\nstream\n{PAGE}\nendstream", PAGE.len()).into_bytes(),
b"<< /Type /Filespec /F (note.txt) /UF (note.txt) /EF << /F 6 0 R >> >>".to_vec(),
];
let mut stream = format!("<< /Type /EmbeddedFile{crypt} /Length {} >>\nstream\n", cipher.len()).into_bytes();
stream.extend_from_slice(&cipher);
stream.extend_from_slice(b"\nendstream");
objs.push(stream);
objs.push(encrypt.into_bytes());
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
let trailer = format!(
"trailer\n<< /Size {} /Root 1 0 R /Encrypt 7 0 R /ID [<{}> <{}>] >>\nstartxref\n{xref}\n%%EOF\n",
objs.len() + 1,
hex(&id0),
hex(&id0)
);
out.extend_from_slice(trailer.as_bytes());
out
}
fn stream(doc: &Document, r: ObjRef) -> pdfcraft_cos::Stream {
match &*doc.get(r) {
Object::Stream(s) => s.clone(),
other => panic!("object {} isn't a stream: {other:?}", r.num),
}
}
/// The attachment as the password reads it from `bytes`.
fn attachment_with_password(bytes: Vec<u8>) -> Vec<u8> {
let doc = Document::open_with_password(Arc::new(bytes), Some(PASSWORD)).unwrap();
assert!(!doc.embedded_files_locked());
let embedded = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap();
assert!(embedded.get(b"Names").is_some());
stream(&doc, EMBEDDED).decoded().unwrap()
}
#[test]
fn a_document_encrypting_only_its_embedded_files_opens_without_a_password() {
let bytes = fixture(Algorithm::Aes256, "EFOpen");
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
assert!(doc.embedded_files_locked());
// Not a repair: the specification has it open so.
assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());
assert!(doc.trailer().get(b"Encrypt").is_some());
// The page reads as stored; the attachment keeps its encrypted bytes and says so.
let content = stream(&doc, CONTENT);
assert_eq!(content.decoded().unwrap(), PAGE.as_bytes());
assert!(!doc.reads_as_stored(&content));
let embedded = stream(&doc, EMBEDDED);
assert!(doc.reads_as_stored(&embedded));
assert_ne!(embedded.decoded().unwrap(), ATTACHED);
// The password still opens it, and reads the attachment.
assert_eq!(attachment_with_password(bytes.clone()), ATTACHED);
// A wrong password is still refused.
assert!(matches!(Document::open_with_password(Arc::new(bytes), Some("nope")), Err(CosError::WrongPassword)));
}
#[test]
fn a_crypt_filter_authorised_when_the_document_opens_still_needs_the_password() {
let bytes = fixture(Algorithm::Aes256, "DocOpen");
assert!(matches!(Document::open(Arc::new(bytes.clone())), Err(CosError::NeedsPassword)));
assert_eq!(attachment_with_password(bytes), ATTACHED);
}
#[test]
fn saves_keep_the_encrypted_embedded_files_as_stored() {
for algorithm in [Algorithm::Aes256, Algorithm::Aes128] {
let mut doc = Document::open(Arc::new(fixture(algorithm, "EFOpen"))).unwrap();
assert!(doc.embedded_files_locked(), "{algorithm:?}");
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"PageMode".to_vec(), Object::name("UseAttachments"))).unwrap();
// An incremental save keeps /Encrypt, and the attachment decrypts with the password.
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert_eq!(attachment_with_password(saved), ATTACHED, "{algorithm:?}");
// A full save renumbers: AES-256 keys don't depend on the number, AES-128 keys do.
match algorithm {
Algorithm::Aes256 => {
let full = write_full(&doc, &SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(full.clone())).unwrap();
assert!(reopened.embedded_files_locked());
let doc = Document::open_with_password(Arc::new(full), Some(PASSWORD)).unwrap();
let names = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap();
let tree = doc.resolve(names.get(b"Names").unwrap()).as_dict().cloned().unwrap();
let files = doc.resolve(tree.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap();
let list = doc.resolve(files.get(b"Names").unwrap()).as_array().cloned().unwrap();
let spec = doc.resolve(&list[1]).as_dict().cloned().unwrap();
let ef = doc.resolve(spec.get(b"EF").unwrap()).as_dict().cloned().unwrap();
let file = match &*doc.resolve(ef.get(b"F").unwrap()) {
Object::Stream(s) => s.decoded().unwrap(),
other => panic!("{other:?}"),
};
assert_eq!(file, ATTACHED);
}
_ => assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))), "{algorithm:?}"),
}
}
}
#[test]
fn security_cannot_change_without_the_embedded_files_password() {
let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
doc.remove_encryption();
assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
}
/// From the review of the EFOpen change: the empty password opens it too, and the credentials of a
/// document opened without the key open no other.
#[test]
fn the_empty_password_opens_it_and_keyless_credentials_open_nothing_more() {
let bytes = fixture(Algorithm::Aes256, "EFOpen");
let doc = Document::open_with_password(Arc::new(bytes.clone()), Some("")).unwrap();
assert!(doc.embedded_files_locked());
assert!(doc.open_with_same_credentials(Arc::new(bytes)).unwrap().embedded_files_locked());
// The same /Encrypt values and /ID, authorised when the document opens.
let doc_open = fixture(Algorithm::Aes256, "DocOpen");
assert!(matches!(doc.open_with_same_credentials(Arc::new(doc_open.clone())), Err(CosError::NeedsPassword)));
assert!(matches!(Document::open_with_password(Arc::new(doc_open), Some("")), Err(CosError::WrongPassword)));
}
/// An embedded file that names an EFOpen crypt filter of its own, with no `/EFF`, opens the same way.
#[test]
fn an_embedded_file_naming_its_own_crypt_filter_opens_without_a_password() {
let bytes = fixture_with(Algorithm::Aes256, "EFOpen", true);
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
assert!(doc.embedded_files_locked());
assert!(doc.reads_as_stored(&stream(&doc, EMBEDDED)));
assert_eq!(stream(&doc, CONTENT).decoded().unwrap(), PAGE.as_bytes());
assert_eq!(attachment_with_password(bytes), ATTACHED);
}
/// A save can't encrypt without the key: removing `/Encrypt` through the trailer, or putting in an
/// embedded file, is refused rather than written in the clear.
#[test]
fn saves_refuse_what_would_need_the_key() {
let open = || Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
let mut doc = open();
doc.trailer_mut().remove(b"Encrypt");
assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
let mut doc = open();
let mut replaced = stream(&doc, EMBEDDED);
replaced.raw = b"new words".to_vec().into();
doc.set(EMBEDDED, Object::Stream(replaced));
assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
}
/// The password given after opening unlocks the embedded files; a wrong one doesn't.
#[test]
fn the_password_given_later_unlocks_the_embedded_files() {
let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
assert!(matches!(doc.unlock("nope"), Err(CosError::WrongPassword)));
assert!(doc.embedded_files_locked());
doc.unlock(PASSWORD).unwrap();
assert!(!doc.embedded_files_locked());
assert_eq!(stream(&doc, EMBEDDED).decoded().unwrap(), ATTACHED);
assert_eq!(stream(&doc, CONTENT).decoded().unwrap(), PAGE.as_bytes());
// Not while an embedded file read as stored has been edited.
let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
let edited = stream(&doc, EMBEDDED);
doc.set(EMBEDDED, Object::Stream(edited));
assert!(matches!(doc.unlock(PASSWORD), Err(CosError::Security(_))));
}
+1
View File
@@ -2,6 +2,7 @@
mod bounded_writer;
mod corpus;
mod efopen;
mod encryption;
mod generations;
mod proptests;
+41 -1
View File
@@ -234,6 +234,42 @@ impl SecurityHandler {
Ok(Self::new(dict, key, auth, recovered_user))
}
/// A handler with no file key, for a document opened without a password whose strings and
/// streams aren't encrypted and whose encrypted crypt filters are all ones the caller found
/// authorised only when an embedded file is opened (`/AuthEvent /EFOpen`, ISO 32000-2 §7.6.6,
/// Table 25). Data such a filter encrypts is left as stored, read and written alike, so it
/// keeps its encrypted bytes; [`SecurityHandler::decrypts`] tells which. `None` when strings or
/// streams are encrypted, which needs the key, or the handler is older than crypt filters (V4).
pub fn without_key(dict: EncryptDict) -> Option<Self> {
if dict.v < 4 {
return None;
}
let methods = Methods::new(&dict);
if methods.string != Method::Identity || methods.stream != Method::Identity {
return None;
}
Some(Self { dict, key: Vec::new(), auth: Auth::User, recovered_user: None, methods })
}
/// Whether the handler has the file key: false for [`SecurityHandler::without_key`].
pub fn has_key(&self) -> bool {
!self.key.is_empty()
}
/// Whether data of `kind` is encrypted, so that reading it needs the file key.
pub fn decrypts(&self, kind: StreamKind) -> bool {
self.method(Some(kind)) != Method::Identity
}
/// Whether data left as stored by a handler without the key still decrypts after its object
/// is renumbered: only AES-256 doesn't mix the object number into the key (Algorithm 1.A).
pub fn survives_renumbering(&self) -> bool {
[self.methods.string, self.methods.stream, self.methods.embedded_file]
.into_iter()
.chain(self.methods.named.values().copied())
.all(|m| matches!(m, Method::Identity | Method::Aes256))
}
/// Upgrade to owner access with the owner (permissions) password, as when a restricted
/// document is unlocked after opening. The user password, or any other, is `WrongPassword`.
/// Both passwords unlock the same file key, so what was decrypted so far stays valid; a
@@ -267,7 +303,7 @@ impl SecurityHandler {
/// `/P` as `/Perms` holds it: AES-256 ECB of one block (= CBC with a zero IV), marked `adb`.
fn perms_p(&self) -> Option<i32> {
if !matches!(self.dict.r, 5 | 6) {
if !matches!(self.dict.r, 5 | 6) || !self.has_key() {
return None;
}
let plain = aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false);
@@ -332,6 +368,10 @@ impl SecurityHandler {
}
fn apply(&self, method: Method, num: u32, generation: u16, data: &[u8], encrypt: bool) -> Vec<u8> {
// Without the key, encrypted data stays as stored (see `without_key`).
if !self.has_key() {
return data.to_vec();
}
match method {
Method::Identity => data.to_vec(),
Method::Rc4 => rc4(&self.object_key(method, num, generation), data),
+60 -2
View File
@@ -5310,6 +5310,7 @@ fn sign_prepared_with(
v.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0; reserve], hex: true }));
v.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
value(&mut v);
let certifies = v.get(b"Reference").is_some();
let sig = doc.add(Object::Dict(v));
// The field by name, never by number: a full save (encryption, repairs) is
// free to renumber objects, and a hardcoded reference would stamp the value
@@ -5319,6 +5320,12 @@ fn sign_prepared_with(
let fields = doc.resolve(&af).as_dict().expect("AcroForm dict").get(b"Fields").cloned().expect("Fields");
let field = doc.resolve(&fields).as_array().expect("Fields array").iter().filter_map(|o| o.as_ref()).next().expect("signature field");
doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Ref(sig))).unwrap();
if certifies {
// A certification is the signature the catalog's /Perms /DocMDP names (ISO 32000-2, 12.8.2.2).
let mut perms = Dict::new();
perms.set(b"DocMDP".to_vec(), Object::Ref(sig));
doc.update_dict(root, |c| c.set(b"Perms".to_vec(), Object::Dict(perms))).unwrap();
}
let save = pdfcraft_cos::SaveOptions { object_streams: false, ..pdfcraft_cos::SaveOptions::default() };
let mut out = pdfcraft_cos::write_incremental(doc, &save).unwrap();
let mark = format!("0 {} {} {}", LAYERED_BR_MARK[0], LAYERED_BR_MARK[1], LAYERED_BR_MARK[2]);
@@ -9181,8 +9188,9 @@ fn ocr_words_refresh_only_the_page_content_they_add() {
let others = (words_on(&s, 0), words_on(&s, 2));
assert!(!words_on(&s, 1).iter().any(|w| w.contains("HELLO")));
take_full_refreshes();
let word = PlacedWord { text: "HELLO".into(), origin: [40.0, 40.0], across: [60.0, 0.0], up: [0.0, 20.0] };
assert_eq!(s.apply_ocr(id, &[OcrPage { page: 1, words: vec![word], already_on_page: 0, skipped: None }]).unwrap(), 1);
let word =
PlacedWord { text: "HELLO".into(), origin: [40.0, 40.0], across: [60.0, 0.0], up: [0.0, 20.0], confidence: None, source: "ocrs".into() };
assert_eq!(s.apply_ocr(id, &[OcrPage { page: 1, words: vec![word], already_on_page: 0, skipped: None, notes: Vec::new() }]).unwrap(), 1);
assert_eq!(take_full_refreshes(), 0, "applying OCR words doesn't re-inspect the document");
let after = s.get(id).unwrap();
assert_eq!(format!("{:?}", (&after.info.pages, &after.info.annotations, &after.info.fields, &after.links, after.marks())), view);
@@ -11838,3 +11846,53 @@ fn imported_bookmarks_undo_in_one_step() {
assert!(err.to_string().contains("no bookmark"), "{err}");
assert!(s.get(id).unwrap().info.outline.is_empty());
}
/// ISO 32000-2 §7.6.6, Table 25: a document whose only encryption is for its embedded files,
/// authorised when one is opened (`/AuthEvent /EFOpen`), opens without a password, lists its
/// attachment, and saves with the attachment still encrypted.
#[test]
fn a_document_encrypting_only_its_embedded_files_opens_and_saves_without_a_password() {
// The password values of an AES-256 protection, as a save writes them.
let protected = protected("pw", "owner", -1);
let source = pdfcraft_cos::Document::open_with_password(protected, Some("pw")).unwrap();
let encrypt = source.resolve(source.trailer().get(b"Encrypt").unwrap());
let values: String = ["O", "U", "OE", "UE", "Perms"]
.iter()
.map(|k| {
let value = source.resolve(encrypt.as_dict().unwrap().get(k.as_bytes()).unwrap());
format!(" /{k} <{}>", value.as_string().unwrap().bytes.iter().map(|b| format!("{b:02X}")).collect::<String>())
})
.collect();
let objs = [
"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(note.txt) 4 0 R] >> >> >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>".to_string(),
"<< /Type /Filespec /F (note.txt) /EF << /F 5 0 R >> >>".to_string(),
"<< /Type /EmbeddedFile /Length 32 >>\nstream\n0123456789abcdef0123456789abcdef\nendstream".to_string(),
format!(
"<< /Filter /Standard /V 5 /R 6{values} /P -4 /CF << /StdCF << /AuthEvent /EFOpen /CFM /AESV3 /Length 32 >> >> /StmF /Identity /StrF /Identity /EFF /StdCF >>"
),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(
format!("trailer\n<< /Size {} /Root 1 0 R /Encrypt 6 0 R /ID [<00> <00>] >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes(),
);
let mut s = Session::new();
let id = s.open("efopen.pdf", None, Arc::new(pdf), None).expect("opens without a password");
let doc = s.get(id).unwrap();
assert_eq!(doc.info.pages.len(), 1);
assert_eq!(doc.info.attachments.len(), 1);
let saved = s.save_bytes(id).unwrap();
let reopened = pdfcraft_cos::Document::open(saved).unwrap();
assert!(reopened.embedded_files_locked(), "the attachment stays encrypted");
}
+148 -20
View File
@@ -189,7 +189,7 @@ pub(crate) fn pay(work: &mut u64, steps: u64) -> Result<(), FilterError> {
/// [`FilterError::Unsupported`] (callers keep such data encoded), as does a chain longer than
/// [`MAX_CHAIN`]. `max_output` bounds every intermediate buffer.
pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<Vec<u8>, FilterError> {
run(chain, data, max_output, None, true, false).map(|(v, _, _)| v).map_err(|(e, _)| e)
run(chain, data, max_output, None, true, false, None).map(|(v, _, _)| v).map_err(|(e, _)| e)
}
/// Like [`decode`] for a caller charging an allowance: `max_output` bounds the bytes all stages
@@ -197,19 +197,19 @@ pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Res
/// bytes, on success and on failure. A stage's output counts even when a later stage or its
/// predictor shrinks it.
pub fn decode_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec<u8>, usize), (FilterError, usize)> {
run(chain, data, max_output, Some(max_output), true, false).map(|(v, _, spent)| (v, spent))
run(chain, data, max_output, Some(max_output), true, false, None).map(|(v, _, spent)| (v, spent))
}
/// Like [`decode`], but on corrupt data returns what was decoded before the error and
/// reports `partial = true` (the behaviour viewers expect). The remaining filters of the
/// chain still run on the partial data. Limit and unsupported errors are still errors.
pub fn decode_tolerant(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec<u8>, bool), FilterError> {
run(chain, data, max_output, None, false, false).map(|(v, partial, _)| (v, partial)).map_err(|(e, _)| e)
run(chain, data, max_output, None, false, false, None).map(|(v, partial, _)| (v, partial)).map_err(|(e, _)| e)
}
/// [`decode_tolerant`] bounded and counted as [`decode_counted`] is.
pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec<u8>, bool, usize), (FilterError, usize)> {
run(chain, data, max_output, Some(max_output), false, false)
run(chain, data, max_output, Some(max_output), false, false, None)
}
/// [`decode_tolerant`], spending from `work`, what decoding may still cost across a run of images:
@@ -222,23 +222,46 @@ pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_outp
///
/// Unlike [`decode_tolerant`], no filters at all gives `data` itself, within `max_output`.
pub fn decode_tolerant_within(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64) -> Result<Vec<u8>, FilterError> {
spending(chain, data, max_output, work, false)
spending(chain, data, max_output, work, false, None)
}
/// [`decode_tolerant_within`] for the samples of an image `rows` high (its `/Height`), which the
/// last stage gives. When that stage is Flate with a PNG predictor and gives exactly `rows` rows
/// without the filter-type byte each predicted row starts with (ISO 32000-2 §7.4.4.4), its writer
/// declared the predictor without applying it: the output is taken as the samples, as other
/// readers take it, and reported as the damage it is, like any other corruption tolerated here.
/// Any other output has its predictor undone as [`decode_tolerant_within`] does.
pub fn decode_image_tolerant_within(
chain: &[(Filter, Params)],
data: &[u8],
max_output: usize,
rows: u32,
work: &mut u64,
) -> Result<Vec<u8>, FilterError> {
spending(chain, data, max_output, work, false, usize::try_from(rows).ok())
}
/// [`decode`], spending from `work` as [`decode_tolerant_within`] does: corrupt data is an error,
/// having paid for what was decoded before it.
pub fn decode_within(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64) -> Result<Vec<u8>, FilterError> {
spending(chain, data, max_output, work, true)
spending(chain, data, max_output, work, true, None)
}
fn spending(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64, strict: bool) -> Result<Vec<u8>, FilterError> {
fn spending(
chain: &[(Filter, Params)],
data: &[u8],
max_output: usize,
work: &mut u64,
strict: bool,
rows: Option<usize>,
) -> Result<Vec<u8>, FilterError> {
decodable(chain)?;
pay(work, data.len() as u64)?;
if chain.is_empty() {
return if data.len() > max_output { Err(FilterError::LimitExceeded(max_output)) } else { Ok(data.to_vec()) };
}
let left = usize::try_from(*work).unwrap_or(usize::MAX);
match run(chain, data, max_output, Some(left), strict, true) {
match run(chain, data, max_output, Some(left), strict, true, rows) {
Ok((out, _, decoded)) => pay(work, decoded as u64).map(|()| out),
// The stage stopped at the lower of the two, which took as much to find out.
Err((FilterError::LimitExceeded(_), decoded)) if left.saturating_sub(decoded) > max_output => {
@@ -284,10 +307,11 @@ fn decodable(chain: &[(Filter, Params)]) -> Result<(), FilterError> {
}
/// The decoded data, whether it is partial and the bytes all stages decoded, or the error and
/// the bytes decoded before it. `max` bounds each stage, and `total` all of them together.
/// the bytes decoded before it. `max` bounds each stage, and `total` all of them together. With
/// `priced`, the count is steps: each Flate stage also pays [`FLATE_READ_WORK`] for each byte it
/// reads, before it runs, so a stage `total` can't pay for doesn't run
/// ([`FilterError::Spent`]).
/// ([`FilterError::Spent`]). `rows` is the height of the image whose samples the last stage
/// produces, if it does ([`decode_image_tolerant_within`]).
fn run(
chain: &[(Filter, Params)],
data: &[u8],
@@ -295,13 +319,14 @@ fn run(
total: Option<usize>,
strict: bool,
priced: bool,
rows: Option<usize>,
) -> Result<(Vec<u8>, bool, usize), (FilterError, usize)> {
decodable(chain).map_err(|e| (e, 0))?;
let mut cur: Cow<[u8]> = Cow::Borrowed(data);
let mut partial = false;
let mut spent = 0usize;
let price = usize::try_from(FLATE_READ_WORK).unwrap_or(usize::MAX);
for (filter, params) in chain {
for (i, (filter, params)) in chain.iter().enumerate() {
let read = if priced && matches!(filter, Filter::Flate) { price.saturating_mul(cur.len()) } else { 0 };
let room = total.map_or(usize::MAX, |total| total.saturating_sub(spent));
if read > room {
@@ -313,8 +338,9 @@ fn run(
let limit = total.map_or(max, |total| max.min(total.saturating_sub(spent)));
// A second reading of the same bytes (Flate's raw retry) is paid for as the first was, and
// makes no more than is left once it has been.
let rows = rows.filter(|_| i + 1 == chain.len());
let again = room.checked_sub(read.saturating_mul(2)).map(|left| max.min(left));
let (step, work, again) = decode_one(filter, params, &cur, limit, again);
let (step, work, again) = decode_one(filter, params, &cur, limit, again, rows);
if again {
spent = spent.saturating_add(read);
}
@@ -333,16 +359,18 @@ fn run(
}
/// One stage's result, the bytes its decoder produced (before any predictor), and whether it
/// read its input twice, which it does only within `again` ([`flate::decode`]).
fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize, again: Option<usize>) -> (Step, usize, bool) {
/// read its input twice, which it does only within `again` ([`flate::decode`]); `rows` as for
/// [`run`].
fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize, again: Option<usize>, rows: Option<usize>) -> (Step, usize, bool) {
let once = |(step, n): (Step, usize)| (step, n, false);
match filter {
Filter::Flate => {
let (step, twice) = flate::decode(data, max, again);
let (step, n) = with_predictor(step, params, "FlateDecode");
let (step, n) = with_predictor(step, params, "FlateDecode", rows);
(step, n, twice)
}
Filter::Lzw => once(with_predictor(lzw::decode(data, params.early_change != 0, max), params, "LZWDecode")),
// LZW rows are predicted whatever their length, as the renderer reads them.
Filter::Lzw => once(with_predictor(lzw::decode(data, params.early_change != 0, max), params, "LZWDecode", None)),
Filter::AsciiHex => once(counted(asciihex::decode(data, max))),
Filter::Ascii85 => once(counted(ascii85::decode(data, max))),
Filter::RunLength => once(counted(runlength::decode(data, max))),
@@ -361,13 +389,14 @@ fn counted(step: Step) -> (Step, usize) {
/// Applies the predictor to a decoder's result, including to partial output on failure. The
/// count is the decoder's output: a PNG predictor drops a tag byte per row, but the decoder
/// produced it.
fn with_predictor(step: Step, params: &Params, name: &'static str) -> (Step, usize) {
/// produced it. `rows` (an image's height, for its last stage) applies only to data that decoded
/// whole: data cut short by damage has its predictor undone as it always had.
fn with_predictor(step: Step, params: &Params, name: &'static str, rows: Option<usize>) -> (Step, usize) {
let (step, n) = counted(step);
let step = match step {
Ok(v) => predictor::decode(params, v, name),
Ok(v) => predictor::decode(params, v, name, rows),
Err(Failure { error, partial, discarded }) => {
let partial = match predictor::decode(params, partial, name) {
let partial = match predictor::decode(params, partial, name, None) {
Ok(v) => v,
Err(f) => f.partial,
};
@@ -791,4 +820,103 @@ mod tests {
assert!(refused(&decode_tolerant_within(&[], b"data", 4, &mut work).unwrap_err()));
assert_eq!(work, 0);
}
/// Four rows of three RGB pixels, every sample above 4 (none reads as a PNG filter type).
fn rgb_rows() -> (Params, Vec<u8>) {
let p = Params { predictor: 15, colors: 3, columns: 3, ..Params::default() };
(p, (0..36u8).map(|i| 200 - i).collect())
}
#[test]
fn an_image_whose_rows_carry_no_png_tags_is_read_as_its_samples_and_reported() {
let (p, samples) = rgb_rows();
let chain = [(Filter::Flate, p.clone())];
let data = encode_flate(&samples);
let mut work = u64::MAX;
assert_eq!(decode_image_tolerant_within(&chain, &data, 1 << 20, 4, &mut work).unwrap(), samples);
// The repair is reported as the damage it is: tolerant decoding says so, and strict refuses.
let (out, partial, _) = run(&chain, &data, 1 << 20, None, false, false, Some(4)).map_err(|(e, _)| e).unwrap();
assert_eq!((out, partial), (samples.clone(), true));
let strict = run(&chain, &data, 1 << 20, None, true, false, Some(4)).map(|_| ()).map_err(|(e, _)| e.to_string());
assert!(strict.as_ref().is_err_and(|e| e.contains("carry no filter-type bytes")), "{strict:?}");
// The same data that isn't known to be an image's rows still goes through the predictor.
let mut work = u64::MAX;
assert_ne!(decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap(), samples);
// LZW is read as before, as the renderer reads it.
let lzw = [(Filter::Lzw, p)];
let data = encode(&Filter::Lzw, &Params::default(), &samples).unwrap();
let mut plain = u64::MAX;
assert_eq!(
decode_image_tolerant_within(&lzw, &data, 1 << 20, 4, &mut work).ok(),
decode_tolerant_within(&lzw, &data, 1 << 20, &mut plain).ok()
);
}
#[test]
fn an_image_whose_rows_carry_png_tags_has_its_predictor_undone() {
let (p, samples) = rgb_rows();
let chain = [(Filter::Flate, p.clone())];
let data = encode(&Filter::Flate, &p, &samples).unwrap();
let (mut a, mut b) = (u64::MAX, u64::MAX);
assert_eq!(decode_image_tolerant_within(&chain, &data, 1 << 20, 4, &mut a).unwrap(), samples);
assert_eq!(decode_tolerant_within(&chain, &data, 1 << 20, &mut b).unwrap(), samples);
assert_eq!(a, b, "the same work");
let (_, partial, _) = run(&chain, &data, 1 << 20, None, false, false, Some(4)).map_err(|(e, _)| e).unwrap();
assert!(!partial);
}
#[test]
fn image_data_of_neither_length_decodes_as_it_did() {
let (p, _) = rgb_rows();
let chain = [(Filter::Flate, p.clone())];
for len in [0, 1, 9, 27, 35, 37, 39, 40, 41, 72] {
let data = encode_flate(&(0..len).map(|i| (i * 7 % 5) as u8).collect::<Vec<_>>());
let (mut a, mut b) = (u64::MAX, u64::MAX);
let image = decode_image_tolerant_within(&chain, &data, 1 << 20, 4, &mut a).map_err(|e| e.to_string());
let plain = decode_tolerant_within(&chain, &data, 1 << 20, &mut b).map_err(|e| e.to_string());
assert_eq!(image, plain, "{len} bytes");
}
// Rows of the untagged length for another height, or no height.
let samples = encode_flate(&[9; 36]);
for rows in [0, 3, 5, u32::MAX] {
let (mut a, mut b) = (u64::MAX, u64::MAX);
assert_eq!(
decode_image_tolerant_within(&chain, &samples, 1 << 20, rows, &mut a).ok(),
decode_tolerant_within(&chain, &samples, 1 << 20, &mut b).ok(),
"{rows} rows"
);
}
// Only the last stage produces the rows: an earlier one is predicted as before.
let hex: Vec<u8> = [9u8; 36].iter().flat_map(|b| format!("{b:02x}").into_bytes()).collect();
let earlier = [(Filter::Flate, p.clone()), (Filter::AsciiHex, Params::default())];
let data = encode_flate(&hex[..36]);
let (mut a, mut b) = (u64::MAX, u64::MAX);
assert_eq!(
decode_image_tolerant_within(&earlier, &data, 1 << 20, 4, &mut a).ok(),
decode_tolerant_within(&earlier, &data, 1 << 20, &mut b).ok()
);
// TIFF predictor 2 and no predictor are left alone.
for predictor in [1, 2] {
let chain = [(Filter::Flate, Params { predictor, ..p.clone() })];
let (mut a, mut b) = (u64::MAX, u64::MAX);
assert_eq!(
decode_image_tolerant_within(&chain, &samples, 1 << 20, 4, &mut a).ok(),
decode_tolerant_within(&chain, &samples, 1 << 20, &mut b).ok()
);
}
}
#[test]
fn image_rows_of_no_bytes_or_past_memory_do_not_panic() {
for (colors, bits_per_component, columns) in [(0, 8, 3), (3, 8, 0), (3, 8, -1), (255, 16, i64::MAX), (1, 1, i64::MAX), (3, 3, 3)] {
let chain = [(Filter::Flate, Params { predictor: 15, colors, bits_per_component, columns, early_change: 1 })];
for len in [0, 1, 36] {
let data = encode_flate(&vec![7; len]);
for rows in [0, 1, 4, u32::MAX] {
let mut work = u64::MAX;
let _ = decode_image_tolerant_within(&chain, &data, 1 << 20, rows, &mut work);
}
}
}
}
}
+29 -5
View File
@@ -6,6 +6,9 @@
//! byte (0 None, 1 Sub, 2 Up, 3 Average, 4 Paeth), whatever the predictor value is.
//!
//! A final partial row is decoded as far as its data goes.
//!
//! The samples of an image whose rows carry no filter-type byte, although a PNG predictor is
//! declared, are taken as they are and reported as damage ([`decode`]).
use crate::{Failure, FilterError, Params, Step};
@@ -37,18 +40,39 @@ fn layout(p: &Params) -> Result<Layout, String> {
Ok(Layout { bpc, colors, bpp: (colors * bpc).div_ceil(8), row: bits.div_ceil(8), samples })
}
/// Undoes the predictor named by `p.predictor` (1 = identity).
pub(crate) fn decode(p: &Params, data: Vec<u8>, name: &'static str) -> Step {
/// Undoes the predictor named by `p.predictor` (1 = identity). `rows` is the height of the image
/// `data` is the samples of, if it is: with a PNG predictor, data of exactly `rows` rows without
/// their filter-type bytes ([`untagged`]) is given back as it is, as a [`Failure`] whose partial
/// output is all of it, so that tolerant decoding takes the samples and reports the repair, and
/// strict decoding refuses them.
pub(crate) fn decode(p: &Params, data: Vec<u8>, name: &'static str, rows: Option<usize>) -> Step {
match p.predictor {
1 => Ok(data),
2 | 10..=15 => {
let l = layout(p).map_err(|e| Failure::corrupt(name, e, Vec::new()))?;
if p.predictor == 2 { Ok(tiff_decode(&l, data)) } else { png_decode(&l, &data, name) }
if p.predictor == 2 {
return Ok(tiff_decode(&l, data));
}
if let Some(rows) = rows.filter(|&rows| untagged(&l, data.len(), rows)) {
let detail =
format!("PNG predictor {} declared, but its {rows} rows carry no filter-type bytes: read as unpredicted samples", p.predictor);
return Err(Failure::corrupt(name, detail, data));
}
png_decode(&l, &data, name)
}
other => Err(Failure::corrupt(name, format!("invalid Predictor {other}"), Vec::new())),
}
}
/// Whether `len` bytes are exactly `rows` rows of `l` without the filter-type byte that starts each
/// PNG-predicted row (ISO 32000-2 §7.4.4.4), where predicted data would be `rows` × (row + 1): a
/// writer that declared the predictor and didn't apply it. Read with the predictor, every row's
/// first sample was taken for a filter type and the image came out a row short and shifted.
/// PDFium, MuPDF and Poppler read such data as the samples it is.
fn untagged(l: &Layout, len: usize, rows: usize) -> bool {
rows > 0 && l.row > 0 && rows.checked_mul(l.row) == Some(len)
}
/// Applies the predictor named by `p.predictor` (1 = identity; 15 = per-row optimum).
pub(crate) fn encode(p: &Params, data: &[u8]) -> Result<Vec<u8>, FilterError> {
match p.predictor {
@@ -213,7 +237,7 @@ mod tests {
}
fn dec(params: &Params, data: &[u8]) -> Result<Vec<u8>, FilterError> {
decode(params, data.to_vec(), "FlateDecode").map_err(|f| f.error)
decode(params, data.to_vec(), "FlateDecode", None).map_err(|f| f.error)
}
#[test]
@@ -262,7 +286,7 @@ mod tests {
#[test]
fn png_invalid_tag() {
let r = decode(&p(10, 1, 8, 2), vec![0, 1, 2, 5, 1, 1], "FlateDecode");
let r = decode(&p(10, 1, 8, 2), vec![0, 1, 2, 5, 1, 1], "FlateDecode", None);
let f = r.err().unwrap();
assert!(matches!(f.error, FilterError::Corrupt { .. }));
assert_eq!(f.partial, [1, 2]);
+102 -2
View File
@@ -18,6 +18,10 @@ pub struct Page {
pub dict: Dict,
}
/// The side, in points, at or below which a `/MediaBox` has no area and the page falls back to
/// US Letter (the renderer's `is_nearly_zero`, 1/4096).
const MIN_BOX_SIDE: f64 = 1.0 / 4096.0;
fn rect(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> {
let o = doc.resolve(o?);
let v: Vec<f64> = o.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
@@ -25,9 +29,13 @@ fn rect(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> {
}
impl Page {
/// The visible region (crop box clipped to the media box), in user space.
/// The visible region (crop box clipped to the media box), in user space. A page with no
/// `/MediaBox`, or one without area (a side of at most 1/4096 pt, as the renderer measures
/// it), is US Letter, as the renderer and inspector size it.
pub fn crop(&self, doc: &Document) -> [f64; 4] {
let media = rect(doc, self.dict.get(b"MediaBox")).unwrap_or([0.0, 0.0, 612.0, 792.0]);
let media = rect(doc, self.dict.get(b"MediaBox"))
.filter(|m| m[2] - m[0] > MIN_BOX_SIDE && m[3] - m[1] > MIN_BOX_SIDE)
.unwrap_or([0.0, 0.0, 612.0, 792.0]);
match rect(doc, self.dict.get(b"CropBox")) {
Some(c) => {
let r = [c[0].max(media[0]), c[1].max(media[1]), c[2].min(media[2]), c[3].min(media[3])];
@@ -230,6 +238,11 @@ mod tests {
format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] /Rotate {rotate} /Resources << /Font << >> >> {pages} >>"),
format!("<< /Type /Page /Parent 2 0 R /CropBox [10 20 210 320] {page} >>"),
];
doc_from(&objs)
}
/// A document whose objects 1, 2, … are `objs`, object 1 its catalog.
fn doc_from(objs: &[String]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
@@ -245,6 +258,93 @@ mod tests {
Document::open(Arc::new(out)).unwrap()
}
/// A page with no `/MediaBox`, its own or inherited, is US Letter, the size the renderer
/// and the inspector use too (hayro-syntax patch 44; ISO 32000-2 Table 31 gives no default).
#[test]
fn a_page_without_a_media_box_is_letter() {
let d = doc_from(&[
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R >>".to_string(),
]);
let p = &pages(&d)[0];
assert_eq!(p.crop(&d), [0.0, 0.0, 612.0, 792.0]);
assert_eq!(p.display_size(&d), (612.0, 792.0));
}
/// A page without a `/MediaBox` of its own inherits its `/Pages` parent's (ISO 32000-2
/// §7.7.3.4): only a box neither has falls back to US Letter.
#[test]
fn a_page_inherits_its_parents_media_box_before_falling_back_to_letter() {
let d = doc_from(&[
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(),
"<< /Type /Page /Parent 2 0 R >>".to_string(),
]);
let p = &pages(&d)[0];
assert_eq!(p.crop(&d), [0.0, 0.0, 300.0, 400.0]);
assert_eq!(p.display_size(&d), (300.0, 400.0));
}
/// The box can come from further up: a grandparent `/Pages` node's `/MediaBox`, through a
/// `/Pages` node without one, with its origin away from 0 0 kept in the crop and view matrix.
#[test]
fn a_page_inherits_a_grandparents_media_box_with_its_origin() {
let d = doc_from(&[
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [100 200 400 600] >>".to_string(),
"<< /Type /Pages /Parent 2 0 R /Kids [4 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 3 0 R >>".to_string(),
]);
let p = &pages(&d)[0];
assert_eq!(p.crop(&d), [100.0, 200.0, 400.0, 600.0]);
assert_eq!(p.display_size(&d), (300.0, 400.0));
assert_eq!(p.view_matrix(&d), view_matrix_for(0, [100.0, 200.0, 400.0, 600.0]));
assert_ne!(p.view_matrix(&d), view_matrix_for(0, [0.0, 0.0, 300.0, 400.0]), "the origin is kept");
}
/// The nearest ancestor's `/MediaBox` wins over the root's, origin and all: the crop and the view matrix come from
/// the intermediate `/Pages` node's box.
#[test]
fn a_page_takes_its_nearest_ancestors_media_box_and_origin() {
let d = doc_from(&[
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".to_string(),
"<< /Type /Pages /Parent 2 0 R /Kids [4 0 R] /Count 1 /MediaBox [100 200 400 600] >>".to_string(),
"<< /Type /Page /Parent 3 0 R >>".to_string(),
]);
let p = &pages(&d)[0];
assert_eq!(p.crop(&d), [100.0, 200.0, 400.0, 600.0]);
assert_eq!(p.view_matrix(&d), view_matrix_for(0, [100.0, 200.0, 400.0, 600.0]));
}
/// An intermediate `/Pages` node's `/MediaBox` without area is the inherited one, so the page is US Letter, not
/// its grandparent's box.
#[test]
fn a_nearest_media_box_without_area_gives_letter_not_a_further_ancestors() {
let d = doc_from(&[
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(),
"<< /Type /Pages /Parent 2 0 R /Kids [4 0 R] /Count 1 /MediaBox [0 0 0 0] >>".to_string(),
"<< /Type /Page /Parent 3 0 R >>".to_string(),
]);
let p = &pages(&d)[0];
assert_eq!(p.crop(&d), [0.0, 0.0, 612.0, 792.0]);
}
/// A `/MediaBox` without area (`[0 0 0 0]`) is US Letter too, as the renderer draws it.
#[test]
fn a_page_whose_media_box_has_no_area_is_letter() {
let d = doc_from(&[
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 0 0] >>".to_string(),
]);
let p = &pages(&d)[0];
assert_eq!(p.crop(&d), [0.0, 0.0, 612.0, 792.0]);
assert_eq!(p.display_size(&d), (612.0, 792.0));
}
#[test]
fn inherited_copies_stop_at_a_budget() {
let mut d = doc(0);
+5
View File
@@ -107,6 +107,11 @@ impl<'a> Copier<'a> {
Object::Ref(r) => self.copy_ref(dst, *r).map(Object::Ref),
Object::Array(a) => a.iter().map(|x| self.copy_value(dst, x, None)).collect::<Result<Vec<_>, _>>().map(Object::Array),
Object::Dict(d) => self.copy_dict(dst, d, this).map(Object::Dict),
// Opened without the password its embedded files need, the source holds their data
// encrypted: copied into another document, it would read as the file.
Object::Stream(s) if self.src.reads_as_stored(s) => Err(OrganizeError::Invalid(
"an attached file is encrypted and the document was opened without its password: open it with the password to copy it".into(),
)),
Object::Stream(s) => self.copy_dict(dst, &s.dict, None).map(|dict| {
let mut s = s.clone();
s.dict = dict;
@@ -0,0 +1,78 @@
//! A document that encrypts only its embedded files (`/AuthEvent /EFOpen`, ISO 32000-2 §7.6.6,
//! Table 25), opened without its password, holds them encrypted: combining it into another
//! document must not copy that data as if it were the files.
use std::sync::Arc;
use pdfcraft_cos::{Algorithm, Document, NewEncryption, SaveOptions, write_full};
use pdfcraft_organize::{OrganizeError, combine};
/// The password values (`/O`, `/U`, `/OE`, `/UE`, `/Perms`) of an AES-256 (R6) protection with a
/// user password, as a save writes them.
fn r6_password_values() -> String {
let plain = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec();
let mut doc = Document::open(Arc::new(plain)).unwrap();
let params = NewEncryption {
algorithm: Algorithm::Aes256,
user_password: "user pw",
owner_password: "owner pw",
permissions: -4,
encrypt_metadata: true,
seed: [5; 32],
};
doc.set_encryption(&params).unwrap();
let saved = Document::open_with_password(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap()), Some("user pw")).unwrap();
let encrypt = saved.resolve(saved.trailer().get(b"Encrypt").unwrap());
let encrypt = encrypt.as_dict().unwrap();
["O", "U", "OE", "UE", "Perms"]
.iter()
.map(|k| {
let value = saved.resolve(encrypt.get(k.as_bytes()).unwrap());
let hex: String = value.as_string().unwrap().bytes.iter().map(|b| format!("{b:02X}")).collect();
format!("/{k} <{hex}>")
})
.collect::<Vec<_>>()
.join(" ")
}
/// One page in the clear and one attached file encrypted with `/StdCF`, authorised by `/EFOpen`.
fn efopen_pdf() -> Vec<u8> {
let encrypt = format!(
"<< /Filter /Standard /V 5 /R 6 {} /P -4 /CF << /StdCF << /AuthEvent /EFOpen /CFM /AESV3 /Length 32 >> >> /StmF /Identity /StrF /Identity /EFF /StdCF >>",
r6_password_values()
);
let objs = [
"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(note.txt) 4 0 R] >> >> >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>".to_string(),
"<< /Type /Filespec /F (note.txt) /EF << /F 5 0 R >> >>".to_string(),
"<< /Type /EmbeddedFile /Length 32 >>\nstream\n0123456789abcdef0123456789abcdef\nendstream".to_string(),
encrypt,
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(
format!("trailer\n<< /Size {} /Root 1 0 R /Encrypt 6 0 R /ID [<00> <00>] >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes(),
);
out
}
#[test]
fn combining_does_not_copy_attachments_held_encrypted() {
let src = Document::open(Arc::new(efopen_pdf())).unwrap();
assert!(src.embedded_files_locked());
match combine(&[("efopen.pdf", &src)]) {
Err(OrganizeError::Invalid(why)) => assert!(why.contains("password"), "{why}"),
Err(other) => panic!("{other}"),
Ok(_) => panic!("an encrypted attachment was copied as the file"),
}
}
+1
View File
@@ -31,5 +31,6 @@ bytemuck = { version = "1" }
getrandom = { version = "0.4", features = ["wasm_js"] }
[dev-dependencies]
pdfcraft-testkit = { workspace = true }
# Builds the decompression-bomb streams in the raster tests.
flate2 = { workspace = true }
+48
View File
@@ -80,6 +80,9 @@ pub fn inspect(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocInfo, O
Ok(p) => info.pages = p,
Err(p) => return Err(OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p)))),
}
// Never silent: the pages of a file whose last update has no `/Prev` were found by rebuilding
// its cross-reference data (`pdfcraft-cos` rebuilds it under the same conditions and logs it).
let unlinked = pdf.rebuilt_unlinked_update();
let options = load_options(password);
// Geometry no longer needs the renderer parser. Do not overlap its allocations with
// structural inspection, including the compatibility fallback for damaged files.
@@ -110,9 +113,17 @@ pub fn inspect(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocInfo, O
if info.pages.is_empty() {
return Err(OpenError::Invalid("the document has no pages".into()));
}
if unlinked {
info.warnings.push(UNLINKED_UPDATE.into());
}
Ok(info)
}
/// The warning for a file whose pages were found by rebuilding its cross-reference data because its
/// last update has no `/Prev` (ISO 32000-2 §7.5.6, §7.5.8.2).
const UNLINKED_UPDATE: &str =
"This file's last update has no link (/Prev) to its earlier revisions; its pages were found by rebuilding the cross-reference data.";
// Commit auxiliary metadata only after inspection succeeds; renderer geometry is the fallback.
pub(super) fn inspect_structure(info: &mut DocInfo, fill: impl FnOnce(&mut DocInfo) -> Result<(), String>) {
let structure = catch_unwind(AssertUnwindSafe(|| {
@@ -434,3 +445,40 @@ mod open_action_javascript_tests {
assert!(!info.has_javascript, "a /GoTo /OpenAction is not JavaScript");
}
}
#[cfg(test)]
mod unlinked_update_tests {
use super::*;
/// An update without `/Prev` cuts the page tree off the chain; the pages come from the
/// rebuilt table, the page tree's pages in its order, and the file says it was rebuilt.
#[test]
fn an_update_without_prev_shows_the_page_tree_and_says_it_was_rebuilt() {
let info = inspect(Arc::new(pdfcraft_testkit::update_without_prev(false)), None).expect("the unlinked file opens");
assert_eq!(info.pages.len(), 3, "{:?}", info.pages);
assert_eq!(info.pages.iter().map(|p| p.width).collect::<Vec<_>>(), [200.0, 300.0, 400.0]);
assert_eq!(info.warnings, [UNLINKED_UPDATE]);
// The structure is read from the rebuilt table as well: the second page's link.
assert_eq!(info.links.len(), 1, "{:?}", info.links);
let linked = inspect(Arc::new(pdfcraft_testkit::update_without_prev(true)), None).expect("the linked file opens");
assert_eq!(linked.pages.len(), 3);
assert!(linked.warnings.is_empty(), "{:?}", linked.warnings);
assert_eq!(linked.links.len(), 1);
}
/// A hybrid update's `/XRefStm` stream is part of that update, not an earlier section: without
/// `/Prev` the file is rebuilt as the document model rebuilds it, and with it, it isn't.
#[test]
fn a_hybrid_update_without_prev_is_rebuilt_as_the_document_model_rebuilds_it() {
let bytes = Arc::new(pdfcraft_testkit::hybrid_update_without_prev(false));
let info = inspect(bytes.clone(), None).expect("the unlinked hybrid file opens");
assert_eq!(info.pages.iter().map(|p| p.width).collect::<Vec<_>>(), [200.0, 300.0, 400.0]);
assert_eq!(info.warnings, [UNLINKED_UPDATE]);
assert!(pdfcraft_cos::Document::open(bytes).expect("cos opens it").rebuilt_unlinked_update());
let bytes = Arc::new(pdfcraft_testkit::hybrid_update_without_prev(true));
let linked = inspect(bytes.clone(), None).expect("the linked hybrid file opens");
assert_eq!(linked.pages.len(), 3);
assert!(linked.warnings.is_empty(), "{:?}", linked.warnings);
assert!(!pdfcraft_cos::Document::open(bytes).expect("cos opens it").rebuilt_unlinked_update());
}
}
+7 -1
View File
@@ -231,9 +231,15 @@ impl<'a> Inspector<'a> {
}
}
/// Fetch an attachment's bytes (decoded). Capped at 1 GiB to defuse decompression bombs.
/// Fetch an attachment's bytes (decoded). Capped at 1 GiB to defuse decompression bombs. A
/// document opened without the password its embedded files need (`/AuthEvent /EFOpen`, ISO
/// 32000-2 §7.6.6) holds them encrypted: asking for one is an error, never its encrypted bytes.
pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) -> Result<Vec<u8>, String> {
let run = || -> Result<Vec<u8>, String> {
// Read through lopdf, which can't read a document opened without its key at all.
if pdfcraft_cos::Document::open_with_password(std::sync::Arc::new(bytes.to_vec()), password).is_ok_and(|d| d.embedded_files_locked()) {
return Err("the attachment is encrypted: open the document with its password to read it".into());
}
let options = load_options(password);
let doc = Document::load_mem_with_options(bytes, options).map_err(|e| e.to_string())?;
let insp = Inspector::new(&doc);
+104
View File
@@ -2071,3 +2071,107 @@ trailer << /Root 1 0 R >>
let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.page, f.rect.map(|r| r[1]))).collect();
assert_eq!(listed, [("radio", Some(0), Some(120.0)), ("pair", Some(0), Some(90.0)), ("other", Some(0), Some(60.0))], "{:?}", info.fields);
}
/// The password values (`/O`, `/U`, `/OE`, `/UE`, `/Perms`) of an AES-256 (R6) protection with a
/// user password, as a save writes them.
fn r6_password_values() -> String {
let plain = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec();
let mut doc = pdfcraft_cos::Document::open(Arc::new(plain)).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "user pw",
owner_password: "owner pw",
permissions: -4,
encrypt_metadata: true,
seed: [5; 32],
})
.unwrap();
let saved = pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
let saved = pdfcraft_cos::Document::open_with_password(Arc::new(saved), Some("user pw")).unwrap();
let encrypt = saved.resolve(saved.trailer().get(b"Encrypt").unwrap());
let encrypt = encrypt.as_dict().unwrap();
["O", "U", "OE", "UE", "Perms"]
.iter()
.map(|k| {
let value = saved.resolve(encrypt.get(k.as_bytes()).unwrap());
let hex: String = value.as_string().unwrap().bytes.iter().map(|b| format!("{b:02X}")).collect();
format!("/{k} <{hex}>")
})
.collect::<Vec<_>>()
.join(" ")
}
/// A page with text in the clear and an attachment, `note.txt`, encrypted with `/StdCF` (through
/// `/EFF`, or a `/Crypt` filter of its own when `named`). `/StdCF` is authorised by `auth_event`,
/// under the handler `version`.
fn efopen_pdf_with(auth_event: &str, named: bool, version: &str) -> Vec<u8> {
let (eff, crypt) = if named { ("", " /Filter [/Crypt] /DecodeParms [<< /Name /StdCF >>]") } else { (" /EFF /StdCF", "") };
let encrypt = format!(
"<< /Filter /Standard {version} {} /P -4 /CF << /StdCF << /AuthEvent /{auth_event} /CFM /AESV3 /Length 32 >> >> /StmF /Identity /StrF /Identity{eff} >>",
r6_password_values()
);
let page = "BT /F1 12 Tf 20 100 Td (Visible page) Tj ET";
let objs = [
"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(note.txt) 4 0 R] >> >> >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>".to_string(),
"<< /Type /Filespec /F (note.txt) /EF << /F 5 0 R >> >>".to_string(),
format!("<< /Type /EmbeddedFile{crypt} /Length 32 >>\nstream\n0123456789abcdef0123456789abcdef\nendstream"),
format!("<< /Length {} >>\nstream\n{page}\nendstream", page.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
encrypt,
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(
format!("trailer\n<< /Size {} /Root 1 0 R /Encrypt {} 0 R /ID [<00> <00>] >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1, objs.len())
.as_bytes(),
);
out
}
fn efopen_pdf(auth_event: &str) -> Vec<u8> {
efopen_pdf_with(auth_event, false, "/V 5 /R 6")
}
/// ISO 32000-2 §7.6.6, Table 25: a crypt filter whose `/AuthEvent` is `/EFOpen` needs its password
/// only when an embedded file is opened. Such a document opens without one, its page reads, and
/// its attachment is listed; asking for the attachment's bytes is an error, not its encrypted data.
#[test]
fn a_document_encrypting_only_its_embedded_files_opens_without_a_password() {
let bytes = Arc::new(efopen_pdf("EFOpen"));
assert!(check_pages(bytes.clone(), None, 1).is_ok());
let info = inspect(bytes.clone(), None).expect("opens without a password");
assert_eq!(info.pages.len(), 1);
let mut renderer = crate::PageRenderer::new(bytes.clone(), crate::RenderConfig::default());
let text = renderer.render(crate::RenderRequest { page: 0, kind: crate::RequestKind::Text, ..Default::default() });
assert!(text.text.is_some_and(|page| page.plain_text().contains("Visible page")), "{:?}", text.error);
let listed = |name: &str| info.attachments.iter().find(|a| a.name == name).unwrap_or_else(|| panic!("{name} is listed")).clone();
let refused = attachment_data(&bytes, None, &listed("note.txt")).expect_err("encrypted bytes aren't the file");
assert!(refused.contains("password"), "{refused}");
// Authorised when the document opens, the same filter still needs the password.
let doc_open = Arc::new(efopen_pdf("DocOpen"));
assert!(matches!(inspect(doc_open.clone(), None), Err(OpenError::NeedsPassword)));
assert!(matches!(check_pages(doc_open, None, 1), Err(OpenError::NeedsPassword)));
}
/// From the review of the EFOpen change: an attachment naming its own EFOpen crypt filter, with no
/// `/EFF`, opens the same way; a handler older than crypt filters (V2) still needs the password.
#[test]
fn efopen_filters_named_by_the_file_open_and_older_handlers_do_not() {
let named = Arc::new(efopen_pdf_with("EFOpen", true, "/V 5 /R 6"));
let info = inspect(named.clone(), None).expect("opens without a password");
let note = info.attachments.iter().find(|a| a.name == "note.txt").expect("listed").clone();
assert!(attachment_data(&named, None, &note).is_err());
let legacy = Arc::new(efopen_pdf_with("EFOpen", false, "/V 2 /R 3 /Length 128"));
assert!(matches!(check_pages(legacy, None, 1), Err(OpenError::NeedsPassword)));
}
+1
View File
@@ -419,6 +419,7 @@ fn render_page<'a>(
y_offset: t.y as f32,
bg_color,
color_replacement,
..Default::default()
}
}
None => {
+577 -3
View File
@@ -3454,6 +3454,56 @@ trailer << /Root 1 0 R >>
}
}
/// PDFBOX-771: a fax image whose `/Rows` says more rows than its `/Height` (1,084,975,104 on a
/// 2,592-column scan) is decoded to its height, as the image is (ISO 32000-2 Table 87): the
/// decoder was asked for `/Rows` rows, which the size check refused, so every page drew blank.
/// Also through an `/Indexed` colour space, the filter's other output path. Samples past the
/// decoded data read as black there, so a second image striped black, white, black, white shows
/// that every row up to the last is decoded.
#[test]
fn group4_fax_with_rows_past_its_height_decodes_to_the_image_height() {
const ALL_BLACK_8X4: [u8; 6] = [0x26, 0xa2, 0xfe, 0x00, 0x20, 0x02];
// Each row in horizontal mode: black rows as white 0 + black 8, white rows as white 8 +
// black 0 (ITU-T T.6 §2.2.3.3, codes from Table 2/T.6), then EOFB.
const STRIPED_8X4: [u8; 12] = [0x26, 0xa2, 0x99, 0x86, 0xe4, 0xd4, 0x53, 0x30, 0xdc, 0x00, 0x40, 0x04];
const BLACK: [u8; 4] = [0, 0, 0, 255];
const WHITE: [u8; 4] = [255, 255, 255, 255];
let images: [(&[u8], [[u8; 4]; 4]); 2] = [(&ALL_BLACK_8X4, [BLACK; 4]), (&STRIPED_8X4, [BLACK, WHITE, BLACK, WHITE])];
for ((data, expected), space) in images.iter().flat_map(|i| ["/DeviceGray", "[/Indexed /DeviceRGB 1 <000000FFFFFF>]"].map(|s| (i, s))) {
for rows in ["/Rows 5", "/Rows 1000000000", "/Rows 4294967295"] {
let content = "q 20 0 0 20 5 5 cm /Im1 Do Q";
let mut pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 4 /ColorSpace {space} /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 8 {rows} >> /Length {} >> stream
",
content.len(),
data.len()
)
.into_bytes();
pdf.extend_from_slice(data);
pdf.extend_from_slice(
b"
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
);
let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{space} {rows}: {:?}", page.error);
for (y, colour) in [17, 22, 27, 32].into_iter().zip(expected) {
assert_eq!(&page.rgba[((y * 40 + 15) * 4)..][..4], colour, "{space} {rows}: image row at y {y}");
}
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 255, 255, 255], "{space} {rows}: outside the image stays white");
}
}
}
#[test]
fn tiff_predictor_packed_rgb_rows_keep_their_byte_boundaries() {
use hayro_syntax::object::{FromBytes, Object};
@@ -5536,6 +5586,182 @@ fn a_font_without_a_subtype_is_drawn_by_its_base_font() {
assert_eq!(render("", "Courier-Bold"), courier, "no /Subtype");
}
/// A page with no `/MediaBox`, its own or inherited, is US Letter (612 × 792 pt), as other
/// readers draw it and as the page model and inspector already sized it; it was A4 (vendored
/// hayro-syntax patch 44; ISO 32000-2 Table 31 makes the box required and gives no default;
/// PDFBOX-4001). A `/MediaBox` without area falls back to the same size.
#[test]
fn a_page_without_a_media_box_is_letter() {
let page_pdf = |media: &str| {
format!(
"%PDF-1.4
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R {media} /Contents 4 0 R >> endobj
4 0 obj << /Length 25 >> stream
0 0 1 rg 10 10 50 50 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF"
)
.into_bytes()
};
for media in ["", "/MediaBox [0 0 0 0]"] {
let pdf = page_pdf(media);
let page = render_first_page(pdf.clone());
assert_eq!((page.width, page.height), (612, 792), "rendered size, {media:?}");
let info = crate::inspect(Arc::new(pdf), None).expect("inspect");
assert_eq!((info.pages[0].width, info.pages[0].height), (612.0, 792.0), "inspected size, {media:?}");
}
// The blue square sits at the bottom left of the Letter page, also when the box has no
// area: the page is placed and clipped by the same Letter region it is sized by.
for media in ["", "/MediaBox [0 0 0 0]"] {
let page = render_first_page(page_pdf(media));
assert_eq!(rgb_at(&page.rgba, page.width as usize, 30, 792 - 30), [0, 0, 255], "{media:?}");
}
}
/// A page without a `/MediaBox` of its own inherits its `/Pages` parent's (ISO 32000-2 §7.7.3.4)
/// before any fallback: rendered and inspected at 300 × 400, not US Letter.
#[test]
fn a_page_inherits_its_parents_media_box_before_falling_back_to_letter() {
let pdf = b"%PDF-1.4
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj
4 0 obj << /Length 25 >> stream
0 0 1 rg 10 10 50 50 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF"
.to_vec();
let page = render_first_page(pdf.clone());
assert_eq!((page.width, page.height), (300, 400), "rendered size");
assert_eq!(rgb_at(&page.rgba, page.width as usize, 30, 400 - 30), [0, 0, 255]);
let info = crate::inspect(Arc::new(pdf), None).expect("inspect");
assert_eq!((info.pages[0].width, info.pages[0].height), (300.0, 400.0), "inspected size");
}
/// The same through a `/Pages` node without a box: the page takes its grandparent's `/MediaBox`.
#[test]
fn a_page_inherits_its_grandparents_media_box() {
let pdf = b"%PDF-1.4
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [5 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
5 0 obj << /Type /Pages /Parent 2 0 R /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 5 0 R /Contents 4 0 R >> endobj
4 0 obj << /Length 25 >> stream
0 0 1 rg 10 10 50 50 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF"
.to_vec();
let page = render_first_page(pdf.clone());
assert_eq!((page.width, page.height), (300, 400), "rendered size");
let info = crate::inspect(Arc::new(pdf), None).expect("inspect");
assert_eq!((info.pages[0].width, info.pages[0].height), (300.0, 400.0), "inspected size");
}
/// The nearest ancestor's `/MediaBox` wins: an intermediate `/Pages` node's box over the root's,
/// and a box whose origin isn't 0 0 keeps the content where the box puts it.
#[test]
fn a_page_inherits_its_nearest_ancestors_media_box_with_its_origin() {
let pdf = b"%PDF-1.4
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [5 0 R] /Count 1 /MediaBox [0 0 612 792] >> endobj
5 0 obj << /Type /Pages /Parent 2 0 R /Kids [3 0 R] /Count 1 /MediaBox [100 100 400 500] >> endobj
3 0 obj << /Type /Page /Parent 5 0 R /Contents 4 0 R >> endobj
4 0 obj << /Length 27 >> stream
0 0 1 rg 110 110 50 50 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF"
.to_vec();
let page = render_first_page(pdf.clone());
assert_eq!((page.width, page.height), (300, 400), "the intermediate box, not the root's");
// The square sits 10 pt in from the box's lower-left corner, at (100, 100).
assert_eq!(rgb_at(&page.rgba, page.width as usize, 30, 400 - 30), [0, 0, 255], "placed by the box's origin");
let info = crate::inspect(Arc::new(pdf), None).expect("inspect");
assert_eq!((info.pages[0].width, info.pages[0].height), (300.0, 400.0), "inspected size");
}
/// A page's own `/MediaBox` wins over its parent's: rendered and inspected at the page's 200 × 100, the content placed by
/// the page box's origin.
#[test]
fn a_pages_own_media_box_wins_over_its_parents() {
let pdf = b"%PDF-1.4
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [50 50 250 150] /Contents 4 0 R >> endobj
4 0 obj << /Length 25 >> stream
0 0 1 rg 60 60 50 50 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF"
.to_vec();
let page = render_first_page(pdf.clone());
assert_eq!((page.width, page.height), (200, 100), "the page's box, not its parent's");
// The square sits 10 pt in from the page box's lower-left corner, at (50, 50).
assert_eq!(rgb_at(&page.rgba, page.width as usize, 30, 100 - 30), [0, 0, 255], "placed by the page box's origin");
let info = crate::inspect(Arc::new(pdf), None).expect("inspect");
assert_eq!((info.pages[0].width, info.pages[0].height), (200.0, 100.0), "inspected size");
}
/// The nearest ancestor's `/MediaBox` is the inherited one even when it has no area: an intermediate `/Pages` node's
/// `[0 0 0 0]` gives US Letter, not the grandparent's 300 × 400 (ISO 32000-2 §7.7.3.4).
#[test]
fn a_nearest_media_box_without_area_gives_letter_not_a_further_ancestors() {
let pdf = b"%PDF-1.4
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [5 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
5 0 obj << /Type /Pages /Parent 2 0 R /Kids [3 0 R] /Count 1 /MediaBox [0 0 0 0] >> endobj
3 0 obj << /Type /Page /Parent 5 0 R /Contents 4 0 R >> endobj
4 0 obj << /Length 25 >> stream
0 0 1 rg 10 10 50 50 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF"
.to_vec();
let page = render_first_page(pdf.clone());
assert_eq!((page.width, page.height), (612, 792), "rendered size");
let info = crate::inspect(Arc::new(pdf), None).expect("inspect");
assert_eq!((info.pages[0].width, info.pages[0].height), (612.0, 792.0), "inspected size");
}
/// An embedded CMap whose codes are all two bytes long (codespace `<0000> <FFFF>`) and whose
/// mappings are written as `bfchar` reads `<2041>` as the one code 0x2041 (ISO 32000-2
/// §9.7.6.2: the codespace ranges decide how many bytes form a code). It drew as two
/// glyphs: the lone first byte 0x20 matched the two-byte entry `<0020>` (PDFBOX-1282,
/// `Characters_Arial.pdf` page 3, codes 0x20AC to 0xFFFC). Vendored hayro-cmap patch 4.
#[test]
fn a_bfchar_cmap_reads_codes_of_its_codespace_length() {
let font = include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf");
let render = |encoding: &str, text: &str| -> Vec<u8> {
let cmap = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap
/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> def
/CMapName /Test-H def /CMapType 1 def
1 begincodespacerange <0000> <FFFF> endcodespacerange
3 beginbfchar <0020> <0005> <0041> <0006> <2041> <0024> endbfchar
endcmap CMapName currentdict /CMap defineresource pop end end";
let resources = format!(
"<< /Font << /F1 << /Type /Font /Subtype /Type0 /BaseFont /JetBrainsMono-Regular /Encoding {encoding} /DescendantFonts [<< /Type /Font /Subtype /CIDFontType2 /BaseFont /JetBrainsMono-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /DW 600 /CIDToGIDMap /Identity /FontDescriptor << /Type /FontDescriptor /FontName /JetBrainsMono-Regular /Flags 4 /FontBBox [-1000 -300 1000 1000] /ItalicAngle 0 /Ascent 1020 /Descent -300 /CapHeight 730 /StemV 80 /FontFile2 5 0 R >> >>] >> >> >>"
);
let content = format!("BT /F1 40 Tf 10 20 Td {text} Tj ET");
let font_dict = format!("<< /Length {} >>", font.len());
let cmap_dict = format!(
"<< /Type /CMap /CMapName /Test-H /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /Length {} >>",
cmap.len()
);
let page = render_first_page(image_cache_pdf(&content, &resources, &[(&font_dict, font), (&cmap_dict, cmap)]));
page.rgba.to_vec()
};
let blank = render("/Identity-H", "<>");
let reference = render("/Identity-H", "<0024>");
assert_ne!(reference, blank, "the reference glyph draws");
assert_eq!(render("6 0 R", "<2041>"), reference, "<2041> is one code, CID 0x24");
assert_ne!(render("/Identity-H", "<00050006>"), reference, "the two-glyph misreading looks different");
}
/// A page whose content expands far beyond its compressed stream stops at the expansion budget,
/// which is 64 times the compressed size and at least `MAX_DECODED_STREAM`, and the page says so.
#[test]
@@ -7459,8 +7685,8 @@ fn raw_crop_unit_floor_preserves_nearly_zero_fallback() {
let bytes = raw_crop_unit_pdf(unit, 0, [0.0, 0.0, side, side], None);
let pdf = Pdf::new(Arc::new(bytes)).expect("existing degenerate-page fallback");
let page = pdf.pages().first().expect("one page");
// Existing A4 raw fallback remains raw A4, then scales by the page unit.
let expected = (595.2755905511812_f64 as f32, 841.8897637795277_f64 as f32);
// The raw fallback is US Letter (hayro-syntax patch 44), then scales by the page unit.
let expected = (612.0, 792.0);
assert_eq!(page.base_dimensions(), expected, "unit={unit}, raw side={side}");
assert_eq!(page.render_dimensions(), (expected.0 * unit as f32, expected.1 * unit as f32));
}
@@ -8922,6 +9148,9 @@ mod type3_proc_cache;
// hayro-interpret patch 91).
mod failed_image_decodes;
// A page past its rasterizer coverage leaves the rest out and says so (vendored vello_cpu patch 1).
mod coverage_budget;
// A symbolic TrueType font draws through its (3, 0) cmap subtable first (#4156).
mod symbolic_truetype_cmap;
@@ -9721,7 +9950,8 @@ mod default_colour_spaces;
// (vendored hayro-syntax patch 28, hayro-interpret patch 55).
mod devicen_inks;
// #5430: PNG-predicted Flate images are undone byte by byte (vendored hayro-syntax patch 35).
// #5430: PNG-predicted Flate images are undone byte by byte (vendored hayro-syntax patch 35), and
// PDFBOX-3932: rows that carry no filter-type byte are read as samples (patch 40).
mod png_predicted_images;
// TIFF-predicted Flate images at 8 and 16 bits are undone byte by byte (vendored hayro-syntax
@@ -9825,6 +10055,183 @@ fn a_rebuilt_table_finds_an_object_whose_header_names_another_generation() {
assert_eq!((page.width, page.height), (200, 100));
}
/// A one-page file: a 200 x 100 page, its offsets right.
fn one_page_file() -> Vec<u8> {
one_page_file_with(false)
}
/// The same, and with `stale` a 999 x 999 copy of the page after it that the table does not
/// name: a file read through its table shows 200 x 100, one rebuilt from object headers 999 x 999.
fn one_page_file_with(stale: bool) -> Vec<u8> {
let mut out = b"%PDF-1.4\n".to_vec();
let mut offsets = Vec::new();
for body in
["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] >>"]
{
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes());
}
if stale {
out.extend_from_slice(b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 999 999] >>\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(b"xref\n0 4\n0000000000 65535 f \n");
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
out
}
/// `base` with an incremental update appended: a new 300 x 150 first page whose content stream is
/// `padding` bytes long, and the page tree redefined to hold both pages. With `footer` the update
/// ends in its trailer, `startxref` and `%%EOF`; without, it stops after its cross-reference rows,
/// as a save cut short does.
fn with_update(base: &[u8], padding: usize, footer: bool) -> Vec<u8> {
let prev = base
.windows(9)
.rposition(|w| w == b"startxref")
.map(|p| String::from_utf8_lossy(&base[p + 9..]).split_whitespace().next().unwrap_or("").to_string())
.unwrap();
let mut out = base.to_vec();
let mut offsets = vec![(4, out.len())];
out.extend_from_slice(b"4 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 150] /Contents 5 0 R >>\nendobj\n");
offsets.push((5, out.len()));
out.extend_from_slice(format!("5 0 obj\n<< /Length {padding} >>\nstream\n").as_bytes());
out.extend(std::iter::repeat_n(b' ', padding));
out.extend_from_slice(b"\nendstream\nendobj\n");
offsets.push((2, out.len()));
out.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [4 0 R 3 0 R] /Count 2 >>\nendobj\n");
let xref = out.len();
out.extend_from_slice(b"xref\n0 1\n0000000000 65535 f \n");
for (num, o) in offsets {
out.extend_from_slice(format!("{num} 1\n{o:010} 00000 n \n").as_bytes());
}
if footer {
out.extend_from_slice(format!("trailer\n<< /Size 6 /Root 1 0 R /Prev {prev} >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
}
out
}
/// What the renderer shows of `file` (page count and the first page's size) and what the
/// document model reads (the page tree's /Count), for comparing the two.
fn renderer_and_model_pages(file: Vec<u8>) -> ((usize, (u32, u32)), i64) {
let bytes = Arc::new(file);
let mut r = PageRenderer::new(bytes.clone(), RenderConfig::default());
let count = r.page_count();
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
assert!(page.error.is_none(), "{:?}", page.error);
let doc = pdfcraft_cos::Document::open(bytes).unwrap();
let catalog = doc.get(doc.root().unwrap());
let pages = doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap());
let model = pages.as_dict().and_then(|d| d.int(b"Count")).unwrap();
((count, (page.width, page.height)), model)
}
/// PDFBOX-3934, PDFBOX-3787: the last incremental update was cut off after its cross-reference
/// rows, with no trailer, `startxref` or `%%EOF`, so the last `startxref` in the file is the
/// older revision's, more than the footer window (4 KiB) from the end, and objects follow it.
/// The update's objects are the latest revision (ISO 32000-2 §7.5.6); the document model rebuilds
/// the file from object headers and reads them. The renderer read the older table the last
/// `startxref` names instead and showed one page where the model has two. It now rebuilds too.
#[test]
fn an_update_cut_off_before_its_trailer_shows_its_pages_as_the_model_reads_them() {
let file = with_update(&one_page_file(), 5000, false);
assert_eq!(model_first_media_box(&file), [0.0, 0.0, 300.0, 150.0], "the model's first page is the update's");
let ((count, size), model) = renderer_and_model_pages(file);
assert_eq!(model, 2, "the model reads the update");
assert_eq!(count, 2, "the renderer shows the update's page tree");
assert_eq!(size, (300, 150), "and its new first page");
}
/// The same update with its footer is read through its table as before: renderer and model both
/// show its two pages.
#[test]
fn a_complete_update_reads_as_before() {
let ((count, size), model) = renderer_and_model_pages(with_update(&one_page_file(), 5000, true));
assert_eq!((count, size, model), (2, (300, 150), 2));
}
/// A file with data after `%%EOF` that holds no object header (a web page glued to a served file)
/// is read through its table by the renderer, as before: the file has a stale copy of its page that
/// only a rebuild would show. The model, finding no `startxref` in its footer window, rebuilds from
/// object headers and takes that stale 999 x 999 copy, so here the two still differ, as they did
/// before this change.
#[test]
fn trailing_data_without_objects_reads_as_before() {
let mut junk = one_page_file_with(true);
junk.extend_from_slice(b"<html><p>1 0 objection: 12 0 endobj, 3 0obj, 4 0 obj_name, 4294967296 0 obj</p>");
junk.extend(std::iter::repeat_n(b' ', 5000));
junk.extend_from_slice(b"</html>\n");
assert_eq!(model_first_media_box(&junk), [0.0, 0.0, 999.0, 999.0], "the model's rebuilt page");
let ((count, size), model) = renderer_and_model_pages(junk);
assert_eq!((count, size, model), (1, (200, 100), 1));
}
/// The /MediaBox of the first page in the document model's page tree (its first /Kids entry).
fn model_first_media_box(file: &[u8]) -> Vec<f64> {
let doc = pdfcraft_cos::Document::open(Arc::new(file.to_vec())).unwrap();
let catalog = doc.get(doc.root().unwrap());
let pages = doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap());
let kid = doc.resolve(pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap().first().unwrap());
kid.as_dict().unwrap().get(b"MediaBox").unwrap().as_array().unwrap().iter().filter_map(|x| x.as_f64()).collect()
}
/// A cut-off update on `one_page_file` whose object headers are written `N G obj{after}<<` in place
/// of `N G obj\n<<`. Only the update is rewritten, so the base file's table stays at the offset
/// its `startxref` names and reading it raises no error that would rebuild the file anyway: the
/// update is found only if the header scan accepts what follows `obj`.
fn cut_off_update_with_headers(after: &str) -> Vec<u8> {
let base = one_page_file();
let mut file = with_update(&base, 5000, false);
let update = String::from_utf8(file.split_off(base.len())).unwrap();
assert_eq!(update.matches(" obj\n<<").count(), 3, "every updated header is rewritten");
file.extend_from_slice(update.replace(" obj\n<<", &format!(" obj{after}<<")).as_bytes());
file
}
/// A cut-off update whose object headers run straight into their dictionaries (`4 0 obj<<`) is
/// still found: a delimiter ends the keyword (ISO 32000-2 §7.2.3, Table 2), and the renderer
/// shows the update's new first page, as the model does.
#[test]
fn a_cut_off_update_with_obj_followed_by_a_delimiter_is_rebuilt() {
let file = cut_off_update_with_headers("");
assert_eq!(model_first_media_box(&file), [0.0, 0.0, 300.0, 150.0], "the model's first page is the update's");
let ((count, size), model) = renderer_and_model_pages(file);
assert_eq!((count, size, model), (2, (300, 150), 2));
}
/// The same with a comment straight after the keyword (`4 0 obj%comment`): `%` is a delimiter
/// that starts a comment (ISO 32000-2 §7.2.3 Table 2, §7.2.4), so it ends the keyword too.
#[test]
fn a_cut_off_update_with_obj_followed_by_a_comment_is_rebuilt() {
let file = cut_off_update_with_headers("%comment\n");
assert_eq!(model_first_media_box(&file), [0.0, 0.0, 300.0, 150.0], "the model's first page is the update's");
let ((count, size), model) = renderer_and_model_pages(file);
assert_eq!((count, size, model), (2, (300, 150), 2));
}
/// A cut-off update that ends within the footer window leaves the older `startxref` near the end,
/// which the model reads too: both keep the older revision there.
#[test]
fn a_short_cut_off_update_reads_as_before() {
let ((count, size), model) = renderer_and_model_pages(with_update(&one_page_file(), 100, false));
assert_eq!((count, size, model), (1, (200, 100), 1));
}
/// A file cut short in the middle of its last update, whose new page tree names a page that is
/// not there: the rebuilt table finds no page, so the older table is read and the file still
/// opens with the page it had, rather than not at all.
#[test]
fn an_update_cut_off_before_its_new_pages_keeps_the_older_table() {
let file = String::from_utf8(with_update(&one_page_file(), 5000, false)).unwrap().replace("4 0 obj", "9 0 obj").into_bytes();
let mut r = PageRenderer::new(Arc::new(file), RenderConfig::default());
assert_eq!(r.page_count(), 1);
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (200, 100));
}
/// #3109 / #3116: clipping-only text (`7 Tr`) installs a clip for subsequent painting
/// but draws no pixels itself.
#[test]
@@ -10367,3 +10774,170 @@ fn a_stream_with_a_crypt_filter_of_its_own_is_drawn() {
}
}
}
/// Data after `%%EOF` that holds an object header, past the footer window, is read the way the
/// document model reads it: the model rebuilds the file from object headers and takes the later
/// definition of the page, and the renderer shows that same page, so the two still agree.
#[test]
fn trailing_data_with_an_object_header_reads_as_the_model_reads_it() {
let mut f = one_page_file();
f.extend_from_slice(
b"<html><pre>
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 999 999] >>
endobj
</pre>",
);
f.extend(std::iter::repeat_n(b' ', 5000));
let doc = pdfcraft_cos::Document::open(Arc::new(f.clone())).unwrap();
let page = doc.get(pdfcraft_cos::ObjRef::new(3, 0));
let model_box = page.as_dict().and_then(|d| d.get(b"MediaBox").cloned());
assert_eq!(model_box, Some(pdfcraft_cos::Object::Array([0, 0, 999, 999].map(pdfcraft_cos::Object::Int).to_vec())), "the model");
let ((count, size), model) = renderer_and_model_pages(f);
assert_eq!((count, size, model), (1, (999, 999), 1), "the renderer, as the model");
}
/// An encrypted file whose last update was cut off is still refused with a wrong password, and
/// opened with the right one.
#[test]
fn an_encrypted_file_with_a_cut_off_update_still_needs_its_password() {
use pdfcraft_cos::{Algorithm, Document, NewEncryption, SaveOptions, write_full};
let mut doc = Document::open(Arc::new(one_page_file())).unwrap();
doc.set_encryption(&NewEncryption {
algorithm: Algorithm::Aes128,
user_password: "user",
owner_password: "owner",
permissions: -1,
encrypt_metadata: true,
seed: [7; 32],
})
.unwrap();
let base = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
let file = Arc::new(with_update(&base, 5000, false));
let open = |password: &str| {
let mut r = PageRenderer::new(file.clone(), RenderConfig { password: Some(Arc::from(password)), ..RenderConfig::default() });
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
(r.page_count(), page.error.is_none(), (page.width, page.height))
};
let (_, drawn, _) = open("wrong");
assert!(!drawn, "a wrong password opens nothing");
let (count, drawn, _) = open("user");
assert!(count >= 1 && drawn, "the right one opens it");
}
/// An encrypted file whose cut-off update ends in a trailer dictionary without `/Encrypt`: the rebuild would take that
/// later dictionary and read the encrypted content as plain bytes, so it is not taken. A wrong password is still
/// refused, and the right one opens the table the last `startxref` names, its content decrypted and drawn.
#[test]
fn a_rebuild_whose_trailer_lacks_the_encryption_dictionary_is_not_taken() {
use pdfcraft_cos::{Algorithm, Document, NewEncryption, SaveOptions, write_full};
let plain = b"%PDF-1.4
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R >> endobj
4 0 obj << /Length 25 >> stream
0 0 1 rg 10 10 50 50 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF"
.to_vec();
let mut doc = Document::open(Arc::new(plain)).unwrap();
doc.set_encryption(&NewEncryption {
algorithm: Algorithm::Aes128,
user_password: "user",
owner_password: "owner",
permissions: -1,
encrypt_metadata: true,
seed: [7; 32],
})
.unwrap();
let root = doc.root().unwrap();
let mut file = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
// The update: one object, past the 4 KiB footer window, and a trailer without `/Encrypt`, cut off before its
// `startxref`.
file.extend_from_slice(b"900 0 obj\n<< /Length 5000 >>\nstream\n");
file.extend(std::iter::repeat_n(b' ', 5000));
file.extend_from_slice(format!("\nendstream\nendobj\ntrailer\n<< /Size 901 /Root {} {} R >>\n", root.num, root.generation).as_bytes());
let file = Arc::new(file);
let open = |password: &str| {
let mut r = PageRenderer::new(file.clone(), RenderConfig { password: Some(Arc::from(password)), ..RenderConfig::default() });
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
page.error.is_none().then(|| rgb_at(&page.rgba, page.width as usize, 30, page.height as usize - 30))
};
assert_eq!(open("wrong"), None, "a wrong password opens nothing");
assert_eq!(open(""), None, "nor does none");
assert_eq!(open("user"), Some([0, 0, 255]), "the right one draws the decrypted content");
}
/// A stream with no `endstream` after it, as a producer that writes `endstrea` after every stream
/// leaves it, is the bytes its `/Length` gives: "how many bytes of the PDF file are used for the
/// stream's data" (ISO 32000-2 7.3.8.2). Nothing found its end, so the page's content was lost and
/// the page drew blank, while other readers draw it. Vendored hayro-syntax patch 36.
#[test]
fn a_content_stream_without_endstream_is_drawn_to_its_length() {
use super::*;
let content = "1 0 0 rg 0 0 200 200 re f";
let centre = |end: &str| -> [u8; 4] {
let page = format!(
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\n{end}\nendobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
content.len()
);
let page = PageRenderer::new(Arc::new(page.into_bytes()), RenderConfig::default()).render(RenderRequest {
page: 0,
kind: RequestKind::Pixels,
tile: None,
scale: 1.0,
tag: 0,
preview: false,
});
let start = (100 * page.width as usize + 100) * 4;
let mut pixel = [0; 4];
pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel"));
pixel
};
assert_eq!(centre("endstream"), [255, 0, 0, 255]);
for end in ["endstrea", "", "end"] {
assert_eq!(centre(end), [255, 0, 0, 255], "{end:?}");
}
}
/// A ToUnicode CMap with a token the scanner can't read before its mappings (a stray `>` after
/// a CMap name that holds one, as in pdf.js `issue11651.pdf`; a CMap name with spaces in it, as
/// in `issue7492.pdf`) still maps its codes (vendored hayro-cmap patch 3, hayro-postscript
/// patch 2): the scan stopped at that token, so the page's text could not be copied or found.
#[test]
fn a_to_unicode_cmap_reads_its_mappings_past_a_token_it_cannot_parse() {
use hayro::hayro_interpret::hayro_cmap::{BfString, CMap};
let body = "1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n2 beginbfchar\n<002B> <0048>\n<0048> <0065>\nendbfchar\n1 beginbfrange\n<0050> <0052> <0061>\nendbfrange\nendcmap\n";
let stray = format!(
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap\n/CIDSystemInfo <</Registry (Adobe) /Ordering (UC>) /Supplement 0>> def\n/CMapName /Adobe-Identity-UC> def\n/CMapType 2 def\n{body}"
);
let spaced = format!(
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap\n/CMapName /-usr-share-fonts-Panton Family-Fontfabric - Panton.otf,000-UTF16 def\n/CMapType 2 def\n{body}"
);
for (what, data) in [("a stray >", stray), ("a name with spaces", spaced)] {
let cmap = CMap::parse(data.as_bytes(), |_| None).unwrap_or_else(|| panic!("{what}: parsed"));
assert_eq!(cmap.lookup_bf_string(0x2B), Some(BfString::Char('H')), "{what}");
assert_eq!(cmap.lookup_bf_string(0x48), Some(BfString::Char('e')), "{what}");
assert_eq!(cmap.lookup_bf_string(0x51), Some(BfString::Char('b')), "{what}: bfrange");
}
// Unreadable tokens are stepped over a bounded number of times (1,024 per CMap): a long run
// of them ends the scan, and the mappings read before it are kept.
let flood = format!("{body}{}", "-".repeat(1 << 16));
assert!(CMap::parse(flood.as_bytes(), |_| None).is_some_and(|c| c.lookup_bf_string(0x2B).is_some()));
// A run past the limit before the mappings ends the scan before them, and a CMap that read
// no mappings, like one of unreadable tokens only, reports failure. (A run of `-` with
// nothing between them is one token, as in PostScript, so these are spaced.)
let flood_first = format!("{}\n{body}", "- ".repeat(1025));
assert!(CMap::parse(flood_first.as_bytes(), |_| None).is_none());
assert!(CMap::parse("-".repeat(1 << 16).as_bytes(), |_| None).is_none());
// Unclosed strings after the mappings: the scan ends and keeps them. (That the first one,
// which reads to the end of the data, is not read again for each `(` inside it is
// hayro-postscript's `parse_object_or_skip_does_not_read_a_failed_read_again`.)
let unclosed = format!("{body}{}{}", "(".repeat(4096), "x".repeat(1 << 16));
assert!(CMap::parse(unclosed.as_bytes(), |_| None).is_some_and(|c| c.lookup_bf_string(0x48).is_some()));
}
@@ -0,0 +1,183 @@
//! A page that paints the same content over and over runs out of rasterizer coverage: what is
//! past it is left out and said to be, rather than panicking once a strip's alpha index no longer
//! fits (PDFBOX-5415's map page drew one form 2^22 times and panicked at 7 GB).
use super::*;
use std::sync::Mutex;
/// A blue circle, filled.
const FILLED_CIRCLE: &str = "0 0 1 rg 100 160 m 133 160 160 133 160 100 c 160 67 133 40 100 40 c 67 40 40 67 40 100 c 40 133 67 160 100 160 c f";
/// A page whose form `F0` draws `F1` twice, `F1` draws `F2` twice, and so on `levels` deep; the
/// last form fills a circle. The page draws `F0` once, so the circle is filled 2^`levels` times.
fn doubling_forms(levels: usize) -> Vec<u8> {
doubling_forms_then(levels, FILLED_CIRCLE, "")
}
/// [`doubling_forms`] with the last form painting `leaf` instead, and the page painting `after`
/// once the forms are done.
fn doubling_forms_then(levels: usize, leaf: &str, after: &str) -> Vec<u8> {
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
// Object 4 is the page's content; forms start at 5.
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /F0 5 0 R >> >> >>".into(),
String::new(),
];
let content = format!("/F0 Do {after}");
objs[3] = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());
for level in 0..=levels {
let (body, resources) = if level == levels {
(leaf.to_string(), String::new())
} else {
("/F Do /F Do".to_string(), format!("/Resources << /XObject << /F {} 0 R >> >>", 6 + level))
};
objs.push(format!("<< /Type /XObject /Subtype /Form /BBox [0 0 200 200] {resources} /Length {} >>\nstream\n{body}\nendstream", body.len()));
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// Renders page 1 with the rasterizer kept to `max_coverage` values, returning its pixels and
/// whether it said content was left out.
fn render_kept_to(pdf: Vec<u8>, max_coverage: Option<usize>) -> (Vec<u8>, bool) {
let parsed = Pdf::new(Arc::new(pdf)).unwrap();
let truncated = Arc::new(Mutex::new(false));
let seen = truncated.clone();
let settings = InterpreterSettings {
warning_sink: Arc::new(move |w| {
if matches!(w, InterpreterWarning::ContentTruncated) {
*seen.lock().unwrap() = true;
}
}),
..Default::default()
};
let pixmap =
hayro::render(&parsed.pages()[0], &RenderCache::new(), &settings, &RenderSettings { bg_color: WHITE, max_coverage, ..Default::default() });
let cut = *truncated.lock().unwrap();
(pixmap.data_as_u8_slice().to_vec(), cut)
}
#[test]
fn a_page_past_its_coverage_leaves_the_rest_out_and_says_so() {
// 2^10 circles, each storing its edge tiles' coverage.
let pdf = doubling_forms(10);
let (whole, whole_cut) = render_kept_to(pdf.clone(), None);
assert!(!whole_cut, "within the limit nothing is left out");
let blue = |px: &[u8]| px.as_chunks::<4>().0.iter().filter(|p| p[2] > 200 && p[0] < 50).count();
assert!(blue(&whole) > 10_000, "the circle is drawn");
// Room for a few circles only: the rest are left out, the page still renders, and says so.
let (kept, cut) = render_kept_to(pdf, Some(200_000));
assert!(cut, "the page says content was left out");
assert_eq!(kept.len(), whole.len());
assert!(blue(&kept) > 10_000, "the circles drawn before the limit stay: {}", blue(&kept));
}
/// Pixels of `px` that are mostly red.
fn red(px: &[u8]) -> usize {
px.as_chunks::<4>().0.iter().filter(|p| p[0] > 200 && p[2] < 50).count()
}
/// The page paints `leaf` 2^10 times through forms and then `marker` (red, away from the leaf's
/// drawing) once, both through the same rasterizer path. Kept to 200,000 coverage values, the
/// marker is past the limit: it is left out and the page says so. Unlimited, it is drawn.
fn leaves_out_past_the_limit(leaf: &str, marker: &str) {
let pdf = doubling_forms_then(10, leaf, marker);
let (whole, whole_cut) = render_kept_to(pdf.clone(), None);
assert!(!whole_cut, "within the limit nothing is left out");
assert!(red(&whole) > 50, "unlimited, the marker is drawn: {}", red(&whole));
let (kept, cut) = render_kept_to(pdf, Some(200_000));
assert!(cut, "the page says content was left out");
assert_eq!(kept.len(), whole.len());
assert_eq!(red(&kept), 0, "the marker, past the limit, is left out");
}
#[test]
fn strokes_past_the_coverage_are_left_out() {
// A stroked circle and a stroked curve: neither is a rectangle, so both go through
// `stroke_path`, and the page's only coverage is strokes.
leaves_out_past_the_limit(
"0 0 1 RG 4 w 100 160 m 133 160 160 133 160 100 c 160 67 133 40 100 40 c 67 40 40 67 40 100 c 40 133 67 160 100 160 c S",
"1 0 0 RG 6 w 170 10 m 180 30 190 10 195 30 c S",
);
}
#[test]
fn fast_rectangles_past_the_coverage_are_left_out() {
// Axis-aligned rectangles fill through `fill_rect_fast`, and are the page's only coverage.
leaves_out_past_the_limit("0 0 1 rg 40.5 40.5 119 119 re f", "1 0 0 rg 170.5 10.5 20 20 re f");
}
#[test]
fn layer_clips_past_the_coverage_are_left_out() {
// hayro pushes no clipped layers (its clips go through `push_clip_path`), so the layer clip
// is driven through the vello_cpu context directly, with clips as the scene's only coverage.
use hayro::vello_cpu::color::palette::css::RED;
use hayro::vello_cpu::kurbo::{Circle, Rect, Shape};
use hayro::vello_cpu::{Pixmap, RenderContext, Resources};
let clip = Circle::new((50.0, 50.0), 40.0).to_path(0.1);
let mut ctx = RenderContext::new(100, 100);
// Room while fewer than 30,000 - 2 x 100 x 100 = 10,000 values are stored.
ctx.set_max_coverage(30_000);
assert!(!ctx.coverage_full(), "an empty scene has room");
// Below the limit the clip is kept: a fill inside it shows within the circle only.
ctx.push_clip_layer(&clip);
assert!(!ctx.coverage_full(), "the first clip fits");
ctx.set_paint(RED);
ctx.fill_rect(&Rect::new(0.0, 0.0, 100.0, 100.0));
ctx.pop_layer();
assert!(!ctx.coverage_full(), "the fill inside it fits too");
let mut pixmap = Pixmap::new(100, 100);
ctx.flush();
ctx.render_to_pixmap(&mut Resources::new(), &mut pixmap);
let px = |x: usize, y: usize| pixmap.data_as_u8_slice().get((y * 100 + x) * 4..(y * 100 + x) * 4 + 4).map(<[u8]>::to_vec);
assert_eq!(px(50, 50), Some(vec![255, 0, 0, 255]), "inside the clip");
assert_eq!(px(2, 2), Some(vec![0, 0, 0, 0]), "outside the clip");
ctx.reset();
for _ in 0..1_000 {
ctx.push_clip_layer(&clip);
ctx.pop_layer();
}
assert!(ctx.coverage_full(), "the clips past the limit were left out, and that is said");
// The scene still rasterizes: the layers left unclipped have nothing drawn into them.
ctx.flush();
ctx.render_to_pixmap(&mut Resources::new(), &mut pixmap);
ctx.reset();
assert!(!ctx.coverage_full(), "a reset scene starts with room");
}
/// Past the limit the clip itself is refused, not only what is drawn into its layer: with room
/// for exactly one path, a fill takes it and the clip pushed next is what finds the scene full.
#[test]
fn a_layer_clip_past_the_coverage_is_itself_left_out() {
use hayro::vello_cpu::RenderContext;
use hayro::vello_cpu::color::palette::css::RED;
use hayro::vello_cpu::kurbo::{Circle, Rect, Shape};
let mut ctx = RenderContext::new(100, 100);
// Room while fewer than 20,001 - 2 x 100 x 100 = 1 value is stored: for one path.
ctx.set_max_coverage(20_001);
ctx.set_paint(RED);
ctx.fill_path(&Rect::new(10.5, 10.5, 30.5, 30.5).to_path(0.1));
assert!(!ctx.coverage_full(), "the fill fits");
ctx.push_clip_layer(&Circle::new((50.0, 50.0), 40.0).to_path(0.1));
assert!(ctx.coverage_full(), "the clip, past the limit, is left out and that is said");
ctx.pop_layer();
}
@@ -1,7 +1,8 @@
//! #5430: Flate images with PNG predictors (ISO 32000-2 §7.4.4.4) are undone a byte row at a
//! time (vendored hayro-syntax patch 35). The bit-level decoder they went through before dropped
//! the last byte of each filtered row that ends part-way through a pixel, so every later row
//! started a byte early and the picture came out sheared.
//! started a byte early and the picture came out sheared. An image whose rows carry no filter-type
//! byte at all, although it declares a PNG predictor, is read as its samples (patch 40).
use super::rgb_at;
use crate::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
@@ -67,3 +68,73 @@ fn a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared() {
}
}
}
/// PDFBOX-3932: a writer declared `/Predictor 15` for an image and its soft mask without applying
/// it, so their rows carry no PNG filter-type byte (vendored hayro-syntax patch 40). Read with the
/// predictor, each row's first sample was taken for a filter type and the image came out a row
/// short and sheared; PDFium, MuPDF and Poppler draw the samples as they are.
#[test]
fn an_image_and_soft_mask_whose_rows_carry_no_png_tags_draw_their_samples() {
// Four rows of three 8-bit RGB pixels, every sample above 4 (no byte reads as a filter type
// that would happen to undo cleanly), and an opaque mask of three grey samples a row.
let rgb: Vec<u8> = ROWS.iter().flat_map(|px| (0..3).flat_map(move |_| px.map(|s| s * 16 + 8))).collect();
let mask = [255u8; 12];
let pdf = image_pdf(&[("/ColorSpace /DeviceRGB /SMask 6 0 R", "/Colors 3", &rgb), ("/ColorSpace /DeviceGray", "/Colors 1", &mask)]);
let page = render(pdf);
for (row, px) in ROWS.iter().enumerate() {
let expected = px.map(|s| s * 16 + 8);
for column in 0..3 {
let (x, y) = (column * 50 + 25, row * 50 + 25);
assert_eq!(rgb_at(&page.rgba, WIDTH, x, y), expected, "image row {row}, column {column}");
}
}
}
/// Data one byte short of rows without tags is neither layout: it is decoded with the predictor
/// as before, and does not panic.
#[test]
fn an_image_one_byte_short_of_untagged_rows_still_goes_through_the_predictor() {
let rgb = [0u8; 35];
let page = render(image_pdf(&[("/ColorSpace /DeviceRGB", "/Colors 3", &rgb)]));
assert_eq!((page.width, page.height), (WIDTH as u32, HEIGHT as u32));
}
/// Image objects 5, 6, … of a 3 × 4 8-bit image each, Flate-compressed with `/Predictor 15`
/// declared and their `data` stored as given; the page draws object 5 over all of itself.
fn image_pdf(images: &[(&str, &str, &[u8])]) -> Vec<u8> {
let content = "q 150 0 0 200 0 0 cm /Im0 Do Q";
let mut pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 150 200] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n",
content.len()
)
.into_bytes();
for (i, (keys, colors, data)) in images.iter().enumerate() {
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(data).unwrap();
let data = encoder.finish().unwrap();
pdf.extend_from_slice(
format!(
"{} 0 obj << /Type /XObject /Subtype /Image /Width 3 /Height 4 {keys} /BitsPerComponent 8 \
/Filter /FlateDecode /DecodeParms << /Predictor 15 {colors} /BitsPerComponent 8 /Columns 3 >> /Length {} >> stream\n",
5 + i,
data.len()
)
.as_bytes(),
);
pdf.extend_from_slice(&data);
pdf.extend_from_slice(b"\nendstream endobj\n");
}
pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF");
pdf
}
fn render(pdf: Vec<u8>) -> crate::RenderedPage {
let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (WIDTH as u32, HEIGHT as u32));
page
}
+3 -1
View File
@@ -144,7 +144,9 @@ pub(crate) struct LazyStructure {
impl LazyStructure {
pub(crate) fn new(bytes: Arc<Vec<u8>>, password: Option<&str>) -> std::result::Result<Self, String> {
let source = Document::open_with_stream_limit(bytes, password, 256 << 20).map_err(|e| e.to_string())?;
if !source.repair_log().is_empty() {
// A file rebuilt because its last update has no `/Prev` is read here as rebuilt: the
// renderer rebuilds it too, and the fallback reader would follow the cut-off chain.
if !source.repair_log().is_empty() && !source.rebuilt_unlinked_update() {
return Err("the structure requires compatibility repair".into());
}
let trailer = convert_dict(source.trailer(), 0).map_err(|e| e.to_string())?;
+106 -8
View File
@@ -2412,29 +2412,37 @@ fn text_outside_the_crop_box_is_clipped() {
}
}
// A CropBox that is zero-area or disjoint from the MediaBox leaves an empty visible region,
// even when a glyph straddles the clamped intersection point.
for crop in [[200, 200, 200, 200], [100, 200, 300, 200], [500, 500, 600, 600]] {
// A CropBox that is zero-area or disjoint from the MediaBox is read as a missing one
// (vendored hayro-syntax patch 38): the visible region is the MediaBox, as on a page without
// a CropBox, not a region of no area.
let text_with = |crop: Option<[i64; 4]>| {
let mut doc = Document::with_version("1.7");
let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" });
let content = doc.add_object(Stream::new(dictionary! {}, ops.as_bytes().to_vec()));
let pages = doc.new_object_id();
let page = doc.add_object(dictionary! {
let mut page = dictionary! {
"Type" => "Page",
"Parent" => pages,
"MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)],
"CropBox" => crop.map(Object::Integer).to_vec(),
"Resources" => dictionary! { "Font" => dictionary! { "F1" => font } },
"Contents" => content,
});
};
if let Some(crop) = crop {
page.set("CropBox", crop.map(Object::Integer).to_vec());
}
let page = doc.add_object(page);
doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into());
let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
doc.trailer.set("Root", catalog);
let mut bytes = Vec::new();
doc.save_to(&mut bytes).unwrap();
let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap();
let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap();
assert!(text.is_empty(), "expected no glyphs on {crop:?}, got {:?}", text.plain_text());
extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text()
};
let media = text_with(None);
assert!(media.contains("LeftOut") && media.contains("AboveOut") && !media.contains("OutSpan"), "{media:?}");
for crop in [[200, 200, 200, 200], [100, 200, 300, 200], [500, 500, 600, 600]] {
assert_eq!(text_with(Some(crop)), media, "{crop:?}");
}
}
@@ -2593,6 +2601,96 @@ fn cid_range_to_unicode_text(cmap: &[u8]) -> String {
extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text().trim().to_string()
}
/// A page box that encloses no area is read as a missing one (vendored hayro-syntax patch 38,
/// ISO 32000-2 §14.11.2): a `/MediaBox [0 0 0 0]` as the default page, a `/CropBox [0 0 0 0]`,
/// or one with nothing in common with the media box, as the media box. The page's visible region
/// had no area, so its text was all outside it and none was extracted (pdf.js
/// `boundingBox_invalid`). Each case is checked on both paths: the text is extracted, and the page
/// renders at the size of the box that stands in, with the text's ink on it. A page's own box
/// without area gives way to an inherited one with area; an inherited box without area falls
/// through to the default as an own one does.
#[test]
fn text_on_a_page_whose_box_has_no_area_is_extracted() {
use lopdf::{Document, Object, Stream, dictionary};
type Boxes = &'static [(&'static str, [i64; 4])];
/// (case, inherited boxes, the page's own boxes, where the word goes, expected size).
type Case = (&'static str, Boxes, Boxes, (i64, i64), (u32, u32));
/// A one-page document whose `/Pages` node carries `inherited`, and whose page carries `own`
/// and draws "Boxed" at `(x, y)` in Helvetica 30 pt.
fn boxed(inherited: Boxes, own: Boxes, (x, y): (i64, i64)) -> Vec<u8> {
let rect = |r: &[i64; 4]| r.iter().map(|v| Object::Integer(*v)).collect::<Vec<_>>();
let mut doc = Document::with_version("1.7");
let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" });
let pages = doc.new_object_id();
let ops = format!("BT /F1 30 Tf {x} {y} Td (Boxed) Tj ET");
let content = doc.add_object(Stream::new(dictionary! {}, ops.into_bytes()));
let mut page = dictionary! { "Type" => "Page", "Parent" => pages, "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content };
for (key, r) in own {
page.set(*key, rect(r));
}
let page = doc.add_object(page);
let mut node = dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1};
for (key, r) in inherited {
node.set(*key, rect(r));
}
doc.objects.insert(pages, node.into());
let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages});
doc.trailer.set("Root", catalog);
let mut bytes = Vec::new();
doc.save_to(&mut bytes).unwrap();
bytes
}
// US Letter (612 × 792 pt, hayro-syntax patch 44) covers 612 × 792 device pixels at scale 1.
const LETTER_PX: (u32, u32) = (612, 792);
let cases: [Case; 7] = [
("own MediaBox without area", &[], &[("MediaBox", [0, 0, 0, 0])], (25, 500), LETTER_PX),
("own CropBox without area", &[], &[("MediaBox", [0, 0, 800, 600]), ("CropBox", [0, 0, 0, 0])], (25, 500), (800, 600)),
(
"CropBox touching the media box at a corner",
&[],
&[("MediaBox", [0, 0, 600, 800]), ("CropBox", [600, 800, 1000, 1000])],
(25, 500),
(600, 800),
),
// Text past US Letter but inside the inherited box is kept.
(
"own MediaBox without area, inherited one with area",
&[("MediaBox", [0, 0, 1200, 1200])],
&[("MediaBox", [0, 0, 0, 0])],
(700, 900),
(1200, 1200),
),
(
"own CropBox without area, inherited one with area",
&[("CropBox", [0, 0, 300, 300])],
&[("MediaBox", [0, 0, 600, 800]), ("CropBox", [0, 0, 0, 0])],
(25, 100),
(300, 300),
),
("inherited MediaBox without area", &[("MediaBox", [0, 0, 0, 0])], &[], (25, 500), LETTER_PX),
("inherited CropBox outside the media box", &[("CropBox", [600, 600, 900, 900])], &[("MediaBox", [0, 0, 500, 500])], (25, 400), (500, 500)),
];
let mut failures = Vec::new();
for (case, inherited, own, at, size) in cases {
let bytes = boxed(inherited, own, at);
let pdf = Pdf::new(std::sync::Arc::new(bytes.clone())).unwrap();
let text = extract_page(&pdf, 0, &InterpreterSettings::default()).map(|t| t.plain_text().trim().to_string());
if text.as_deref() != Some("Boxed") {
failures.push(format!("{case}: text {text:?}"));
}
let mut renderer = crate::PageRenderer::new(std::sync::Arc::new(bytes), crate::RenderConfig::default());
let out = renderer.render(crate::RenderRequest { page: 0, kind: crate::RequestKind::Pixels, scale: 1.0, ..Default::default() });
let ink = out.rgba.as_chunks::<4>().0.iter().filter(|p| p[0] < 128 && p[1] < 128 && p[2] < 128).count();
if out.error.is_some() || (out.width, out.height) != size || ink < 50 {
failures.push(format!(
"{case}: rendered {}×{} with {ink} dark pixels ({:?}), expected {}×{} with ink",
out.width, out.height, out.error, size.0, size.1
));
}
}
assert!(failures.is_empty(), "{failures:#?}");
}
/// The glyph under a point is the first whose box holds it, edges included; between glyphs, none.
#[test]
fn the_glyph_at_a_point_is_the_first_whose_box_holds_it() {
+3 -2
View File
@@ -172,8 +172,9 @@ fn reconstructed_and_unreadable_objects_are_not_cached_as_complete() {
assert_eq!(cache.timestamp_candidates(&repaired), vec![ObjRef::new(3, 0)]);
}
// A single invalid keyword immediately before endobj is deliberately recovered as
// Null by parse_indirect. Use a stream without endstream to exercise a real read error.
let unreadable = fixture(&[STAMP, "<< /Length 0 >>\nstream\nunterminated"]);
// Null by parse_indirect. Use a stream with neither endstream nor /Length to exercise a real
// read error (with a /Length that fits, the stream is that many bytes).
let unreadable = fixture(&[STAMP, "<< >>\nstream\nunterminated"]);
assert!(unreadable.repair_log().is_empty());
assert!(unreadable.try_get(4).is_err());
assert!(unreadable.scan_objects_checked().any(|(r, object)| r.num == 4 && object.is_err()));
+112
View File
@@ -133,6 +133,118 @@ pub fn stream_obj(dict: &str, data: impl AsRef<[u8]>) -> Vec<u8> {
out
}
/// A three-page file (200, 300 and 400 points wide, in that order, the second with a link to
/// `https://example.com/`) whose page tree sits in an object stream of its first revision, plus an update that rewrites the catalog and the first
/// page in a cross-reference stream: with `/Prev` when `linked`, without it otherwise (the shape
/// of the signed PDFBOX-2720 and PDFBOX-2942 files). Without `/Prev` a reader that follows the
/// chain (ISO 32000-2 §7.5.6, §7.5.8.2) finds no page tree, and a scan for page dictionaries
/// outside object streams finds only the rewritten page.
pub fn update_without_prev(linked: bool) -> Vec<u8> {
let (mut out, eight) = unlinked_original();
// The update: the catalog and the first page again, and a section for them and itself.
let (catalog, first) = unlinked_rewrites(&mut out);
let nine = out.len();
let mut rows = Vec::new();
xref_row(&mut rows, 1, catalog, 0);
xref_row(&mut rows, 1, first, 0);
xref_row(&mut rows, 1, nine, 0);
let prev = if linked { format!(" /Prev {eight}") } else { String::new() };
out.extend_from_slice(
format!("9 0 obj\n<< /Type /XRef /Size 11 /Root 1 0 R /Index [1 1 3 1 9 1] /W [1 4 2]{prev} /Length {} >>\nstream\n", rows.len()).as_bytes(),
);
out.extend_from_slice(&rows);
out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{nine}\n%%EOF\n").as_bytes());
out
}
/// [`update_without_prev`] with the update written as a hybrid-reference section (ISO 32000-2
/// §7.5.8.4): a classic table whose trailer names a cross-reference stream with `/XRefStm`. That
/// stream belongs to the same update; it is not an earlier section, so without `/Prev` the page
/// tree is cut off just the same.
pub fn hybrid_update_without_prev(linked: bool) -> Vec<u8> {
let (mut out, eight) = unlinked_original();
let (catalog, first) = unlinked_rewrites(&mut out);
// The update's cross-reference stream, locating only itself.
let nine = out.len();
let mut rows = Vec::new();
xref_row(&mut rows, 1, nine, 0);
out.extend_from_slice(format!("9 0 obj\n<< /Type /XRef /Size 11 /Index [9 1] /W [1 4 2] /Length {} >>\nstream\n", rows.len()).as_bytes());
out.extend_from_slice(&rows);
out.extend_from_slice(b"\nendstream\nendobj\n");
let table = out.len();
out.extend_from_slice(format!("xref\n1 1\n{catalog:010} 00000 n \n3 1\n{first:010} 00000 n \n").as_bytes());
let prev = if linked { format!(" /Prev {eight}") } else { String::new() };
out.extend_from_slice(format!("trailer\n<< /Size 11 /Root 1 0 R /XRefStm {nine}{prev} >>\nstartxref\n{table}\n%%EOF\n").as_bytes());
out
}
/// One row of a cross-reference stream with `/W [1 4 2]`.
fn xref_row(out: &mut Vec<u8>, kind: u8, field: usize, index: u16) {
out.push(kind);
out.extend_from_slice(&u32::try_from(field).unwrap_or(u32::MAX).to_be_bytes());
out.extend_from_slice(&index.to_be_bytes());
}
/// The update both fixtures append: the catalog and the first page again. Their offsets.
fn unlinked_rewrites(out: &mut Vec<u8>) -> (usize, usize) {
let catalog = out.len();
out.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Lang (en) >>\nendobj\n");
let first = out.len();
out.extend_from_slice(b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Annots [] >>\nendobj\n");
(catalog, first)
}
/// The first revision of [`update_without_prev`], and the offset of its cross-reference stream.
fn unlinked_original() -> (Vec<u8>, usize) {
let row = xref_row;
let page = |width: u32, extra: &str| format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {width} 200] /Contents 4 0 R{extra} >>");
let mut out = b"%PDF-1.7\n".to_vec();
let one = out.len();
out.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
let four = out.len();
out.extend_from_slice(b"4 0 obj\n<< /Length 14 >>\nstream\n0 0 50 50 re f\nendstream\nendobj\n");
// Objects 2 (the page tree), 3, 6 and 7 (the pages) and 10 (the link) in object stream 5.
let members = [
(2, "<< /Type /Pages /Kids [3 0 R 6 0 R 7 0 R] /Count 3 >>".to_string()),
(3, page(200, "")),
(6, page(300, " /Annots [10 0 R]")),
(7, page(400, "")),
(10, "<< /Type /Annot /Subtype /Link /Rect [10 10 60 60] /A << /S /URI /URI (https://example.com/) >> >>".to_string()),
];
let (mut header, mut body) = (String::new(), String::new());
for (num, member) in &members {
header.push_str(&format!("{num} {} ", body.len()));
body.push_str(member);
body.push(' ');
}
let five = out.len();
out.extend_from_slice(
format!(
"5 0 obj\n<< /Type /ObjStm /N 5 /First {} /Length {} >>\nstream\n{header}{body}\nendstream\nendobj\n",
header.len(),
header.len() + body.len()
)
.as_bytes(),
);
let eight = out.len();
let mut rows = Vec::new();
row(&mut rows, 0, 0, 65535);
row(&mut rows, 1, one, 0);
row(&mut rows, 2, 5, 0);
row(&mut rows, 2, 5, 1);
row(&mut rows, 1, four, 0);
row(&mut rows, 1, five, 0);
row(&mut rows, 2, 5, 2);
row(&mut rows, 2, 5, 3);
row(&mut rows, 1, eight, 0);
row(&mut rows, 0, 0, 0);
row(&mut rows, 2, 5, 4);
out.extend_from_slice(format!("8 0 obj\n<< /Type /XRef /Size 11 /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", rows.len()).as_bytes());
out.extend_from_slice(&rows);
out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{eight}\n%%EOF\n").as_bytes());
(out, eight)
}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -561,7 +561,7 @@ mod tests {
assert!(app.optimize_run.is_none());
app.scan_clean_run = None;
app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Reduce, progress: Arc::default() });
app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Reduce, destination: None, progress: Arc::default() });
app.start_scan_clean();
assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Optimization is already running"));
assert!(app.scan_clean_run.is_none());
+8
View File
@@ -87,8 +87,16 @@ fn sign(id: &pdfcraft_engine::sign::pkcs12::DigitalId, file: Vec<u8>, more: impl
v.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0; RESERVE], hex: true }));
v.set(b"M".to_vec(), PdfString::literal(b"D:20261002120000+01'00'".to_vec()));
more(&mut v);
let certifies = v.get(b"Reference").is_some();
let sig = doc.add(Object::Dict(v));
doc.update_dict(ObjRef::new(4, 0), |d| d.set(b"V".to_vec(), Object::Ref(sig))).unwrap();
if certifies {
// A certification is the signature the catalog's /Perms /DocMDP names (ISO 32000-2, 12.8.2.2).
let mut perms = Dict::new();
perms.set(b"DocMDP".to_vec(), Object::Ref(sig));
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Perms".to_vec(), Object::Dict(perms))).unwrap();
}
let save = pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() };
let mut out = pdfcraft_cos::write_incremental(&doc, &save).unwrap();
let br = out.windows(MARK.len()).position(|w| w == MARK.as_bytes()).unwrap();
+15 -15
View File
@@ -46,8 +46,8 @@ milestone = "M1"
status = "shipped"
commands = ["file.open"]
tools = ["doc_open"]
evidence = ["crates/cos/src/document/tests.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document/tests.rs::self_referential_length_does_not_overflow", "crates/engine/src/tests.rs::a_repaired_file_is_rewritten_by_save_even_without_edits", "crates/automation/tests/it/automation.rs::saving_a_repaired_file_puts_the_repair_on_disk", "crates/engine/src/tests.rs::a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits", "crates/cos/src/document/tests.rs::a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0"]
notes = "Save and doc_save write a reconstructed file in full even when nothing was edited, so the repair reaches the disk and the saved file opens without one (#1937); a signed one keeps its bytes, so its signatures stay valid. A repair does not mark the document as having unsaved changes, so closing it does not offer to save the repaired file. A classic table numbered 1 n that begins with object 0's free entry is read from 0 (#2676) and logged."
evidence = ["crates/cos/src/document/tests.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document/tests.rs::self_referential_length_does_not_overflow", "crates/engine/src/tests.rs::a_repaired_file_is_rewritten_by_save_even_without_edits", "crates/automation/tests/it/automation.rs::saving_a_repaired_file_puts_the_repair_on_disk", "crates/engine/src/tests.rs::a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits", "crates/cos/src/document/tests.rs::a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0", "crates/render/src/raster/tests.rs::an_update_cut_off_before_its_trailer_shows_its_pages_as_the_model_reads_them", "crates/cos/src/document/tests.rs::an_update_without_prev_is_rebuilt_with_its_page_tree_and_a_repair_note", "crates/render/src/inspect/core.rs::an_update_without_prev_shows_the_page_tree_and_says_it_was_rebuilt", "crates/cos/src/document/tests.rs::a_hybrid_update_without_prev_is_rebuilt_too", "crates/render/src/inspect/core.rs::a_hybrid_update_without_prev_is_rebuilt_as_the_document_model_rebuilds_it"]
notes = "Save and doc_save write a reconstructed file in full even when nothing was edited, so the repair reaches the disk and the saved file opens without one (#1937); a signed one keeps its bytes, so its signatures stay valid. A repair does not mark the document as having unsaved changes, so closing it does not offer to save the repaired file. A classic table numbered 1 n that begins with object 0's free entry is read from 0 (#2676) and logged. A last update cut off before its trailer (objects after a last `startxref` more than 4 KiB from the end) is rebuilt from object headers by the renderer as by the document model, so both show its latest objects. A file whose last update has no /Prev (ISO 32000-2 §7.5.6, §7.5.8.2), so that its page tree is cut off the chain, (a hybrid section's /XRefStm stream counts as part of the same update) is rebuilt from its object headers by the document model and the renderer alike, with a repair note and an open warning (the signed PDFBOX-2720 and PDFBOX-2942 files)."
[[feature]]
id = "core.malformed-input-safety"
@@ -118,8 +118,8 @@ milestone = "M1"
status = "shipped"
commands = []
tools = []
evidence = ["crates/filters/src/lib.rs::chain_three_filters_with_predictor", "crates/filters/src/lzw.rs::spec_example", "crates/filters/src/predictor.rs::png_hand_vectors", "crates/filters/src/lib.rs::raw_flate_fallback_preserves_output_limits", "crates/cos/src/object.rs::raw_flate_fallback_streams_preserve_the_caller_limit", "crates/render/src/raster/tests/tiff_predicted_images.rs::an_eight_bit_rgb_image_with_tiff_predicted_rows_draws_its_colours", "crates/render/src/raster/tests/tiff_predicted_images.rs::a_sixteen_bit_grey_image_with_tiff_predicted_rows_draws_its_levels"]
notes = "Raw-deflate fallback preserves output limits in strict and tolerant decoding, including misleading zlib headers. The renderer undoes PNG predictors, and TIFF predictor 2 at 8 and 16 bits per component, a byte row at a time. Image codecs remain outside this general-purpose filter implementation."
evidence = ["crates/filters/src/lib.rs::chain_three_filters_with_predictor", "crates/filters/src/lzw.rs::spec_example", "crates/filters/src/predictor.rs::png_hand_vectors", "crates/filters/src/lib.rs::raw_flate_fallback_preserves_output_limits", "crates/cos/src/object.rs::raw_flate_fallback_streams_preserve_the_caller_limit", "crates/render/src/raster/tests/tiff_predicted_images.rs::an_eight_bit_rgb_image_with_tiff_predicted_rows_draws_its_colours", "crates/render/src/raster/tests/tiff_predicted_images.rs::a_sixteen_bit_grey_image_with_tiff_predicted_rows_draws_its_levels", "crates/filters/src/lib.rs::an_image_whose_rows_carry_no_png_tags_is_read_as_its_samples_and_reported", "crates/cos/src/budget.rs::an_image_whose_rows_carry_no_png_tags_decodes_to_its_samples"]
notes = "Raw-deflate fallback preserves output limits in strict and tolerant decoding, including misleading zlib headers. The renderer undoes PNG predictors, and TIFF predictor 2 at 8 and 16 bits per component, a byte row at a time. An image whose PNG predictor was declared but never applied (exactly /Height rows with no filter-type bytes) is read as its samples and reported as tolerated damage, in decoding and on screen. Image codecs remain outside this general-purpose filter implementation."
[[feature]]
id = "core.image-filters"
@@ -235,11 +235,11 @@ title = "Attachment-only encryption (/EFF) opens without a password"
area = "A"
tier = "P1"
milestone = "M1"
status = "partial"
status = "shipped"
commands = []
tools = []
evidence = []
notes = "two corpus files still ask for a password (M1.6)"
evidence = ["crates/cos/tests/it/efopen.rs::a_document_encrypting_only_its_embedded_files_opens_without_a_password", "crates/cos/tests/it/efopen.rs::saves_keep_the_encrypted_embedded_files_as_stored", "crates/render/src/inspect/tests.rs::a_document_encrypting_only_its_embedded_files_opens_without_a_password", "crates/engine/src/tests.rs::a_document_encrypting_only_its_embedded_files_opens_and_saves_without_a_password", "crates/organize/tests/efopen_attachments.rs::combining_does_not_copy_attachments_held_encrypted"]
notes = "a document whose encrypting crypt filters are all /AuthEvent /EFOpen, with /StmF and /StrF /Identity, opens without a password (ISO 32000-2 7.6.6, Table 25); with /EFF or a /Crypt filter of the file naming the filter; its attachments are listed but not read or copied without the password, and saves keep them encrypted (a full save is refused where the key depends on the object number, as is any save that would have to encrypt). The password given later (Document::unlock) unlocks the embedded files. Not yet: reading an attachment stored in the clear in such a document without the password (attachment_data reads through lopdf, which can't read the document without its key, so every attachment of it is refused), and the app asking for the password when such an attachment is saved. pdf.js encrypted-attachment.pdf has no /AuthEvent (DocOpen) and a non-empty user password, so it rightly asks for one; its broken startxref currently reports it as unreadable instead"
[[feature]]
id = "core.unencrypted-metadata"
@@ -734,8 +734,8 @@ milestone = "M2"
status = "partial"
commands = []
tools = ["page_render"]
evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it", "crates/render/src/raster/tests.rs::non_interpolated_minification_keeps_thin_strokes", "crates/render/src/raster/tests.rs::a_shrunk_stencil_mask_keeps_thin_strokes", "crates/render/src/raster/tests.rs::minified_image_edges_snap_to_whole_pixels", "crates/render/src/raster/tests.rs::large_non_interpolated_images_are_smoothed_when_enlarged", "crates/render/src/raster/tests.rs::small_non_interpolated_images_keep_hard_pixels_when_enlarged", "crates/render/src/raster/tests.rs::a_font_without_a_subtype_is_drawn_by_its_base_font"]
notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value. An image drawn below its resolution is shrunk once to whole device pixels: by area averaging unless it asks for /Interpolate (then Catmull-Rom), so the text of a 300 dpi scan at 100% keeps its strokes, a 1-bit /ImageMask text layer included (it was nearest-neighbour, which dropped them). Enlarged by more than 1%, an image of page-scan size (512 samples or more on its shorter side) is smoothed bilinearly whatever its /Interpolate, so a zoomed scan has no staircases; smaller images keep their hard pixels. A font dictionary with a missing or empty `/Subtype` is drawn as a Type 1 font by its `/BaseFont`. Not measured against Acrobat"
evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it", "crates/render/src/raster/tests.rs::non_interpolated_minification_keeps_thin_strokes", "crates/render/src/raster/tests.rs::a_shrunk_stencil_mask_keeps_thin_strokes", "crates/render/src/raster/tests.rs::minified_image_edges_snap_to_whole_pixels", "crates/render/src/raster/tests.rs::large_non_interpolated_images_are_smoothed_when_enlarged", "crates/render/src/raster/tests.rs::small_non_interpolated_images_keep_hard_pixels_when_enlarged", "crates/render/src/raster/tests.rs::a_font_without_a_subtype_is_drawn_by_its_base_font", "crates/render/src/raster/tests.rs::a_page_without_a_media_box_is_letter"]
notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value. An image drawn below its resolution is shrunk once to whole device pixels: by area averaging unless it asks for /Interpolate (then Catmull-Rom), so the text of a 300 dpi scan at 100% keeps its strokes, a 1-bit /ImageMask text layer included (it was nearest-neighbour, which dropped them). Enlarged by more than 1%, an image of page-scan size (512 samples or more on its shorter side) is smoothed bilinearly whatever its /Interpolate, so a zoomed scan has no staircases; smaller images keep their hard pixels. A font dictionary with a missing or empty `/Subtype` is drawn as a Type 1 font by its `/BaseFont`. A page with no `/MediaBox`, or one without area, is US Letter in the renderer, the page model and the inspector alike. Not measured against Acrobat"
[[feature]]
id = "view.render-shadings-patterns"
@@ -794,8 +794,8 @@ milestone = "M2"
status = "partial"
commands = []
tools = []
evidence = ["crates/render/src/raster/tests.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster/tests.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster/tests.rs::mincho_substitute_covers_half_width_katakana", "crates/render/src/raster/tests.rs::non_embedded_chinese_and_korean_cid_fonts_draw_with_an_installed_face", "crates/engine/src/tests.rs::installed_cjk_fonts_draw_in_the_viewer_but_not_in_an_export", "crates/render/src/text/tests.rs::identity_to_unicode_names_give_text", "crates/fonts/src/pdf/tests.rs::identity_to_unicode_names_read_codes_as_unicode", "crates/render/src/raster/tests.rs::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen", "crates/render/src/raster/tests/non_embedded_arial.rs::an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name", "crates/render/src/raster/tests/non_embedded_arial.rs::a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name", "crates/render/src/raster/tests/non_embedded_arial.rs::codes_of_other_fonts_are_not_read_in_the_macintosh_order"]
notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name, else by the descendant font descriptor's Serif flag; bold Gothic for a Bold name or a `/FontWeight` of 600 or more; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; the desktop viewer can fall back to an installed CJK face for GB1, CNS1, Korea1 and Japan1, including GBK-EUC-H TrueType CID fonts without ToUnicode. Windows preserves installed SimSun, SimHei, KaiTi and FangSong families, including subset-prefixed Founder aliases. Combine thumbnails and accessibility figure previews use the same on-screen fallback. System faces are display-only, disabled by PDFCRAFT_SYSTEM_FONTS=0, and not used by export, printing, OCR or automation; machines without a candidate and web builds remain unsupported. A `/ToUnicode` given as the name `/Identity-H` or `/Identity-V` (WSCAD) reads codes as Unicode for selection, search and editing, without control characters. A non-embedded Identity font whose `/ToUnicode` gives a soft hyphen (U+00AD, as Windows exporters write Arial's hyphen) draws the standard substitute's hyphen (#5888). A non-embedded Arial or Times New Roman (`CIDFontType2`; ArialMT, Arial-BoldMT, Arial,Bold, TimesNewRomanPSMT, TimesNewRoman,Bold and their other faces) whose codes no Unicode route draws is read as glyph indexes of the author's font, whose first 258 glyphs follow the standard Macintosh order: the substitute draws each glyph by that name, .notdef when it has none (#5889, #5726, #6449). Its text still can't be selected, searched or copied without a `/ToUnicode`, Courier New and other faces whose numbering isn't confirmed aren't read this way, and Arial and Times New Roman glyphs past the first 258 (Cyrillic, Greek) and every other font's characters the substitute lacks still fall back to the CID taken as one of the substitute's glyph indexes, an unrelated glyph (#3529, #5726)"
evidence = ["crates/render/src/raster/tests.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster/tests.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster/tests.rs::mincho_substitute_covers_half_width_katakana", "crates/render/src/raster/tests.rs::non_embedded_chinese_and_korean_cid_fonts_draw_with_an_installed_face", "crates/engine/src/tests.rs::installed_cjk_fonts_draw_in_the_viewer_but_not_in_an_export", "crates/render/src/text/tests.rs::identity_to_unicode_names_give_text", "crates/fonts/src/pdf/tests.rs::identity_to_unicode_names_read_codes_as_unicode", "crates/render/src/raster/tests.rs::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen", "crates/render/src/raster/tests/non_embedded_arial.rs::an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name", "crates/render/src/raster/tests/non_embedded_arial.rs::a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name", "crates/render/src/raster/tests/non_embedded_arial.rs::codes_of_other_fonts_are_not_read_in_the_macintosh_order", "crates/render/src/raster/tests.rs::a_bfchar_cmap_reads_codes_of_its_codespace_length"]
notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name, else by the descendant font descriptor's Serif flag; bold Gothic for a Bold name or a `/FontWeight` of 600 or more; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; the desktop viewer can fall back to an installed CJK face for GB1, CNS1, Korea1 and Japan1, including GBK-EUC-H TrueType CID fonts without ToUnicode. Windows preserves installed SimSun, SimHei, KaiTi and FangSong families, including subset-prefixed Founder aliases. Combine thumbnails and accessibility figure previews use the same on-screen fallback. System faces are display-only, disabled by PDFCRAFT_SYSTEM_FONTS=0, and not used by export, printing, OCR or automation; machines without a candidate and web builds remain unsupported. A `/ToUnicode` given as the name `/Identity-H` or `/Identity-V` (WSCAD) reads codes as Unicode for selection, search and editing, without control characters. A non-embedded Identity font whose `/ToUnicode` gives a soft hyphen (U+00AD, as Windows exporters write Arial's hyphen) draws the standard substitute's hyphen (#5888). A non-embedded Arial or Times New Roman (`CIDFontType2`; ArialMT, Arial-BoldMT, Arial,Bold, TimesNewRomanPSMT, TimesNewRoman,Bold and their other faces) whose codes no Unicode route draws is read as glyph indexes of the author's font, whose first 258 glyphs follow the standard Macintosh order: the substitute draws each glyph by that name, .notdef when it has none (#5889, #5726, #6449). Its text still can't be selected, searched or copied without a `/ToUnicode`, Courier New and other faces whose numbering isn't confirmed aren't read this way, and Arial and Times New Roman glyphs past the first 258 (Cyrillic, Greek) and every other font's characters the substitute lacks still fall back to the CID taken as one of the substitute's glyph indexes, an unrelated glyph (#3529, #5726). An embedded CMap that writes its CID mappings as `bfchar` reads each code at the length its codespace ranges give it (ISO 32000-2 §9.7.6.2), so a two-byte code is one glyph, not two (hayro-cmap patch 4)"
[[feature]]
id = "view.render-image-codecs"
@@ -806,8 +806,8 @@ milestone = "M2"
status = "partial"
commands = []
tools = []
evidence = []
notes = "hayro bootstrap"
evidence = ["crates/render/src/raster/tests.rs::group4_fax_with_rows_zero_decodes_to_the_image_height", "crates/render/src/raster/tests.rs::group4_fax_with_rows_below_its_height_decodes_to_the_image_height", "crates/render/src/raster/tests.rs::group4_fax_with_rows_past_its_height_decodes_to_the_image_height"]
notes = "hayro bootstrap; a CCITT fax image is decoded to its /Height whatever its /DecodeParms /Rows says (0, fewer or more rows)"
[[feature]]
id = "view.render-annotation-appearances"
@@ -830,8 +830,8 @@ milestone = "M3"
status = "shipped"
commands = []
tools = []
evidence = ["crates/render/src/raster/tests.rs::missing_page_and_garbage_input_fail_gracefully", "crates/render/src/raster/tests.rs::scale_is_clamped_for_huge_pages", "crates/render/src/raster/tests.rs::watchdog_skips_a_stuck_page_and_keeps_rendering"]
notes = ""
evidence = ["crates/render/src/raster/tests.rs::missing_page_and_garbage_input_fail_gracefully", "crates/render/src/raster/tests.rs::scale_is_clamped_for_huge_pages", "crates/render/src/raster/tests.rs::watchdog_skips_a_stuck_page_and_keeps_rendering", "crates/render/src/raster/tests/coverage_budget.rs::a_page_past_its_coverage_leaves_the_rest_out_and_says_so"]
notes = "A page that paints more coverage than the rasterizer can index (one form drawn millions of times over) renders what fitted and says content was left out, rather than failing the page."
[[feature]]
id = "view.render-priority"
+9 -8
View File
@@ -27,7 +27,7 @@ licence files unchanged. Every patch is marked `PdfCraft patch:` in the source,
PdfCraft test, and should be proposed upstream. Remove the vendored copy once upstream releases
the fix. Vendoring copyleft code is never allowed (plan/adr/0001).
A PdfCraft patch in `hayro-syntax::Page::base_dimensions` expresses the existing one-point floor as a raw reciprocal of `/UserUnit`, rounded upward only when f32 multiplication would fall below one point. Five `pdfcraft-render` regressions with prefix `raster::tests::raw_crop_` cover the observed dimensions, origins and rotations, units 41/47, bounded vector rasters and unchanged nearly-zero A4 fallback. Accepted page-unit policy and licences are unchanged.
A PdfCraft patch in `hayro-syntax::Page::base_dimensions` expresses the existing one-point floor as a raw reciprocal of `/UserUnit`, rounded upward only when f32 multiplication would fall below one point. Five `pdfcraft-render` regressions with prefix `raster::tests::raw_crop_` cover the observed dimensions, origins and rotations, units 41/47, bounded vector rasters and the nearly-zero fallback, which is US Letter since patch 44. Accepted page-unit policy and licences are unchanged.
In `hayro-interpret`, Type 3 glyph bounding boxes are transformed to device space before intersecting clip and shading bounds; `raster::tests::shaded_type3_glyph_draws_at_multiple_positions` covers translated glyphs (#3178).
@@ -40,20 +40,21 @@ Other resource categories retain their existing parent-chain lookup.
| Crate | Version | Licence | Patches | Test |
|---|---|---|---|---|
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.nLine truncated
| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour (since (21), only inside Type 3 glyphs), planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option<ColorReplacement>` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk image that is interpolated (or, since (21), area-averaged) and whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle (halves round up, so a tile snaps an edge where the whole page does) and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroLine truncated
| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour (since (21), only inside Type 3 glyphs), planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option<ColorReplacement>` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk image that is interpolated (or, since (21), area-averaged) and whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle (halves round up, so a tile snaps an edge where the whole page does) and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroLine truncated
| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (13) a bitmap filled with a default pixel of 1 (a page, or an intermediate text or halftone region) had 1s past its width in the last word of each row, and a refinement, which reads its reference a word at a time, read them as pixels where the standard has 0 (6.3.5.2): refining such a reference not a multiple of 32 wide decoded to other pixels than were coded, release builds too. `Bitmap::clear_padding` clears those bits when a bitmap is filled with 1s, as cutting symbols and patterns from a collective bitmap already did. Ghostscript reads the test's page and text streams as they decode now; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say; a refinement of the page, see (16); (15) a refinement whose reference is offset right of it (GRREFERENCEDX above 0, which a refined text instance has from a wider bitmap, TaLine truncated
| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` |
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwLine truncated
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwLine truncated
| winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, Line truncated
| egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::vendor_provenance_egui_wgpu_surface_fit_identity`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure |
| vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical; (4) `flatten::stroke`: when kurbo 0.13.1's stroker returns an outline containing NaN, which `fill` would drop whole, the path is stroked again from a copy (`robust_for_stroking`, one element out per element in) in which each curve whose control points lie within the stroke tolerance of its chord is a line (the #4907 fix, a nearly straight cubic given a NaN join, kurbo issues #617 and #519; its row here was lost in a merge and is restored with this entry) and each cubic whose second control point is its end point, as `y` builds (ISO 32000-2 §8.5.2.1 Table 58), has that control point moved the tolerance towards its first (`open_zero_end_handle`; the curve moves by at most 4/9 of the tolerance): kurbo's cusp search on such a curve's offset can stop on the end point, where the derivative is zero, and normalising it gives NaN, so at 16, 32 and 63 pt a two-curve `y` path drew nothing and a file in the PDFBox tracker lost its 32 pt "XX" outline. Only an exact zero triggers it (none of 192,000 random strokes with a handle 1e-9 long, nor of 256,000 `v` strokes, came back with NaN; 1,073 of 192,000 random `y`/`v` strokes at 1–64 pt did before and none after). A finite outline takes the old path unchanged | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate; (4) `flatten::tests::a_tiny_degenerate_cubic_does_not_drop_the_stroke` and the render crate's `stroke_degenerate_cubic` fail without the near-line replacement, `flatten::tests::a_wide_stroke_of_a_curve_with_a_zero_end_tangent_is_drawn` (widths 1–64, every join and cap, `y` and the mirrored `v`) and the render crate's `stroke_zero_end_tangent` fail without the moved control point; `a_finite_outline_is_stroked_as_before` and `a_path_with_nan_is_still_dropped` pass either way |
| hayro-cmap | 0.1.0 | Apache-2.0 OR MIT | (1) malformed parts of a CMap (bad bfchar/bfrange entries, unresolvable `usecmap`, unparseable tail) are skipped instead of discarding the whole map, so search and redaction find text the edit layer can read (#3177); (2) a binary CMap's `usecmap` counts against the 16-level nesting limit as a text one does, where it passed its own depth and a self-referencing binary CMap recursed until the stack overflowed (from storytold/craft-libs#254) | (1) `hayro-cmap::tests::bad_bfchar_entry_is_skipped`, `unresolvable_usecmap_is_skipped`; (2) `tests::self_referencing_binary_usecmap_stops_at_the_nesting_limit` |
| hayro-postscript | 0.1.0 | Apache-2.0 OR MIT | (1) the token scanner's `object::read` skips `<<` and `>>` in a loop; it called itself once per delimiter, so a CMap stream holding a long run of `<` or `>` overflowed the stack and aborted the process (from storytold/craft-libs#263) | (1) `hayro-postscript::object::tests::long_open_dict_run_does_not_overflow`, `long_close_dict_run_does_not_overflow`, `dict_delimiters_are_skipped`, `nested_dict_delimiters_are_skipped` |
| vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical; (2) `StripStorage::room_for_path{,_within}`: whether one more path's coverage (at most the whole surface, counted twice) still fits below 2^31 values, the most a strip's packed alpha index reaches (`Strip::new` asserts it), or below a smaller limit; (4) `flatten::stroke`: when kurbo 0.13.1's stroker returns an outline containing NaN, which `fill` would drop whole, the path is stroked again from a copy (`robust_for_stroking`, one element out per element in) in which each curve whose control points lie within the stroke tolerance of its chord is a line (the #4907 fix, a nearly straight cubic given a NaN join, kurbo issues #617 and #519; its row here was lost in a merge and is restored with this entry) and each cubic whose second control point is its end point, as `y` builds (ISO 32000-2 §8.5.2.1 Table 58), has that control point moved the tolerance towards its first (`open_zero_end_handle`; the curve moves by at most 4/9 of the tolerance): kurbo's cusp search on such a curve's offset can stop on the end point, where the derivative is zero, and normalising it gives NaN, so at 16, 32 and 63 pt a two-curve `y` path drew nothing and a file in the PDFBox tracker lost its 32 pt "XX" outline. Only an exact zero triggers it (none of 192,000 random strokes with a handle 1e-9 long, nor of 256,000 `v` strokes, came back with NaN; 1,073 of 192,000 random `y`/`v` strokes at 1–64 pt did before and none after). A finite outline takes the old path unchanged | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate; (2) `strip_generator::pdfcraft_tests::strips_have_room_until_a_surface_short_of_the_limit`; (4) `flatten::tests::a_tiny_degenerate_cubic_does_not_drop_the_stroke` and the render crate's `stroke_degenerate_cubic` fail without the near-line replacement, `flatten::tests::a_wide_stroke_of_a_curve_with_a_zero_end_tangent_is_drawn` (widths 1–64, every join and cap, `y` and the mirrored `v`) and the render crate's `stroke_zero_end_tangent` fail without the moved control point; `a_finite_outline_is_stroked_as_before` and `a_path_with_nan_is_still_dropped` pass either way |
| vello_cpu | 0.0.8 | Apache-2.0 OR MIT | (1) the single-threaded dispatcher leaves out a fill, stroke, fast rectangle or layer clip once the scene has no room for one more path's coverage (`StripStorage::room_for_path_within`, vello_common patch 2), records it (`RenderContext::coverage_full`, cleared by `reset`) and can be kept to fewer values (`RenderContext::set_max_coverage`, kept across `reset`). Every path's coverage stays in one buffer until the scene is rasterized, so a page that painted one form 2^22 times over (PDFBOX-5415) grew it past 2^31 values at about 7 GB and panicked in `Strip::new` (`alpha_idx` too large); it now renders what fitted. The multi-threaded dispatcher is unchanged (PdfCraft renders single-threaded); the backport of linebender/vello#2000 is recorded under the KenM76 integration patches (#5199) | (1) `raster::tests::coverage_budget::a_page_past_its_coverage_leaves_the_rest_out_and_says_so` renders a page that fills a circle 2^10 times with the rasterizer kept to 200,000 values, and fails without the dispatcher's check; `strokes_past_the_coverage_are_left_out`, `fast_rectangles_past_the_coverage_are_left_out` and `layer_clips_past_the_coverage_are_left_out` (the last through `RenderContext` directly, as hayro pushes no clipped layers, with the first clip kept below the limit and its pixels checked) each fail without that path's check, and `a_layer_clip_past_the_coverage_is_itself_left_out` fails when the clip is generated without room; vello_cpu's own tests need upstream assets that are not vendored |
| hayro-cmap | 0.1.0 | Apache-2.0 OR MIT | (1) malformed parts of a CMap (bad bfchar/bfrange entries, unresolvable `usecmap`, unparseable tail) are skipped instead of discarding the whole map, so search and redaction find text the edit layer can read (#3177); (2) a binary CMap's `usecmap` counts against the 16-level nesting limit as a text one does, where it passed its own depth and a self-referencing binary CMap recursed until the stack overflowed (from storytold/craft-libs#254); (3) a token the scanner can't read (a stray `>` after a CMap name, a CMap name with spaces in it) is stepped over with `parse_object_or_skip`, at most 1,024 times per CMap, instead of ending the scan before the mappings that follow it (pdf.js `issue11651.pdf`, `issue7492.pdf`: no text could be copied); (4) `lookup_cid_code`'s `bfchar` fallback (used where a CMap writes its CID mappings as `bfchar`) applies only when the codespace ranges give the code the byte length being tried (ISO 32000-2 §9.7.6.2; `codespace_allows`, a CMap without codespace ranges defers to its `usecmap` base, and with none anywhere the entry's own source length decides, now recorded as `BfRange::code_len` for text CMaps): the first byte of `<fe96>` had matched the entry `<00fe>`, so the first byte of `<fe96>` matched the entry `<00fe>` and every two-byte code drew as two glyphs (PDFBOX-1282 `Characters_Arial.pdf` page 3). An unmapped code still advances one byte | (1) `hayro-cmap::tests::bad_bfchar_entry_is_skipped`, `unresolvable_usecmap_is_skipped`; (2) `tests::self_referencing_binary_usecmap_stops_at_the_nesting_limit`; (3) `hayro-cmap::tests::{mappings_after_an_unreadable_token_are_read, a_number_run_into_an_operator_does_not_start_its_mappings, the_scan_steps_over_at_most_max_skipped_unreadable_tokens, a_cmap_of_unreadable_tokens_only_reports_failure}`, `raster::tests::a_to_unicode_cmap_reads_its_mappings_past_a_token_it_cannot_parse` (pdfcraft-render); (4) `hayro-cmap::tests::{bfchar_cid_fallback_takes_the_codespace_length, bfchar_cid_fallback_checks_each_byte_of_the_codespace, bfchar_cid_fallback_without_codespace_takes_the_source_length}`, `raster::tests::a_bfchar_cmap_reads_codes_of_its_codespace_length` (pdfcraft-render) |
| hayro-postscript | 0.1.0 | Apache-2.0 OR MIT | (1) the token scanner's `object::read` skips `<<` and `>>` in a loop; it called itself once per delimiter, so a CMap stream holding a long run of `<` or `>` overflowed the stack and aborted the process (from storytold/craft-libs#263); (2) `Scanner::parse_object_or_skip`: after an object that can't be read, the scanner reads on from where the failed read stopped, or from one byte past where its token starts (after whitespace and comments) when it read nothing, so a caller can read on, one failure per unreadable token, and the data is read about once; a number that can't be read goes to the end of its token, so `1beginbfchar` is not read as `beginbfchar` (hayro-cmap patch 3) | (1) `hayro-postscript::object::tests::long_open_dict_run_does_not_overflow`, `long_close_dict_run_does_not_overflow`, `dict_delimiters_are_skipped`, `nested_dict_delimiters_are_skipped`; (2) `hayro-postscript::tests::{parse_object_or_skip_steps_over_an_unreadable_token, parse_object_or_skip_skips_the_rest_of_an_unreadable_number, parse_object_or_skip_does_not_read_a_failed_read_again, parse_object_or_skip_stays_at_the_end}`, `raster::tests::a_to_unicode_cmap_reads_its_mappings_past_a_token_it_cannot_parse` (pdfcraft-render) |
`hayro-postscript` is the crates.io 0.1.0 package plus patch (1). The local Cargo
`hayro-postscript` is the crates.io 0.1.0 package plus patches (1) and (2). The local Cargo
registry cache metadata for that package (`.cargo_vcs_info.json`) records upstream
VCS commit `a2354aebaf86f5ab13e5fd6b6b0521306488bf9d`. The registry copy and this vendored tree's
source files differ only in `src/object.rs` (the patch above); the vendored tree
source files differ only in `src/object.rs` (patch 1) and `src/lib.rs` (patch 2); the vendored tree
omits the registry dotfiles `.cargo_vcs_info.json` and `.cargo-ok`, so the upstream
commit is recorded here.
+2
View File
@@ -410,6 +410,8 @@ fn parse_bf_segment(
entries.push(BfRange {
range: Range { start, end },
dst_base,
// PdfCraft patch (4): a binary CMap doesn't record the length.
code_len: 0,
});
prev_end = Some(end);
+198 -4
View File
@@ -359,8 +359,9 @@ const MAX_NESTING_DEPTH: u32 = 16;
#[derive(Debug, Clone)]
pub struct CMap {
metadata: Metadata,
// Note that we don't actually use this, because Acrobat _seems_ to ignore
// it, too.
// Note that the CID lookup doesn't check this, because Acrobat _seems_ to
// ignore it, too. PdfCraft patch (4): the `bfchar` fallback of
// `lookup_cid_code` does check it (see there).
_codespace_ranges: Vec<CodespaceRange>,
cid_ranges: PartitionedRanges,
notdef_ranges: PartitionedRanges,
@@ -449,7 +450,20 @@ impl CMap {
// See pdfjs_bug920426, it uses bf chars for encoding the characters
// of a text-showing operator, so try that as well. We don't want to
// recurse though, only check the bf chars of this level.
if let Some(BfString::Char(lookup)) = self.lookup_bf_string_inner(code, false) {
//
// PdfCraft patch (4): bf entries don't record how many bytes their code
// has, so the fallback only applies when the codespace ranges give
// the code `byte_len` bytes (ISO 32000-2 §9.7.6.2). Otherwise the
// first byte of `<fe96>` matched a `<00fe>` entry and a two-byte code
// drew as two glyphs. A CMap with no codespace ranges anywhere uses
// the length the entry's own source code was written with instead.
let length_fits = match self.codespace_allows(code, byte_len, 0) {
Some(allowed) => allowed,
None => self.bf_code_has_length(code, byte_len),
};
if length_fits
&& let Some(BfString::Char(lookup)) = self.lookup_bf_string_inner(code, false)
{
return Some(lookup as u32);
}
@@ -459,6 +473,40 @@ impl CMap {
.and_then(|b| b.lookup_cid_code(code, byte_len))
}
/// PdfCraft patch (4): whether this level's bf entry for `code` was written with `byte_len`
/// bytes (`<00fe>` is a two-byte code, not the one-byte `<fe>`), or doesn't record its length.
/// Used where no codespace range gives the code its length.
fn bf_code_has_length(&self, code: u32, byte_len: u8) -> bool {
find_in_ranges(&self.bf_entries, code)
.is_some_and(|entry| entry.code_len == 0 || entry.code_len == byte_len)
}
/// PdfCraft patch (4): whether a code of `byte_len` bytes lies in a codespace range of that
/// length (each byte within the range's bounds for its position, ISO
/// 32000-2 §9.7.6.2). A CMap without codespace ranges defers to its base,
/// and with none anywhere the answer is `None`.
fn codespace_allows(&self, code: u32, byte_len: u8, depth: u32) -> Option<bool> {
if self._codespace_ranges.is_empty() {
return match &self.base {
Some(base) if depth < MAX_NESTING_DEPTH => {
base.codespace_allows(code, byte_len, depth.saturating_add(1))
}
_ => None,
};
}
Some(self._codespace_ranges.iter().any(|range| {
range.number_bytes == byte_len
&& (0..u32::from(byte_len)).all(|i| {
// `byte_len` is at most 4 here (the range's own length),
// so `shift` is at most 24.
let shift = (u32::from(byte_len) - 1 - i) * 8;
let byte = |v: u32| v.checked_shr(shift).map_or(0, |b| b & 0xFF);
byte(range.low) <= byte(code) && byte(code) <= byte(range.high)
})
}))
}
/// Look up a bf string in the cmap. This is usually
/// used for mapping character codes to Unicode codepoints in a
/// `ToUnicode` cmap.
@@ -584,6 +632,9 @@ impl PartitionedRanges {
pub(crate) struct BfRange {
pub(crate) range: Range,
pub(crate) dst_base: Vec<u16>,
/// PdfCraft patch (4): how many bytes the entry's source code was written with (1 to 4),
/// or 0 where that isn't recorded (a binary CMap).
pub(crate) code_len: u8,
}
impl HasRange for BfRange {
@@ -594,7 +645,6 @@ impl HasRange for BfRange {
/// A codespace range defining valid character code byte sequences.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub(crate) struct CodespaceRange {
pub(crate) number_bytes: u8,
pub(crate) low: u32,
@@ -1128,6 +1178,96 @@ endbfchar
);
}
/// Parse `body` after the metadata of [`PREAMBLE`] but without its
/// codespace ranges.
fn parse_without_codespace(body: &[u8]) -> CMap {
let mut data = Vec::new();
data.extend_from_slice(
br#"/CIDSystemInfo 3 dict dup begin
/Registry (Adobe) def
/Ordering (Identity) def
/Supplement 0 def
end def
/CMapName /Test def
"#,
);
data.extend_from_slice(body);
CMap::parse(&data, |_| None).unwrap()
}
/// PdfCraft patch (4) test: a `bfchar` entry used as a CID mapping matches only
/// codes of the length the codespace ranges give it (ISO 32000-2 §9.7.6.2).
#[test]
fn bfchar_cid_fallback_takes_the_codespace_length() {
let cmap = parse_without_codespace(
br#"
1 begincodespacerange
<0000> <FFFF>
endcodespacerange
2 beginbfchar
<0020> <0020>
<2041> <0042>
endbfchar
"#,
);
// The first byte of <2041> alone is not a code of this CMap.
assert_eq!(cmap.lookup_cid_code(0x20, 1), None);
assert_eq!(cmap.lookup_cid_code(0x2041, 2), Some(0x42));
assert_eq!(cmap.lookup_cid_code(0x0020, 2), Some(0x20));
// ToUnicode-style lookups are unchanged.
assert_eq!(cmap.lookup_bf_string(0x20), Some(BfString::Char(' ')));
}
/// PdfCraft patch (4) test: mixed codespaces give each code its own length,
/// byte by byte, and a CMap without codespace ranges keeps the fallback.
#[test]
fn bfchar_cid_fallback_checks_each_byte_of_the_codespace() {
let cmap = parse_without_codespace(
br#"
2 begincodespacerange
<00> <80>
<8140> <9FFC>
endcodespacerange
3 beginbfchar
<41> <0041>
<8141> <0100>
<8220> <0101>
endbfchar
"#,
);
assert_eq!(cmap.lookup_cid_code(0x41, 1), Some(0x41));
assert_eq!(cmap.lookup_cid_code(0x8141, 2), Some(0x100));
// 0x8220 lies numerically between 0x8140 and 0x9FFC, but its second
// byte 0x20 is below 0x40.
assert_eq!(cmap.lookup_cid_code(0x8220, 2), None);
let no_codespace = parse_without_codespace(b"1 beginbfchar <20> <0020> endbfchar\n");
assert_eq!(no_codespace.lookup_cid_code(0x20, 1), Some(0x20));
}
/// PdfCraft patch (4) test: without codespace ranges, a `bfchar` entry used as a CID
/// mapping matches only codes of the length its source was written with, so the first
/// byte of `<fe96>` is not taken for `<00fe>`.
#[test]
fn bfchar_cid_fallback_without_codespace_takes_the_source_length() {
let cmap = parse_without_codespace(
br#"
2 beginbfchar
<00fe> <0041>
<fe96> <0042>
endbfchar
1 beginbfrange
<0100> <0102> <0050>
endbfrange
"#,
);
assert_eq!(cmap.lookup_cid_code(0xfe, 1), None);
assert_eq!(cmap.lookup_cid_code(0xfe96, 2), Some(0x42));
assert_eq!(cmap.lookup_cid_code(0x00fe, 2), Some(0x41));
assert_eq!(cmap.lookup_cid_code(0x0101, 2), Some(0x51));
assert_eq!(cmap.lookup_cid_code(0x0101, 3), None);
}
#[test]
fn stream_cut_off_inside_a_block_keeps_the_entries_before_it() {
let cmap = parse_with_preamble(b"1 beginbfchar <41> <0058>\n<42> <00");
@@ -1176,6 +1316,60 @@ endbfchar
assert!(cmap.lookup_bf_string(0x41).is_some());
}
// PdfCraft patch (3): a token the scanner can't read (a stray `>` after a CMap name, a name
// with spaces in it) is stepped over, so the mappings after it are still read; at most
// `MAX_SKIPPED` of them per CMap.
#[test]
fn mappings_after_an_unreadable_token_are_read() {
for header in [
&b"/CMapName /Adobe-Identity-UC> def"[..],
b"/CMapName /-usr-share-fonts-Panton Family - Panton.otf,000-UTF16 def",
] {
let mut data = header.to_vec();
data.extend_from_slice(
b"\n1 beginbfchar <41> <0058> endbfchar\n1 beginbfrange <50> <52> <0061> endbfrange",
);
let cmap = parse_with_preamble(&data);
assert_eq!(cmap.lookup_bf_string(0x41), Some(BfString::Char('X')));
assert_eq!(cmap.lookup_bf_string(0x51), Some(BfString::Char('b')));
}
}
#[test]
fn a_number_run_into_an_operator_does_not_start_its_mappings() {
// `1beginbfchar` is one token, not `1` and `beginbfchar`: the pairs after it are not
// mappings, and the well-formed block after them still reads.
let cmap = parse_with_preamble(
b"1beginbfchar <41> <0058> endbfchar\n1 beginbfchar <42> <0059> endbfchar",
);
assert_eq!(cmap.lookup_bf_string(0x41), None);
assert_eq!(cmap.lookup_bf_string(0x42), Some(BfString::Char('Y')));
}
/// A CMap with `count` unreadable tokens, each after a space, before one `bfchar` mapping.
fn mapping_after_unreadable_tokens(count: usize) -> Option<CMap> {
let mut data = PREAMBLE.to_vec();
data.extend_from_slice(&b" >".repeat(count));
data.extend_from_slice(b"\n1 beginbfchar <41> <0058> endbfchar");
CMap::parse(&data, |_| None)
}
#[test]
fn the_scan_steps_over_at_most_max_skipped_unreadable_tokens() {
let limit = parse::MAX_SKIPPED;
let cmap = mapping_after_unreadable_tokens(limit).expect("read up to the limit");
assert_eq!(cmap.lookup_bf_string(0x41), Some(BfString::Char('X')));
// One more ends the scan before the mapping, and a CMap with no mappings fails.
assert!(mapping_after_unreadable_tokens(limit + 1).is_none());
}
#[test]
fn a_cmap_of_unreadable_tokens_only_reports_failure() {
assert!(CMap::parse(b"> ) >", |_| None).is_none());
assert!(CMap::parse(&b" >".repeat(4 * parse::MAX_SKIPPED), |_| None).is_none());
}
#[test]
fn bad_bfchar_code_keeps_later_entries_aligned() {
// The malformed 5-byte code still has a destination object; both
+22 -2
View File
@@ -15,6 +15,9 @@ struct Context<F> {
get_cmap: F,
}
/// PdfCraft patch (3): most unreadable tokens one CMap may step over before its scan stops.
pub(crate) const MAX_SKIPPED: usize = 1024;
pub(crate) fn parse_inner<'a>(
data: &[u8],
get_cmap: impl Fn(CMapName<'_>) -> Option<&'a [u8]> + Clone + 'a,
@@ -61,10 +64,20 @@ pub(crate) fn parse_inner<'a>(
// must see failure rather than an empty map, so its encoding fallback
// paths run exactly as they did before the tolerant-usecmap change.
let mut usecmap_failed = false;
let mut skipped = 0usize;
while !scanner.at_end() {
let Ok(obj) = scanner.parse_object() else {
// PdfCraft patch (3): a token that can't be read (a stray `>` after a CMap name, a
// name with spaces in it, both written into ToUnicode CMaps) is stepped over, so the
// mappings after it are still read; it used to end the scan before any of them.
// At most `MAX_SKIPPED` such tokens: a failed read may scan to the end of the data (an
// unclosed string), so a stream of them would cost its length squared.
let Ok(obj) = scanner.parse_object_or_skip() else {
truncated = true;
break;
skipped += 1;
if skipped > MAX_SKIPPED {
break;
}
continue;
};
let Object::Name(name) = &obj else { continue };
@@ -333,6 +346,8 @@ fn parse_bf_char<F>(
let _ = scanner.parse_object();
continue;
};
// PdfCraft patch (4): 1 to 4 (`extract_u32_code` checked).
let code_len = u8::try_from(ctx.buf.len()).unwrap_or(0);
let Ok(dst) = scanner.parse_string() else { continue };
if dst.decode_into(&mut ctx.buf).is_err() { continue }
let Some(dst_base) = decode_be(&ctx.buf) else { continue };
@@ -343,6 +358,7 @@ fn parse_bf_char<F>(
end: code,
},
dst_base,
code_len,
});
}
}
@@ -367,6 +383,8 @@ fn parse_bf_range<F>(
let _ = scanner.parse_object();
continue;
};
// PdfCraft patch (4): 1 to 4 (`extract_u32_code` checked).
let code_len = u8::try_from(ctx.buf.len()).unwrap_or(0);
let Some(end) = read_u32_code(scanner, &mut ctx.buf) else {
// Skip the destination object as well to stay aligned.
let _ = scanner.parse_object();
@@ -383,6 +401,7 @@ fn parse_bf_range<F>(
entries.push(BfRange {
range: Range { start, end },
dst_base,
code_len,
});
}
Object::Array(array) => {
@@ -403,6 +422,7 @@ fn parse_bf_range<F>(
end: code,
},
dst_base,
code_len,
});
}
}
+96
View File
@@ -60,6 +60,37 @@ impl<'a> Scanner<'a> {
object::read(&mut self.reader)
}
/// PdfCraft patch (2): parse the next object; when it can't be read, read on from where
/// the failed read stopped, or, when it read nothing, from one byte past where its token
/// starts, so a caller can read on past a token it can't parse (a stray `>` in a name, a
/// CMap name with spaces in it) instead of stopping there. Every failure moves forward, one
/// failure per unreadable token, and what a failed read went through (an unclosed string,
/// to the end of the data) is not read again, so a loop over this ends and reads the data
/// about once. A number that can't be read is skipped to the end of its token: its tail
/// (`beginbfchar` in `1beginbfchar`) is not read as a token of its own.
pub fn parse_object_or_skip(&mut self) -> Result<Object<'a>> {
// The whitespace and comments before the token are not the token: skipped first, they
// don't count as progress, so a token after a space fails once, not twice.
object::skip_whitespace_and_comments(&mut self.reader);
let start = self.reader.offset();
// `object::read` reads a token starting with one of these bytes as a number.
let number = matches!(
self.reader.peek_byte(),
Some(b'.' | b'+' | b'-' | b'0'..=b'9')
);
let read = object::read(&mut self.reader);
// A number read stops at the first byte that can't be part of a number, which may be
// inside the token; the rest of the token goes with it.
if read.is_err() && number {
self.reader.forward_while(reader::is_regular);
}
if read.is_err() && self.reader.offset() <= start {
self.reader
.jump(start.saturating_add(1).min(self.reader.data.len()));
}
read
}
/// Parse the next object as a [`Number`].
pub fn parse_number(&mut self) -> Result<Number> {
match self.parse_object()? {
@@ -176,4 +207,69 @@ endcmap"#;
let mut scanner = Scanner::new(b"42 ");
assert!(scanner.parse_name().is_err());
}
/// PdfCraft patch (2) test: a token that can't be read fails once and is stepped over,
/// whether or not whitespace or a comment comes before it, and the objects after it read.
#[test]
fn parse_object_or_skip_steps_over_an_unreadable_token() {
let mut s = Scanner::new(b"/A> /B % note\n > /C");
assert_eq!(
s.parse_object_or_skip(),
Ok(Object::Name(Name::new(b"A", true)))
);
assert!(s.parse_object_or_skip().is_err());
assert_eq!(
s.parse_object_or_skip(),
Ok(Object::Name(Name::new(b"B", true)))
);
assert!(s.parse_object_or_skip().is_err());
assert_eq!(
s.parse_object_or_skip(),
Ok(Object::Name(Name::new(b"C", true)))
);
assert!(s.at_end());
}
/// PdfCraft patch (2) test: a number that can't be read fails once with the rest of its
/// token, which is not read as a name of its own; the tokens after it still read.
#[test]
fn parse_object_or_skip_skips_the_rest_of_an_unreadable_number() {
let mut s = Scanner::new(b"1beginbfchar 2.5.3x 16#zz! -x /C");
for _ in 0..4 {
assert!(s.parse_object_or_skip().is_err());
}
assert_eq!(
s.parse_object_or_skip(),
Ok(Object::Name(Name::new(b"C", true)))
);
assert!(s.at_end());
}
/// PdfCraft patch (2) test: what a failed read went through is not read again. An unclosed
/// string reads to the end of the data, so one failure leaves nothing to read; the nested
/// `(` inside it are not each read to the end once more.
#[test]
fn parse_object_or_skip_does_not_read_a_failed_read_again() {
let mut s = Scanner::new(b"/A (((( x");
assert_eq!(
s.parse_object_or_skip(),
Ok(Object::Name(Name::new(b"A", true)))
);
assert!(s.parse_object_or_skip().is_err());
assert!(s.at_end());
}
/// PdfCraft patch (2) test: at the end of the data a read fails and the scanner stays at
/// the end; it never steps past the data.
#[test]
fn parse_object_or_skip_stays_at_the_end() {
for input in [&b""[..], b" ", b">"] {
let mut s = Scanner::new(input);
for _ in 0..3 {
assert!(s.parse_object_or_skip().is_err());
assert!(s.reader.offset() <= input.len());
}
assert!(s.at_end());
}
}
}
+39
View File
@@ -168,6 +168,7 @@ dependencies = [
"rustc-hash",
"sha2",
"smallvec",
"stringprep",
"zune-jpeg",
]
@@ -244,12 +245,50 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-normalization"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993ad287eb20e8ba755312801cfe1413e0f25d604075863acaddca10b04c1833"
[[package]]
name = "version_check"
version = "0.9.5"
+46
View File
@@ -114,6 +114,52 @@ pub(crate) fn get(
dict: &Dict<'_>,
id: &[u8],
password: &[u8],
) -> Result<Decryptor, DecryptionError> {
match decryptor(dict, id, password) {
// PdfCraft patch (37): a document that encrypts only its embedded files, authorised when
// one is opened (`/AuthEvent /EFOpen`, ISO 32000-2 §7.6.6, Table 25), opens without a
// password: its strings and streams are in the clear, and nothing drawn reads an embedded
// file.
Err(DecryptionError::PasswordProtected) if password.is_empty() && efopen_only(dict) => {
Ok(Decryptor::None)
}
other => other,
}
}
/// PdfCraft patch (37): whether `dict` has crypt filters (V4 or later) that encrypt, each
/// authorised only when an embedded file is opened (`/AuthEvent /EFOpen`), and neither `/StmF` nor
/// `/StrF` names one. The embedded files use them through `/EFF` or a `/Crypt` filter of their
/// own; earlier handlers (V1, V2) encrypt everything with RC4.
fn efopen_only(dict: &Dict<'_>) -> bool {
if dict.get::<u8>(V).is_none_or(|v| v < 4) {
return false;
}
let Some(cf) = dict.get::<Dict<'_>>(CF) else {
return false;
};
let mut encrypting = Vec::new();
for key in cf.keys() {
let Some(filter) = cf.get::<Dict<'_>>(key.as_ref()) else {
continue;
};
let method = filter.get::<Name<'_>>(CFM).and_then(|n| DecryptorTag::from_name(&n));
if matches!(method, None | Some(DecryptorTag::None)) {
continue;
}
if filter.get::<Name<'_>>(b"AuthEvent").is_none_or(|e| e.as_str() != "EFOpen") {
return false;
}
encrypting.push(key.as_str().to_string());
}
let names = |key: &[u8]| dict.get::<Name<'_>>(key).is_some_and(|n| encrypting.iter().any(|e| e == n.as_str()));
!encrypting.is_empty() && !names(STM_F) && !names(STR_F)
}
fn decryptor(
dict: &Dict<'_>,
id: &[u8],
password: &[u8],
) -> Result<Decryptor, DecryptionError> {
let filter = dict.get::<Name<'_>>(FILTER).ok_or(InvalidEncryption)?;
+8 -4
View File
@@ -20,10 +20,14 @@ pub(crate) fn decode(
// PdfCraft patch (19): fewer `/Rows` than the image's `/Height` doesn't cut the image short: a
// plotter driver wrote `/Rows` equal to `/Columns`, and the rows after it were drawn black
// (#3308). The decoder still stops where the data ends.
let rows = params
.get::<u32>(ROWS)
.filter(|&rows| rows != 0)
.map_or(image_params.height, |rows| rows.max(image_params.height));
// PdfCraft patch (41): more `/Rows` than the image's `/Height` doesn't make the decoder go
// past it either: the image is `/Height` rows (ISO 32000-2 Table 87), and a `/Rows` of
// 1,084,975,104 on 2,592 columns had the size check refuse every page of a scan (PDFBOX-771).
// `/Rows` counts only for a stream decoded without an image's height.
let rows = match image_params.height {
0 => params.get::<u32>(ROWS).unwrap_or(0),
height => height,
};
let end_of_block = params.get::<bool>(END_OF_BLOCK).unwrap_or(true);
let settings = DecodeSettings {
+179 -11
View File
@@ -6,7 +6,7 @@ use alloc::vec::Vec;
pub(crate) mod flate {
use super::*;
use crate::filter::lzw_flate::{PredictorParams, apply_predictor};
use crate::filter::lzw_flate::{PredictorParams, apply_predictor, untagged_png_rows};
pub(crate) use fallback::FlateState;
@@ -17,9 +17,15 @@ pub(crate) mod flate {
}
use crate::object::Dict;
/// PdfCraft patch: `limit` is the most bytes inflated (see `filter::decode_limit`).
/// PdfCraft patch: `limit` is the most bytes inflated (see `filter::decode_limit`), and `rows`
/// the `/Height` of the image the data is the samples of, if it is (see [`untagged_png_rows`]).
#[cfg(feature = "unsafe")]
pub(crate) fn decode(data: &[u8], params: &Dict<'_>, limit: usize) -> Option<Vec<u8>> {
pub(crate) fn decode(
data: &[u8],
params: &Dict<'_>,
limit: usize,
rows: Option<usize>,
) -> Option<Vec<u8>> {
use flate2::read::{DeflateDecoder, ZlibDecoder};
use std::io::Read;
@@ -51,19 +57,39 @@ pub(crate) mod flate {
if decoded.len() >= limit {
warn!("flate stream stopped at its decode limit of {limit} bytes");
}
let params = PredictorParams::from_params(params);
apply_predictor(decoded, &params)
undo_predictor(decoded, &PredictorParams::from_params(params), rows)
}
#[cfg(not(feature = "unsafe"))]
pub(crate) fn decode(data: &[u8], params: &Dict<'_>, limit: usize) -> Option<Vec<u8>> {
pub(crate) fn decode(
data: &[u8],
params: &Dict<'_>,
limit: usize,
rows: Option<usize>,
) -> Option<Vec<u8>> {
let decoded = fallback::decode(data, limit)?;
// PdfCraft patch: output cut at the limit is reported, never dropped silently.
if decoded.len() >= limit {
warn!("flate stream stopped at its decode limit of {limit} bytes");
}
let params = PredictorParams::from_params(params);
apply_predictor(decoded, &params)
undo_predictor(decoded, &PredictorParams::from_params(params), rows)
}
/// PdfCraft patch: the predictor undone, unless the image's rows carry none
/// ([`untagged_png_rows`]): the data is then its samples as they are, which is reported.
fn undo_predictor(
decoded: Vec<u8>,
params: &PredictorParams,
rows: Option<usize>,
) -> Option<Vec<u8>> {
if untagged_png_rows(params, decoded.len(), rows) {
warn!(
"flate image rows carry no PNG predictor tags, read as stored ({} rows)",
rows.unwrap_or(0)
);
return Some(decoded);
}
apply_predictor(decoded, params)
}
/// Ported from <https://github.com/mozilla/pdf.js/blob/master/src/core/flate_stream.js>
@@ -975,6 +1001,21 @@ impl PredictorParams {
}
}
/// PdfCraft patch: whether `len` bytes of an image `rows` high (`None` for data that isn't an
/// image's samples) are its rows without the filter-type byte each row starts with under a PNG
/// predictor (ISO 32000-2 §7.4.4.4): exactly `rows` × the row's bytes, where the predicted data
/// would be `rows` × (the row's bytes + 1). Some writers declare `/Predictor 15` without applying
/// it; read with the predictor, every row's first byte was taken for a filter type, the image
/// shifted a byte a row and came out short, drawn in fragments. PDFium, MuPDF and Poppler read
/// such data as the samples it is. Any other length is decoded with the predictor, as before.
fn untagged_png_rows(params: &PredictorParams, len: usize, rows: Option<usize>) -> bool {
let row_len = params.row_length_in_bytes();
params.predictor >= 10
&& matches!(params.bits_per_component, 1 | 2 | 4 | 8 | 16)
&& row_len > 0
&& rows.filter(|&r| r > 0).and_then(|r| r.checked_mul(row_len)) == Some(len)
}
fn apply_predictor(data: Vec<u8>, params: &PredictorParams) -> Option<Vec<u8>> {
match params.predictor {
1 => Some(data),
@@ -1289,7 +1330,7 @@ mod tests {
0x78, 0x9c, 0xf3, 0x48, 0xcd, 0xc9, 0xc9, 0x7, 0x0, 0x5, 0x8c, 0x1, 0xf5,
];
let decoded = flate::decode(&input, &Dict::default(), usize::MAX).unwrap();
let decoded = flate::decode(&input, &Dict::default(), usize::MAX, None).unwrap();
assert_eq!(decoded, b"Hello");
}
@@ -1297,7 +1338,7 @@ mod tests {
fn decode_flate() {
let input = [0xf3, 0x48, 0xcd, 0xc9, 0xc9, 0x7, 0x0];
let decoded = flate::decode(&input, &Dict::default(), usize::MAX).unwrap();
let decoded = flate::decode(&input, &Dict::default(), usize::MAX, None).unwrap();
assert_eq!(decoded, b"Hello");
}
@@ -1437,7 +1478,134 @@ mod tests {
/// PdfCraft patch tests: PNG predictors are undone byte by byte (`unfilter_png`).
#[cfg(test)]
mod pdfcraft_png_predictor_tests {
use crate::filter::lzw_flate::{PredictorParams, apply_predictor};
use crate::filter::lzw_flate::{PredictorParams, apply_predictor, flate, untagged_png_rows};
use crate::object::{Dict, FromBytes};
/// `data` as one stored (uncompressed) deflate block, at most 65,535 bytes.
fn stored(data: &[u8]) -> Vec<u8> {
let len = u16::try_from(data.len()).unwrap();
let mut out = vec![1];
out.extend(len.to_le_bytes());
out.extend((!len).to_le_bytes());
out.extend(data);
out
}
/// `data` inflated and its PNG predictor undone, as the last filter of an image `rows` high.
fn inflate(parms: &[u8], data: &[u8], rows: Option<usize>) -> Option<Vec<u8>> {
let parms = Dict::from_bytes(parms).unwrap();
flate::decode(&stored(data), &parms, usize::MAX, rows)
}
const RGB3: &[u8] = b"<< /Predictor 15 /Colors 3 /BitsPerComponent 8 /Columns 3 >>";
#[test]
fn an_image_whose_rows_carry_no_png_tags_is_read_as_its_samples() {
// Three RGB pixels a row, four rows: 36 bytes, where the predictor would need 40. Every
// row starts with a byte above 4, which the predictor took for a filter type, so the
// image came out three rows of shifted samples.
let samples: Vec<u8> = (0..36).map(|i| 200 - i as u8).collect();
assert_eq!(inflate(RGB3, &samples, Some(4)).unwrap(), samples);
let params = PredictorParams {
predictor: 15,
colors: 3,
bits_per_component: 8,
columns: 3,
early_change: false,
};
assert!(
untagged_png_rows(&params, 36, Some(4)),
"the repair is reported"
);
// Not an image's samples (no height known): the predictor is undone, as before.
assert_eq!(
inflate(RGB3, &samples, None).unwrap(),
apply_predictor(samples.clone(), &params).unwrap()
);
assert!(!untagged_png_rows(&params, 36, None));
}
#[test]
fn an_image_whose_rows_carry_png_tags_still_has_its_predictor_undone() {
// The same four rows, each filtered None, then Sub: 40 bytes.
let samples: Vec<u8> = (0..36).map(|i| 200 - i as u8).collect();
let mut tagged = Vec::new();
for row in samples.chunks(9) {
tagged.push(1);
let mut left = [0u8; 3];
for px in row.chunks(3) {
tagged.extend(px.iter().zip(left).map(|(s, l)| s.wrapping_sub(l)));
left.copy_from_slice(px);
}
}
assert_eq!(tagged.len(), 40);
assert_eq!(inflate(RGB3, &tagged, Some(4)).unwrap(), samples);
}
#[test]
fn data_of_neither_length_decodes_as_it_did() {
let params = PredictorParams {
predictor: 15,
colors: 3,
bits_per_component: 8,
columns: 3,
early_change: false,
};
// Short of the untagged length, past it, or between it and the tagged one.
for len in [0, 1, 9, 27, 35, 37, 39, 41, 72] {
let data: Vec<u8> = (0..len).map(|i| (i * 7) as u8 % 5).collect();
assert!(!untagged_png_rows(&params, len, Some(4)), "{len} bytes");
assert_eq!(
inflate(RGB3, &data, Some(4)),
apply_predictor(data.clone(), &params),
"{len} bytes"
);
}
// Rows of the untagged length for another height.
assert!(!untagged_png_rows(&params, 36, Some(3)));
assert!(!untagged_png_rows(&params, 36, Some(0)));
// No PNG predictor, or one with an invalid depth.
for (predictor, bits_per_component) in [(1, 8), (2, 8), (15, 3), (15, 0)] {
let params = PredictorParams {
predictor,
bits_per_component,
..params
};
assert!(!untagged_png_rows(&params, 36, Some(4)));
}
}
#[test]
fn rows_of_no_bytes_or_of_more_than_memory_holds_do_not_panic() {
for (colors, bits_per_component, columns) in [
(0, 8, 3),
(3, 8, 0),
(255, 16, usize::MAX),
(1, 1, usize::MAX),
(3, 8, usize::MAX / 24 + 1),
] {
let params = PredictorParams {
predictor: 15,
colors,
bits_per_component,
columns,
early_change: false,
};
for rows in [None, Some(0), Some(1), Some(4), Some(usize::MAX)] {
for len in [0, 1, 36, usize::MAX] {
let _ = untagged_png_rows(&params, len, rows);
}
}
}
let zero = b"<< /Predictor 15 /Colors 3 /BitsPerComponent 8 /Columns 0 >>";
let huge =
b"<< /Predictor 15 /Colors 255 /BitsPerComponent 16 /Columns 9223372036854775807 >>";
for parms in [&zero[..], &huge[..]] {
for rows in [None, Some(0), Some(4), Some(usize::MAX)] {
let _ = inflate(parms, &[7; 36], rows);
}
}
}
/// PNG unfiltering written out from the PNG specification (§9.2–9.4), one byte at a time.
fn reference(data: &[u8], row_len: usize, bpp: usize) -> Vec<u8> {
+5 -2
View File
@@ -87,13 +87,16 @@ impl Filter {
}
}
/// PdfCraft patch: `limit` is the most bytes the filter may produce (see `decode_limit`).
/// PdfCraft patch: `limit` is the most bytes the filter may produce (see `decode_limit`), and
/// `rows` the `/Height` of the image whose samples it produces, when it is an image's last
/// filter.
pub(crate) fn apply(
&self,
data: &[u8],
params: &Dict<'_>,
#[cfg_attr(not(feature = "images"), allow(unused))] image_params: &ImageDecodeParams,
limit: usize,
rows: Option<usize>,
) -> Result<FilterResult<'static>, DecodeFailure> {
let res = match self {
Self::AsciiHexDecode => ascii_hex::decode(data)
@@ -108,7 +111,7 @@ impl Filter {
Self::LzwDecode => lzw_flate::lzw::decode(data, params, limit)
.map(FilterResult::from_data)
.ok_or(DecodeFailure::StreamDecode),
Self::FlateDecode => lzw_flate::flate::decode(data, params, limit)
Self::FlateDecode => lzw_flate::flate::decode(data, params, limit, rows)
.map(FilterResult::from_data)
.ok_or(DecodeFailure::StreamDecode),
#[cfg(feature = "images")]
+26
View File
@@ -273,11 +273,16 @@ impl<'a> Stream<'a> {
}
// PdfCraft patch: see `filter::decode_limit`.
let limit = crate::filter::decode_limit(image_params, i + 1 == count).min(max);
// PdfCraft patch: the last filter of an image produces its rows.
let rows = (i + 1 == count && image_params.height > 0)
.then(|| usize::try_from(image_params.height).ok())
.flatten();
let new = filter.apply(
current.as_ref().map(|c| c.data.as_ref()).unwrap_or(&data),
params,
image_params,
limit,
rows,
)?;
current = Some(new);
}
@@ -322,6 +327,10 @@ impl<'a> Readable<'a> for Stream<'a> {
r.jump(offset);
parse_fallback(r, &dict)
})
.or_else(|| {
r.jump(offset);
parse_declared(r, &dict)
})
.error_none("was unable to manually parse the stream")
}
}
@@ -431,6 +440,23 @@ fn parse_fallback<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option<Stream<'a>>
Some(Stream::new(data, dict.clone()))
}
/// PdfCraft patch (36): a stream with no `endstream` after it, as a producer that writes
/// `endstrea` leaves every stream, is the bytes its `/Length` gives, "how many bytes of the PDF
/// file are used for the stream's data" (ISO 32000-2 7.3.8.2), as other readers take it. Neither
/// parse above found it, so every font and page content of such a file was lost and its pages
/// drew blank. Called only after [`parse_fallback`] found no `endstream` in the rest of the data,
/// so it doesn't search again, and only a `/Length` the data holds is read.
fn parse_declared<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option<Stream<'a>> {
let length = dict.get::<u32>(LENGTH)?;
r.skip_white_spaces_and_comments();
r.forward_tag(b"stream")?;
forward_stream_eol(r)?;
let data = r.read_bytes(length as usize)?;
Some(Stream::new(data, dict.clone()))
}
fn trim_trailing_ascii_whitespace(data: &[u8]) -> usize {
let mut end = data.len();
+51 -5
View File
@@ -403,12 +403,36 @@ impl<'a> Page<'a> {
return None;
}
let media_box = dict.get::<Rect>(MEDIA_BOX).or(ctx.media_box).unwrap_or(A4);
// PdfCraft patch (38): a box that encloses no area is no box. A `/MediaBox` without
// area (`[0 0 0 0]`) is read as a missing one, and a `/CropBox` that has none, or
// none in common with the media box, as a missing one (the media box, ISO 32000-2
// §14.11.2, Table 33). Kept, the page's visible region had no area: nothing was drawn
// and no text was extracted, where other readers show the page.
// A page's own box without area gives way to one it inherits that has some. Each box
// set aside is logged, as other repairs of this reader are.
// PdfCraft patch (44): a page with no `/MediaBox`, its own or inherited, or only ones
// without area, is US Letter, not A4. The box is required (ISO 32000-2 §7.7.3.3,
// Table 31) and has no default; other readers use Letter, as do PdfCraft's page model
// and inspector. The page is then sized, placed and clipped by the same Letter region
// and its content shows.
let own_media = dict.get::<Rect>(MEDIA_BOX);
let media_box = own_media
.filter(has_area)
.or(ctx.media_box.filter(has_area))
.unwrap_or(LETTER);
if own_media.or(ctx.media_box).is_some_and(|m| !has_area(&m)) {
warn!("a /MediaBox without area was read as missing");
}
let crop_box = dict
.get::<Rect>(CROP_BOX)
.or(ctx.crop_box)
let shows = |c: &Rect| has_area(&c.intersect(media_box));
let own_crop = dict.get::<Rect>(CROP_BOX);
let crop_box = own_crop
.filter(shows)
.or(ctx.crop_box.filter(shows))
.unwrap_or(media_box);
if own_crop.or(ctx.crop_box).is_some_and(|c| !shows(&c)) {
warn!("a /CropBox with no area in the media box was read as missing");
}
let rotation = match dict
.get::<i32>(ROTATE)
@@ -556,7 +580,8 @@ impl<'a> Page<'a> {
if (crop_box.width() as f32).is_nearly_zero() || (crop_box.height() as f32).is_nearly_zero()
{
(A4.width() as f32, A4.height() as f32)
// PdfCraft patch (44): the same US Letter default as a missing `/MediaBox`.
(LETTER.width() as f32, LETTER.height() as f32)
} else {
// PdfCraft patch: retain a one-point floor after UserUnit, not one raw unit.
// The stored unit is finite and within 1..=75000. Round upward only if the
@@ -798,6 +823,18 @@ impl<'a> Resources<'a> {
const POINTS_PER_INCH: f64 = 72.0;
const POINTS_PER_MM: f64 = 1.0 / (10.0 * 2.54) * POINTS_PER_INCH;
/// PdfCraft patch (38): whether `r` encloses some area: finite, and wider and taller than
/// nothing.
fn has_area(r: &Rect) -> bool {
let (w, h) = (r.width() as f32, r.height() as f32);
w.is_finite()
&& h.is_finite()
&& w > 0.0
&& h > 0.0
&& !w.is_nearly_zero()
&& !h.is_nearly_zero()
}
/// The dimension of an A4 page.
pub const A4: Rect = Rect {
x0: 0.0,
@@ -806,6 +843,15 @@ pub const A4: Rect = Rect {
y1: 297.0 * POINTS_PER_MM,
};
/// PdfCraft patch (44): the dimension of a US Letter page (8.5 × 11 in), the size of a page
/// that has no `/MediaBox`.
pub const LETTER: Rect = Rect {
x0: 0.0,
y0: 0.0,
x1: 8.5 * POINTS_PER_INCH,
y1: 11.0 * POINTS_PER_INCH,
};
pub(crate) mod cached {
use crate::page::Pages;
use crate::reader::ReaderContext;
+46 -3
View File
@@ -6,7 +6,7 @@ use crate::page::Pages;
use crate::page::cached::CachedPages;
use crate::reader::Reader;
use crate::sync::Arc;
use crate::xref::{XRef, XRefError, fallback, root_xref};
use crate::xref::{XRef, XRefError, cut_off_update_follows, fallback, root_xref};
pub use crate::crypto::DecryptionError;
use crate::metadata::Metadata;
@@ -17,6 +17,8 @@ pub struct Pdf {
header_version: PdfVersion,
pages: CachedPages,
data: PdfData,
/// PdfCraft patch: (hayro-syntax (45)) see [`Pdf::rebuilt_unlinked_update`].
rebuilt_unlinked_update: bool,
}
/// An error that occurred while loading a PDF file.
@@ -47,8 +49,41 @@ impl Pdf {
let data = data.into();
let password = password.as_bytes();
let version = find_version(data.as_ref()).unwrap_or(PdfVersion::Pdf10);
let xref = match root_xref(data.clone(), password) {
Ok(x) => x,
// PdfCraft patch: (hayro-syntax (43)) a last update cut off before its trailer is read by
// rebuilding the table from object headers, the latest definition of each object
// winning, as the document model reads such a file. Only when that finds no page is the
// older table the last `startxref` names read instead (a file cut short in the middle of
// the update, whose new page tree names pages that are not there).
// The table the last `startxref` names is read first either way: a wrong password is
// refused through it, and the rebuilt table must be encrypted exactly when it is, so a
// rebuild that missed the encryption dictionary is not taken.
let root = root_xref(data.clone(), password);
if let Err(XRefError::Encryption(e)) = root {
return Err(LoadPdfError::Decryption(e));
}
let root_encrypted = root.as_ref().ok().map(|(x, _)| x.is_encrypted());
// PdfCraft patch: (hayro-syntax (45)) said of the file whichever table is kept, as
// `pdfcraft-cos` says it under the same conditions.
let rebuilt_unlinked_update = root.as_ref().is_ok_and(|(_, rebuilt)| *rebuilt);
if cut_off_update_follows(data.as_ref())
&& let Some(xref) = fallback(data.clone(), password)
.filter(|x| root_encrypted.is_none_or(|e| e == x.is_encrypted()))
{
let xref = Arc::new(xref);
if let Some(pages) = CachedPages::new(xref.clone())
&& !pages.get().is_empty()
{
return Ok(Self {
xref,
header_version: version,
pages,
data,
rebuilt_unlinked_update,
});
}
}
let xref = match root {
Ok((x, _)) => x,
Err(e) => match e {
XRefError::Unknown => {
fallback(data.clone(), password).ok_or(LoadPdfError::Invalid)?
@@ -65,9 +100,17 @@ impl Pdf {
header_version: version,
pages,
data,
rebuilt_unlinked_update,
})
}
/// PdfCraft patch: (hayro-syntax (45)) whether the newest cross-reference section has no
/// `/Prev`, so the page tree in the revisions before it could only be found by rebuilding the
/// table from the file's objects.
pub fn rebuilt_unlinked_update(&self) -> bool {
self.rebuilt_unlinked_update
}
/// Return the number of objects present in the PDF file.
pub fn len(&self) -> usize {
self.xref.len()
+130 -3
View File
@@ -50,19 +50,55 @@ pub(crate) enum XRefError {
}
/// Parse the "root" xref from the PDF.
pub(crate) fn root_xref(data: PdfData, password: &[u8]) -> Result<XRef, XRefError> {
///
/// PdfCraft patch: (hayro-syntax (45)) also whether the table was rebuilt because the newest
/// section cuts off the page tree (see below).
pub(crate) fn root_xref(data: PdfData, password: &[u8]) -> Result<(XRef, bool), XRefError> {
let mut xref_map = FxHashMap::default();
let xref_pos = find_last_xref_pos(data.as_ref()).ok_or(XRefError::Unknown)?;
let trailer =
populate_xref_impl(data.as_ref(), xref_pos, &mut xref_map).ok_or(XRefError::Unknown)?;
// PdfCraft patch: (hayro-syntax (45)) whether the newest section names an earlier one. Its
// `/XRefStm` (a hybrid file's stream, §7.5.8.4) belongs to the same update and doesn't count,
// as `pdfcraft-cos` doesn't count it.
let linked = Reader::new(trailer)
.read_with_context::<Dict<'_>>(&ReaderContext::dummy())
.is_some_and(|newest| prev_offset(data.as_ref(), &newest, &XrefMap::default()).is_some());
XRef::new(
let xref = XRef::new(
data.clone(),
xref_map,
XRefInput::TrailerDictData(trailer),
false,
password,
)
)?;
// PdfCraft patch: (hayro-syntax (45)) an update whose section has no `/Prev` (ISO 32000-2
// §7.5.6, §7.5.8.2) cuts off the revisions before it, and with them the page tree its catalog
// names (the signed files of PDFBOX-2720 and PDFBOX-2942). The table is rebuilt by scanning
// the file, as for a broken one, so the page tree is found rather than whatever page
// dictionaries `Pages::new_brute_force` turns up. Only when that section names no earlier
// one, the file holds another `startxref`, and the page tree can't be read: the conditions
// `pdfcraft-cos` rebuilds under, so the two agree on the pages.
let cut_off = !linked
&& xref
.get::<Dict<'_>>(xref.trailer_data().pages_ref)
.is_none()
&& more_than_one_startxref(data.as_ref());
if cut_off {
warn!(
"the newest xref section has no Prev and the page tree is unreachable; rebuilding the xref table"
);
xref.repair();
}
Ok((xref, cut_off))
}
/// PdfCraft patch: (hayro-syntax (45)) whether `data` holds an earlier `startxref` than its last.
fn more_than_one_startxref(data: &[u8]) -> bool {
let needle = b"startxref";
findr_needle(data, needle)
.and_then(|last| data.get(..last))
.is_some_and(|before| crate::util::find_needle(before, needle).is_some())
}
/// Try to manually parse the PDF to build an xref table and trailer dictionary.
@@ -632,6 +668,15 @@ impl XRef {
locked.xref_map = xref_map;
}
/// PdfCraft patch: (hayro-syntax (43)) whether objects read through this table are
/// decrypted.
pub(crate) fn is_encrypted(&self) -> bool {
match &self.0 {
Inner::Dummy => false,
Inner::Some(r) => !matches!(r.decryptor.as_ref(), Decryptor::None),
}
}
#[inline]
pub(crate) fn needs_decryption(&self, ctx: &ReaderContext<'_>) -> bool {
match &self.0 {
@@ -874,6 +919,11 @@ pub(crate) enum XRefInput<'a> {
RootRef(ObjectIdentifier),
}
/// PdfCraft patch: (hayro-syntax (43)) how far from the end of the file a `startxref` counts as
/// the file's own footer, as `pdfcraft-cos` judges it (ISO 32000-2 §7.5.5 puts it on the last lines
/// of the file).
const STARTXREF_TAIL: usize = 4096;
pub(crate) fn find_last_xref_pos(data: &[u8]) -> Option<usize> {
let needle = b"startxref";
let pos = findr_needle(data, needle)?;
@@ -884,6 +934,83 @@ pub(crate) fn find_last_xref_pos(data: &[u8]) -> Option<usize> {
finder.read_without_context::<i32>()?.try_into().ok()
}
/// PdfCraft patch: (hayro-syntax (43)) whether the last update of the file was cut off before its
/// own cross-reference section and trailer: the last `startxref` is not near the end of the file
/// and objects follow it. That section would supersede the older ones (ISO 32000-2 §7.5.6), so
/// the update's objects are the document; reading the older table the last `startxref` names
/// shows a revision the document model (`pdfcraft-cos`, which rebuilds such a file from its
/// object headers) does not have. A `startxref` near the end, or one followed only by other data
/// (trailing junk), is no cut-off update.
pub(crate) fn cut_off_update_follows(data: &[u8]) -> bool {
let needle = b"startxref";
findr_needle(data, needle).is_some_and(|pos| {
pos < data.len().saturating_sub(STARTXREF_TAIL)
&& data
.get(pos.saturating_add(needle.len())..)
.is_some_and(has_object_header)
})
}
/// PdfCraft patch: (hayro-syntax (43)) whether `data` holds an `N G obj` header: two runs of
/// digits and the keyword, separated by white space, at the start of the data or after white
/// space or `>`, the keyword ended by white space, a delimiter or the end of the data (ISO
/// 32000-2 §7.2.3, Table 2), as the document model's repair scan reads it: `1 0 obj_name` is
/// no header, and neither is one whose object number doesn't fit 32 bits (`4294967296 0 obj`),
/// which that scan skips too. One linear pass, no allocation.
fn has_object_header(data: &[u8]) -> bool {
let digits = |from: usize| {
data.get(from..).map_or(0, |rest| {
rest.iter().take_while(|b| b.is_ascii_digit()).count()
})
};
let spaces = |from: usize| {
data.get(from..).map_or(0, |rest| {
rest.iter()
.take_while(|&&b| is_white_space_character(b))
.count()
})
};
let mut i = 0;
while i < data.len() {
let at_start = i
.checked_sub(1)
.and_then(|prev| data.get(prev))
.is_none_or(|&b| is_white_space_character(b) || b == b'>');
let n = if at_start { digits(i) } else { 0 };
if n == 0 {
i += 1;
continue;
}
let mut at = i + n;
let s1 = spaces(at);
at += s1;
let g = digits(at);
at += g;
let s2 = spaces(at);
at += s2;
// The object number as the model reads it, a 32-bit unsigned integer.
let number_fits = data
.get(i..i + n)
.and_then(|d| core::str::from_utf8(d).ok())
.is_some_and(|d| d.parse::<u32>().is_ok());
if number_fits
&& s1 > 0
&& g > 0
&& s2 > 0
&& data.get(at..).is_some_and(|rest| rest.starts_with(b"obj"))
&& data.get(at + 3).is_none_or(|&b| {
is_white_space_character(b)
|| matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%')
})
{
return true;
}
// Past the digits read: none of them starts a header.
i += n;
}
false
}
/// A type of xref entry.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum EntryType {
+14
View File
@@ -160,6 +160,10 @@ pub struct RenderSettings {
/// PdfCraft patch: when set, solid text and line-art paint colours are remapped while
/// rendering; `None` renders exactly as without the patch.
pub color_replacement: Option<ColorReplacement>,
/// PdfCraft patch (31): the most coverage values the rasterizer keeps for the page; paths
/// past it are left out, with `InterpreterWarning::ContentTruncated`. `None` keeps as many
/// as a strip's index reaches (2^31).
pub max_coverage: Option<usize>,
}
impl Default for RenderSettings {
@@ -173,6 +177,7 @@ impl Default for RenderSettings {
x_offset: 0.0,
y_offset: 0.0,
color_replacement: None,
max_coverage: None,
}
}
}
@@ -257,6 +262,10 @@ pub fn render_into<'a>(
interpreter_settings.preview,
);
if let Some(max) = render_settings.max_coverage {
device.ctx.set_max_coverage(max);
}
// PdfCraft patch (18): the page renders as one transparency group (the page group). Its
// objects composite with an initially transparent backdrop, and the group is then put on
// the paper (ISO 32000-2, page group), so a blend mode only blends where something was
@@ -287,6 +296,11 @@ pub fn render_into<'a>(
}
interpret_page(page, &mut state, &mut device);
// PdfCraft patch (31): what the rasterizer left out once its coverage was full (a page that
// paints the same content millions of times over) is content left out, said as such.
if device.ctx.coverage_full() {
(interpreter_settings.warning_sink)(hayro_interpret::InterpreterWarning::ContentTruncated);
}
if !backdrop_inside {
device.pop_transparency_group();
+55
View File
@@ -69,6 +69,21 @@ impl StripStorage {
self.strips.is_empty() && self.alphas.is_empty()
}
/// PdfCraft patch (2): whether these strips have room for the coverage of one more path on a
/// `width` x `height` surface, which covers at most the whole surface (twice, to be safe). A
/// strip packs its index into the alphas in 31 bits and [`Strip::new`] asserts that it fits:
/// a scene that painted past it (a form drawn 2^22 times over) panicked.
pub fn room_for_path(&self, width: u16, height: u16) -> bool {
self.room_for_path_within(width, height, MAX_ALPHAS)
}
/// [`room_for_path`](Self::room_for_path) with a scene kept to `max_alphas` coverage values
/// (never more than a strip's index can reach).
pub fn room_for_path_within(&self, width: u16, height: u16, max_alphas: usize) -> bool {
let surface = usize::from(width) * usize::from(height);
self.alphas.len().saturating_add(surface.saturating_mul(2)) < max_alphas.min(MAX_ALPHAS)
}
/// Extend the current strip storage with the data from another storage.
pub fn extend(&mut self, other: &Self) {
self.strips.extend(&other.strips);
@@ -76,6 +91,10 @@ impl StripStorage {
}
}
/// PdfCraft patch (2): the most coverage (alpha) values a strip index can reach (31 bits; the
/// top bit of [`Strip`]'s packed index is its fill-gap flag).
const MAX_ALPHAS: usize = 1 << 31;
/// An object for easily generating strips for a filled/stroked path.
#[derive(Debug)]
pub struct StripGenerator {
@@ -439,3 +458,39 @@ mod tests {
assert_rect_fast_eq_path(Rect::new(18.0, 18.0, 2.0, 2.0), "inverted_both_axes");
}
}
#[cfg(test)]
mod pdfcraft_tests {
use super::*;
/// PdfCraft patch (2): room runs out a whole surface's coverage before the limit, so the path
/// that would cross it is never generated; at the real limit, an index of `MAX_ALPHAS` would
/// not fit a strip.
#[test]
fn strips_have_room_until_a_surface_short_of_the_limit() {
let mut storage = StripStorage::default();
assert!(storage.room_for_path_within(10, 10, 1_000));
storage.alphas.resize(799, 0);
assert!(
storage.room_for_path_within(10, 10, 1_000),
"799 + 200 < 1000"
);
storage.alphas.push(0);
assert!(
!storage.room_for_path_within(10, 10, 1_000),
"800 + 200 reaches the limit"
);
assert!(
storage.room_for_path_within(8192, 8192, usize::MAX),
"a large page's surface fits, a larger limit held to a strip's index"
);
assert!(
!storage.room_for_path_within(u16::MAX, u16::MAX, usize::MAX),
"the largest surface is counted without overflow, and cannot be promised room"
);
assert!(
Strip::new(0, 0, (MAX_ALPHAS - 1) as u32, true).fill_gap(),
"the last index below the limit fits"
);
}
}
+8
View File
@@ -70,6 +70,14 @@ pub(crate) trait Dispatcher: Debug + Send + Sync {
fn pop_layer(&mut self);
fn reset(&mut self);
fn flush(&mut self, encoded_paints: &[EncodedPaint]);
/// PdfCraft patch (1): whether a path was left out of this scene because its coverage was
/// full (only the single-threaded dispatcher keeps count).
fn coverage_full(&self) -> bool {
false
}
/// PdfCraft patch (1): keep the scene to `max` coverage values (never more than a strip's
/// index reaches); the single-threaded dispatcher leaves later paths out past it.
fn set_max_coverage(&mut self, _max: usize) {}
fn rasterize(
&self,
buffer: &mut [u8],
+42 -2
View File
@@ -42,6 +42,11 @@ pub(crate) struct SingleThreadedDispatcher {
layer_id_next: u32,
/// Dependency graph tracking layer relationships and filter effects.
render_graph: RenderGraph,
/// PdfCraft patch (1): a path was left out because the scene's coverage was full.
coverage_full: bool,
/// PdfCraft patch (1): the most coverage values the scene keeps
/// ([`Dispatcher::set_max_coverage`]).
max_coverage: usize,
}
impl SingleThreadedDispatcher {
@@ -79,9 +84,25 @@ impl SingleThreadedDispatcher {
level,
layer_id_next: 0,
render_graph,
coverage_full: false,
max_coverage: usize::MAX,
}
}
/// PdfCraft patch (1): whether the scene has room for the coverage of one more path
/// ([`StripStorage::room_for_path`]). Once it hasn't, the path and every later one are left
/// out, and [`Dispatcher::coverage_full`] says so.
fn room_for_path(&mut self) -> bool {
let (width, height) = (self.strip_generator.width(), self.strip_generator.height());
let room = self
.strip_storage
.room_for_path_within(width, height, self.max_coverage);
if !room {
self.coverage_full = true;
}
room
}
/// Rasterizes the scene using f32 precision (high quality).
///
/// This dispatches to the appropriate SIMD implementation based on the
@@ -539,6 +560,9 @@ impl Dispatcher for SingleThreadedDispatcher {
mask: Option<Mask>,
encoded_paints: &[EncodedPaint],
) {
if !self.room_for_path() {
return;
}
let wide = &mut self.wide;
// Convert path to coverage strips.
@@ -573,6 +597,9 @@ impl Dispatcher for SingleThreadedDispatcher {
mask: Option<Mask>,
encoded_paints: &[EncodedPaint],
) {
if !self.room_for_path() {
return;
}
let wide = &mut self.wide;
// Convert stroked path to coverage strips.
@@ -604,6 +631,9 @@ impl Dispatcher for SingleThreadedDispatcher {
mask: Option<Mask>,
encoded_paints: &[EncodedPaint],
) {
if !self.room_for_path() {
return;
}
let wide = &mut self.wide;
// Generate strips directly for the rectangle (bypasses path processing).
@@ -638,8 +668,9 @@ impl Dispatcher for SingleThreadedDispatcher {
// Allocate a new unique layer ID.
self.layer_id_next += 1;
// Generate clip coverage if a clip path is provided.
let clip = if let Some(c) = clip_path {
// Generate clip coverage if a clip path is provided. PdfCraft patch (1): without room for
// it the layer goes unclipped, as nothing can be drawn into it any more either.
let clip = if let Some(c) = clip_path.filter(|_| self.room_for_path()) {
self.strip_generator.generate_filled_path(
c,
fill_rule,
@@ -681,6 +712,7 @@ impl Dispatcher for SingleThreadedDispatcher {
self.strip_storage.clear();
self.render_graph.clear();
self.layer_id_next = 0;
self.coverage_full = false;
// Recreate root node as node 0 (required for proper execution order).
let root_node = self.render_graph.add_node(RenderNodeKind::RootLayer {
@@ -697,6 +729,14 @@ impl Dispatcher for SingleThreadedDispatcher {
// No-op for single-threaded dispatcher (no work queue to flush).
}
fn coverage_full(&self) -> bool {
self.coverage_full
}
fn set_max_coverage(&mut self, max: usize) {
self.max_coverage = max;
}
fn rasterize(
&self,
buffer: &mut [u8],
+14
View File
@@ -583,6 +583,20 @@ impl RenderContext {
self.filter = None;
}
/// PdfCraft patch (1): whether paths were left out of the scene because its coverage was
/// full (a page that paints the same content millions of times over). Cleared by
/// [`reset`](Self::reset).
pub fn coverage_full(&self) -> bool {
self.dispatcher.coverage_full()
}
/// PdfCraft patch (1): keep the scene to `max` coverage (alpha) values, fewer than the
/// 2^31 a strip's index reaches, after which [`coverage_full`](Self::coverage_full) says
/// paths were left out. Kept across [`reset`](Self::reset).
pub fn set_max_coverage(&mut self, max: usize) {
self.dispatcher.set_max_coverage(max);
}
/// Reset the render context.
pub fn reset(&mut self) {
self.dispatcher.reset();