mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
Merge pull request #5698 from Piohrun/fix/jbig2-dictionary-any-route
fix(redact): a shared JBIG2 dictionary leaves with whatever used it, by any route
This commit is contained in:
12 files changed
+1896
-151
No files matched your search
@@ -19,6 +19,7 @@ mod reader;
|
||||
mod state;
|
||||
mod xref;
|
||||
|
||||
pub use objects::DECODE_PARMS_KEYS;
|
||||
pub(crate) use objects::Held;
|
||||
#[cfg(test)]
|
||||
pub(crate) use reader::find;
|
||||
|
||||
@@ -507,17 +507,6 @@ impl Document {
|
||||
pdfcraft_filters::decode_ccitt_within(&data, &p, rows, work).map_err(filter)
|
||||
}
|
||||
|
||||
/// The `/JBIG2Globals` stream a JBIG2 image `s` names by reference in its last filter's
|
||||
/// `/DecodeParms`: `None` for a stream whose last filter isn't `JBIG2Decode`, or one without
|
||||
/// globals. Redaction finds with it the other images that share a redacted image's globals.
|
||||
pub fn jbig2_globals_ref(&self, s: &Stream) -> Option<ObjRef> {
|
||||
let chain = s.readable_filters().ok()?;
|
||||
if !matches!(chain.last(), Some((pdfcraft_filters::Filter::Jbig2, _))) {
|
||||
return None;
|
||||
}
|
||||
self.last_filter_parms(s).get(b"JBIG2Globals").and_then(Object::as_ref)
|
||||
}
|
||||
|
||||
/// What the `/JBIG2Globals` stream of JBIG2 image `s` (in its last filter's `/DecodeParms`)
|
||||
/// decodes to, within [`MAX_JBIG2_GLOBALS`]; `None` without one (a reference to no object is
|
||||
/// one to null, ISO 32000-2 §7.3.10, which is an absent entry, §7.3.7). The image is then read
|
||||
@@ -1079,7 +1068,7 @@ impl Document {
|
||||
/// The `/DecodeParms` entries the decoders read (ISO 32000-2 Tables 8, 11 and 13: the Flate and
|
||||
/// LZW predictors, CCITT, DCT's colour transform): numbers and flags a file may give by
|
||||
/// reference, which [`Dict::int`] doesn't follow.
|
||||
const DECODE_PARMS_KEYS: [&[u8]; 13] = [
|
||||
pub const DECODE_PARMS_KEYS: [&[u8]; 13] = [
|
||||
b"Predictor",
|
||||
b"Colors",
|
||||
b"BitsPerComponent",
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
use super::*;
|
||||
|
||||
/// What [`crate::reachable_objects`] gives is what a full save keeps: not an object nothing refers to, nor one
|
||||
/// a reference only dangles at, and a ring of references ends.
|
||||
#[test]
|
||||
fn what_is_reachable_is_what_a_full_save_keeps() {
|
||||
let mut doc = Document::new_empty();
|
||||
let root = doc.root().unwrap();
|
||||
let kept = doc.add(Object::Int(7));
|
||||
let dangling = ObjRef::new(4_000, 0);
|
||||
let list = doc.add(Object::Null);
|
||||
let ring = doc.add(Object::Array(vec![Object::Ref(list)]));
|
||||
doc.set(list, Object::Array(vec![Object::Ref(kept), Object::Ref(ring), Object::Ref(dangling)]));
|
||||
let orphan = doc.add(Object::Int(8));
|
||||
doc.update_dict(root, |d| d.set(b"Held".to_vec(), Object::Ref(list))).unwrap();
|
||||
let found = crate::reachable_objects(&doc).unwrap();
|
||||
assert!([root, kept, list, ring].iter().all(|r| found.contains(&r.num)));
|
||||
assert!(!found.contains(&orphan.num) && !found.contains(&dangling.num));
|
||||
let saved = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap();
|
||||
let holds = |n: i64| saved.scan_objects().any(|(_, o)| matches!(*o, Object::Int(v) if v == n));
|
||||
assert!(holds(7) && !holds(8));
|
||||
}
|
||||
|
||||
std::thread_local! {
|
||||
/// How many times `Document::scan_for` read the file on this thread.
|
||||
pub(super) static FILE_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
|
||||
@@ -24,7 +24,7 @@ mod writer;
|
||||
|
||||
pub use budget::{DecodeBudget, ImageBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS, MAX_REDACTED_IMAGE_PIXELS};
|
||||
pub use bytes::Bytes;
|
||||
pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry};
|
||||
pub use document::{DECODE_PARMS_KEYS, Document, ReadState, Revision, SourceIdentity, XrefEntry};
|
||||
pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream, TextChars};
|
||||
pub use parser::{Lexer, is_whitespace, parse_indirect};
|
||||
pub use pdfcraft_crypt::{
|
||||
|
||||
@@ -13306,7 +13306,13 @@ fn redacting_an_optimized_jbig2_page_stores_the_rest_of_its_group_again() {
|
||||
assert_eq!(globals(&doc, images[2]), None, "the DeviceN page is coded alone");
|
||||
pdfcraft_redact::apply(&mut doc, None).unwrap();
|
||||
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
let named = saved.scan_objects().filter(|(_, o)| matches!(&**o, Object::Stream(s) if saved.jbig2_globals_ref(s).is_some())).count();
|
||||
// Optimize writes the parameters direct, so a stream that names the dictionary holds the key.
|
||||
let names = |s: &Stream| {
|
||||
let mut bytes = Vec::new();
|
||||
pdfcraft_cos::serialize(&Object::Dict(s.dict.clone()), &mut bytes);
|
||||
bytes.windows(12).any(|w| w == b"JBIG2Globals")
|
||||
};
|
||||
let named = saved.scan_objects().filter(|(_, o)| matches!(&**o, Object::Stream(s) if names(s))).count();
|
||||
assert_eq!(named, 0, "no image names the dictionary, which left with them");
|
||||
}
|
||||
|
||||
|
||||
+25
-5
@@ -19,15 +19,35 @@ clear_marks(&mut doc, None)?; // remove marks without app
|
||||
inside;
|
||||
- **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared
|
||||
(8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax or JBIG2 as
|
||||
the last filter, decoded through `Document::decoded_image_within`, a JBIG2 image with its
|
||||
the last filter, decoded through `ImageBudget::decode`, a JBIG2 image with its
|
||||
`/JBIG2Globals`; `/DecodeParms` given by reference, numbers inside included, count). The copy
|
||||
is Flate, or for a one-bit image of one component (a scan as a rule) CCITT Group 4, or JBIG2
|
||||
when it was JBIG2, whichever is smallest as stored, coded from the cleared samples; it refers to
|
||||
no globals. Globals hold the shapes of every image that shares them (a scan's pages, often),
|
||||
those of marks under the area included, so every other image that uses the globals of a removed
|
||||
or replaced image is decoded and stored again in place without them, as a copy is (Flate,
|
||||
Group 4 or JBIG2, whichever is smallest; `Report::images_restored`), and they leave the file on the full rewrite; one that can't be
|
||||
decoded for certain refuses the redaction (`RedactError::SharedJbig2Globals`). Codecs redaction doesn't decode (DCT, JPX) are
|
||||
those of marks under the area included, so whatever left the document, by any route, takes its
|
||||
globals with it on the full rewrite: anything else the save would keep that names them, or a
|
||||
copy of them (the same bytes once decoded), is stored again without them, as a copy is (Flate, Group 4 or JBIG2,
|
||||
whichever is smallest; `Report::images_restored`). When one can't be (it isn't JBIG2, its colour
|
||||
space is known to have more than one component, it has no size or one past what redaction
|
||||
clears, its data is in another file, or it can't be decoded), or something else refers to them, `apply`
|
||||
refuses and changes nothing (`RedactError::SharedJbig2Globals`). `remove_hidden` takes them out
|
||||
the same way, but it has changed the document by the time it refuses: a caller passes a copy it
|
||||
can drop. Another dictionary holding the same shapes in other bytes isn't found, nor is a copy
|
||||
nothing names, and an image that hasn't left keeps its globals with its pixels: one another page
|
||||
still draws, or one something that doesn't draw it still refers to. What an image names as
|
||||
globals is read by object number, whatever generation a reference gives, as a reader that doesn't
|
||||
compare generations draws it (cos reads such a reference as null); what one is stored again with,
|
||||
its size, filters and parameters, is read as cos reads it. Finding what to store again
|
||||
reads each object at most once for each way it is read (a `/JBIG2Globals` chain named part way,
|
||||
a ring of references or a chain of more than 33, or a value read through a reference of another
|
||||
generation, may be read again), and what it fetches, remembers and decodes (a decode that
|
||||
fails counting as much as it was allowed, and an image the dictionaries it names) counts against
|
||||
one budget for the run; past it the redaction is refused (`RedactError::SharedJbig2Budget`). The
|
||||
images stored again, and the dictionaries read to know copies of them, are decoded within the
|
||||
run's image allowance, every filter stage and the JBIG2 decoder's own work included, after what
|
||||
clearing spent: past it `apply` is refused as one clearing too many images is
|
||||
(`RedactError::ImagesPastAllowance`), and `remove_hidden`, which chose no pages, as one past the
|
||||
run's budget. Codecs redaction doesn't decode (DCT, JPX) are
|
||||
removed whole (fail-closed), and so is a JBIG2 image whose page isn't its size (the renderer
|
||||
draws the whole page into the image, so the rows cleared wouldn't be the ones shown) or of more
|
||||
than one bit or component, and Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
//! A `/JBIG2Globals` stream is a dictionary of symbols that JBIG2 images can share, and scanners
|
||||
//! and `jbig2enc -s` share one across a scan's pages. It holds the shape of every distinct mark of
|
||||
//! those pages, including a mark found only under a redaction, so taking the image out isn't enough:
|
||||
//! while something else uses the dictionary, a full save keeps it. [`unshare`] stores what uses it
|
||||
//! again without it.
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{CosError, DECODE_PARMS_KEYS, Dict, Document, ImageBudget, MAX_JBIG2_GLOBALS, Object, Stream, reachable_objects};
|
||||
|
||||
/// The references followed from `/JBIG2Globals` towards a stream, so that a ring of them ends.
|
||||
const MAX_HOPS: usize = 32;
|
||||
|
||||
/// The objects a full save would keep, by number, as [`reachable_objects`] finds them: a reference
|
||||
/// whose generation isn't its object's reads as null there (#1143), so it keeps nothing. Fails only
|
||||
/// when a full save couldn't number them all.
|
||||
pub(crate) fn kept(doc: &Document) -> Result<HashSet<u32>, CosError> {
|
||||
reachable_objects(doc)
|
||||
}
|
||||
|
||||
/// `o`, or what references lead to from it. A ring of them ends as a reference, which is no dictionary.
|
||||
pub(crate) fn followed(doc: &Document, o: &Object) -> Arc<Object> {
|
||||
let mut at = doc.resolve(o);
|
||||
for _ in 0..MAX_HOPS {
|
||||
let next = match &*at {
|
||||
Object::Ref(next) => *next,
|
||||
_ => break,
|
||||
};
|
||||
at = doc.get(next);
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
/// The most one run may spend finding what shares a dictionary with what left: objects fetched,
|
||||
/// names remembered, and decoded data, a unit and one for each whole 4 KiB. Past it the run is refused
|
||||
/// ([`RedactError::SharedJbig2Budget`](crate::RedactError::SharedJbig2Budget)). A scan reads a few
|
||||
/// objects a page, and its pages stored again a unit for each 4 KiB of their samples and of the
|
||||
/// dictionary they name, so a book of thousands of pages sharing a dictionary of a MiB or two stays
|
||||
/// within it. The work of decoding, every filter stage's and the JBIG2 decoder's, is paid besides
|
||||
/// from the run's image allowance (`ImageBudget`), which a unit of decoded data says nothing of.
|
||||
pub(crate) const RUN_BUDGET: usize = 1 << 22;
|
||||
|
||||
/// What a parameters value names as `/JBIG2Globals`, and the key it is remembered by: a value the
|
||||
/// file refers to, by the number of the object the references end at and whether it is read as a
|
||||
/// list of entries.
|
||||
pub(crate) type Listed = (Rc<[u32]>, Option<Key>);
|
||||
|
||||
/// A value the file refers to: the number of an object, and whether it is read as a list of entries.
|
||||
pub(crate) type Key = (u32, bool);
|
||||
|
||||
/// How a value is read, and so what is kept of it: what a reference leads to is remembered for each.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum How {
|
||||
/// A size: a number, or null.
|
||||
Number,
|
||||
/// One filter: a name, or null.
|
||||
Name,
|
||||
/// `/Filter`: a name, or an array of names, or null for a chain longer than any decoder takes.
|
||||
Filter,
|
||||
/// `/DecodeParms` for so many filters.
|
||||
Parms(usize),
|
||||
/// One parameters entry: the numbers and flags decoders read, and `/JBIG2Globals` by reference.
|
||||
Entry,
|
||||
/// A number or a flag.
|
||||
Scalar,
|
||||
/// `/F`: whether it names anything (true) or is null.
|
||||
Present,
|
||||
/// `/ColorSpace`: whether it is known to have more than one component.
|
||||
Components,
|
||||
/// An ICC profile: its `/N`, or null.
|
||||
Profile,
|
||||
}
|
||||
|
||||
/// What one redaction or Remove Hidden Information run reads, to find what shares a dictionary with
|
||||
/// what left, against [`RUN_BUDGET`]. What a stream names is read by object number, whatever
|
||||
/// generation a reference gives ([`Run::fetch_numbered`]); what one is stored again with is read as
|
||||
/// cos reads it, by number and generation, so a reference of another generation reads its own way.
|
||||
/// Each object is fetched at most once a run for each way it is read, and kept only as what is read
|
||||
/// of it: a value many streams share, by reference, through a
|
||||
/// chain of them or through an entry, costs it once however many there are. The exceptions are a
|
||||
/// `/JBIG2Globals` chain named part way, which is followed again from there, and a ring of
|
||||
/// references or a chain past the limit, read again (up to 33 fetches) wherever it is met; every
|
||||
/// fetch counts.
|
||||
pub(crate) struct Run {
|
||||
budget: usize,
|
||||
/// What was spent of the budget.
|
||||
pub(crate) spent: usize,
|
||||
/// What a reference leads to, as it is read, under every reference on the way, by number and
|
||||
/// generation: one whose generation isn't its object's reads as null (#1143), and must not give
|
||||
/// another reference to that number what it read, nor take what that one read.
|
||||
reduced: HashMap<(pdfcraft_cos::ObjRef, How), Rc<Object>>,
|
||||
/// What a reference leads to names, under every reference on the way, with the key of the one
|
||||
/// that names what it ends at: aliases and chains find it, and are counted as that one.
|
||||
names: HashMap<Key, (Rc<[u32]>, Key)>,
|
||||
/// What a `/JBIG2Globals` reference leads to: it and each reference after it, by its number.
|
||||
chains: HashMap<u32, Rc<[u32]>>,
|
||||
/// What each dictionary was found to decode to, or its limit when it couldn't be: an image
|
||||
/// naming it may undo its filters again, which costs as much.
|
||||
sizes: HashMap<u32, usize>,
|
||||
/// How many values were read for their names rather than remembered.
|
||||
pub(crate) read: usize,
|
||||
/// How many objects were fetched.
|
||||
pub(crate) fetched: usize,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
pub(crate) fn new(budget: usize) -> Self {
|
||||
Run { budget, spent: 0, reduced: HashMap::new(), names: HashMap::new(), chains: HashMap::new(), sizes: HashMap::new(), read: 0, fetched: 0 }
|
||||
}
|
||||
|
||||
/// Counts `units` against the budget.
|
||||
fn spend(&mut self, units: usize) -> Result<(), crate::RedactError> {
|
||||
self.spent = self.spent.saturating_add(units);
|
||||
if self.spent > self.budget { Err(crate::RedactError::SharedJbig2Budget) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Fetches the object `r` names, counted.
|
||||
fn fetch(&mut self, doc: &Document, r: pdfcraft_cos::ObjRef) -> Result<Arc<Object>, crate::RedactError> {
|
||||
self.spend(1)?;
|
||||
self.fetched += 1;
|
||||
Ok(doc.get(r))
|
||||
}
|
||||
|
||||
/// Fetches the object of `r`'s number, whatever generation `r` gives, counted. Cos reads a
|
||||
/// reference whose generation isn't its object's as null (#1143), but a reader that doesn't
|
||||
/// compare generations draws with the object: what an image that left names this way may hold
|
||||
/// its shapes, so it is named, as what names it this way is a user of it.
|
||||
fn fetch_numbered(&mut self, doc: &Document, r: pdfcraft_cos::ObjRef) -> Result<Arc<Object>, crate::RedactError> {
|
||||
self.fetch(doc, pdfcraft_cos::ObjRef::new(r.num, doc.generation(r.num)))
|
||||
}
|
||||
|
||||
/// What `v` leads to through references, as `followed` goes, kept as `read` makes it. Each
|
||||
/// reference is looked up before it is fetched, and what it leads to is remembered under every
|
||||
/// one on the way, so one remembered costs nothing more, and a chain only the references before
|
||||
/// it. A reference of another generation is looked up as itself, and read as cos reads it. A
|
||||
/// ring, or a chain past the limit, is read as the reference it ends at.
|
||||
fn follow(
|
||||
&mut self,
|
||||
doc: &Document,
|
||||
v: &Object,
|
||||
how: How,
|
||||
read: impl FnOnce(&mut Self, &Document, &Object) -> Result<Object, crate::RedactError>,
|
||||
) -> Result<Rc<Object>, crate::RedactError> {
|
||||
let mut keys: Vec<(pdfcraft_cos::ObjRef, How)> = Vec::new();
|
||||
let mut fetched: Option<Arc<Object>> = None;
|
||||
for _ in 0..=MAX_HOPS {
|
||||
let Some(r) = fetched.as_deref().unwrap_or(v).as_ref() else { break };
|
||||
if let Some(hit) = self.reduced.get(&(r, how)).cloned() {
|
||||
for k in keys {
|
||||
self.reduced.insert(k, hit.clone());
|
||||
}
|
||||
return Ok(hit);
|
||||
}
|
||||
keys.push((r, how));
|
||||
fetched = Some(self.fetch(doc, r)?);
|
||||
}
|
||||
let made = Rc::new(read(self, doc, fetched.as_deref().unwrap_or(v))?);
|
||||
for k in keys {
|
||||
self.reduced.insert(k, made.clone());
|
||||
}
|
||||
Ok(made)
|
||||
}
|
||||
|
||||
/// What `s` names as `/JBIG2Globals`, and each reference on the way to one: in any entry of its
|
||||
/// parameters, whichever filter that entry belongs to and however the filters are given, one
|
||||
/// list for each key that gives it parameters. A reader may use less of this, but leaving a
|
||||
/// dictionary behind is the failure that matters here.
|
||||
pub(crate) fn names_of(&mut self, doc: &Document, s: &Stream) -> Result<Vec<Listed>, crate::RedactError> {
|
||||
// `/FDecodeParms` too: those of data in another file, which a reader may draw instead.
|
||||
[b"DecodeParms".as_slice(), b"DP", b"FDecodeParms"]
|
||||
.into_iter()
|
||||
.filter_map(|key| s.dict.get(key))
|
||||
.map(|v| self.names_in(doc, v, true))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What `v` names: one entry, or (`list`) an array of them or one.
|
||||
fn names_in(&mut self, doc: &Document, v: &Object, list: bool) -> Result<Listed, crate::RedactError> {
|
||||
let mut keys: Vec<Key> = Vec::new();
|
||||
let mut fetched: Option<Arc<Object>> = None;
|
||||
for _ in 0..=MAX_HOPS {
|
||||
let Some(r) = fetched.as_deref().unwrap_or(v).as_ref() else { break };
|
||||
let key = (r.num, list);
|
||||
if let Some((hit, at)) = self.names.get(&key).cloned() {
|
||||
for k in keys {
|
||||
self.names.insert(k, (hit.clone(), at));
|
||||
}
|
||||
return Ok((hit, Some(at)));
|
||||
}
|
||||
keys.push(key);
|
||||
fetched = Some(self.fetch_numbered(doc, r)?);
|
||||
}
|
||||
self.read += 1;
|
||||
let held = fetched.as_deref().unwrap_or(v);
|
||||
let names: Rc<[u32]> = match (held, list) {
|
||||
(Object::Array(entries), true) => {
|
||||
// A set as it is read, so many entries naming the same holds them once.
|
||||
let mut all = std::collections::BTreeSet::new();
|
||||
for e in entries {
|
||||
all.extend(self.names_in(doc, e, false)?.0.iter());
|
||||
}
|
||||
all.into_iter().collect()
|
||||
}
|
||||
(entry, _) => self.entry_names(doc, entry)?.into(),
|
||||
};
|
||||
// Remembered when the references end at an object, under the last of them, and counted
|
||||
// against the budget as held; a ring, or a chain past the limit, is read again where met.
|
||||
let at = keys.last().copied().filter(|_| !matches!(held, Object::Ref(_)));
|
||||
if let Some(at) = at {
|
||||
self.spend(names.len())?;
|
||||
for k in keys {
|
||||
self.names.insert(k, (names.clone(), at));
|
||||
}
|
||||
}
|
||||
Ok((names, at))
|
||||
}
|
||||
|
||||
/// What one parameters entry names as `/JBIG2Globals`, and each reference on the way to it.
|
||||
fn entry_names(&mut self, doc: &Document, entry: &Object) -> Result<Vec<u32>, crate::RedactError> {
|
||||
match entry.as_dict().and_then(|d| d.get(b"JBIG2Globals")).and_then(Object::as_ref) {
|
||||
Some(start) => Ok(self.chain(doc, start)?.to_vec()),
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `start` and each reference after it, up to the limit, remembered under `start` for the run,
|
||||
/// so entries naming it, directly or not, fetch it once however many there are; a chain that
|
||||
/// reaches the start of one already remembered takes what is known from there.
|
||||
fn chain(&mut self, doc: &Document, start: pdfcraft_cos::ObjRef) -> Result<Rc<[u32]>, crate::RedactError> {
|
||||
if let Some(hit) = self.chains.get(&start.num) {
|
||||
return Ok(hit.clone());
|
||||
}
|
||||
let mut found = Vec::new();
|
||||
let mut at = start;
|
||||
for _ in 0..=MAX_HOPS {
|
||||
if let Some(rest) = self.chains.get(&at.num) {
|
||||
found.extend(rest.iter());
|
||||
break;
|
||||
}
|
||||
found.push(at.num);
|
||||
match &*self.fetch_numbered(doc, at)? {
|
||||
Object::Ref(next) => at = *next,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
found.truncate(MAX_HOPS + 1);
|
||||
let found: Rc<[u32]> = found.into();
|
||||
self.spend(found.len())?;
|
||||
self.chains.insert(start.num, found.clone());
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// A size through any references: a number, or null.
|
||||
fn number(&mut self, doc: &Document, v: &Object) -> Result<Object, crate::RedactError> {
|
||||
let read = |_: &mut Self, _: &Document, o: &Object| Ok(if matches!(o, Object::Int(_) | Object::Real(_)) { o.clone() } else { Object::Null });
|
||||
Ok((*self.follow(doc, v, How::Number, read)?).clone())
|
||||
}
|
||||
|
||||
/// A `/Filter` through any references, as a decoder reads it: a name a decoder knows, or an array
|
||||
/// of them, each entry through references too. Anything else is null, a chain longer than any
|
||||
/// decoder takes (`MAX_CHAIN`, and a `/Crypt`) included, so a stream that names it is read as one
|
||||
/// with no filter.
|
||||
fn filter(&mut self, doc: &Document, v: &Object) -> Result<Object, crate::RedactError> {
|
||||
// A name a decoder knows; any other is no filter, and isn't copied, however long.
|
||||
fn name(_: &mut Run, _: &Document, o: &Object) -> Result<Object, crate::RedactError> {
|
||||
let known = |n: &[u8]| n.len() <= 16 && !matches!(pdfcraft_filters::Filter::from_name(n), pdfcraft_filters::Filter::Unknown(_));
|
||||
Ok(match o {
|
||||
Object::Name(n) if known(n) => o.clone(),
|
||||
_ => Object::Null,
|
||||
})
|
||||
}
|
||||
let read = |run: &mut Self, doc: &Document, o: &Object| match o {
|
||||
Object::Array(a) if a.len() > pdfcraft_filters::MAX_CHAIN + 1 => Ok(Object::Null),
|
||||
Object::Array(a) => {
|
||||
let mut names = Vec::with_capacity(a.len());
|
||||
for e in a {
|
||||
names.push((*run.follow(doc, e, How::Name, name)?).clone());
|
||||
}
|
||||
Ok(Object::Array(names))
|
||||
}
|
||||
other => name(run, doc, other),
|
||||
};
|
||||
Ok((*self.follow(doc, v, How::Filter, read)?).clone())
|
||||
}
|
||||
|
||||
/// `/DecodeParms` as a reader takes them, through any references, for `filters` filters: of each
|
||||
/// entry, the numbers and flags the decoders read ([`DECODE_PARMS_KEYS`]), and `/JBIG2Globals` when
|
||||
/// it is a reference (a direct value is no stream, so no decoder reads it, and it may be large).
|
||||
/// An entry that isn't a dictionary is null, as `Document::with_direct_parms` reads them.
|
||||
fn parms(&mut self, doc: &Document, v: &Object, filters: usize) -> Result<Object, crate::RedactError> {
|
||||
fn scalar(_: &mut Run, _: &Document, o: &Object) -> Result<Object, crate::RedactError> {
|
||||
Ok(if matches!(o, Object::Int(_) | Object::Real(_) | Object::Bool(_)) { o.clone() } else { Object::Null })
|
||||
}
|
||||
fn entry(run: &mut Run, doc: &Document, o: &Object) -> Result<Object, crate::RedactError> {
|
||||
let Object::Dict(d) = o else { return Ok(Object::Null) };
|
||||
let mut made = Dict::new();
|
||||
for key in DECODE_PARMS_KEYS {
|
||||
let Some(value) = d.get(key) else { continue };
|
||||
let value = run.follow(doc, value, How::Scalar, scalar)?;
|
||||
if !matches!(*value, Object::Null) {
|
||||
made.set(key.to_vec(), (*value).clone());
|
||||
}
|
||||
}
|
||||
if let Some(globals @ Object::Ref(_)) = d.get(b"JBIG2Globals") {
|
||||
made.set(b"JBIG2Globals".to_vec(), globals.clone());
|
||||
}
|
||||
Ok(Object::Dict(made))
|
||||
}
|
||||
let read = move |run: &mut Self, doc: &Document, o: &Object| match o {
|
||||
Object::Array(a) => {
|
||||
let mut entries = Vec::new();
|
||||
for e in a.iter().take(filters) {
|
||||
entries.push((*run.follow(doc, e, How::Entry, entry)?).clone());
|
||||
}
|
||||
Ok(Object::Array(entries))
|
||||
}
|
||||
other => entry(run, doc, other),
|
||||
};
|
||||
Ok((*self.follow(doc, v, How::Parms(filters), read)?).clone())
|
||||
}
|
||||
|
||||
/// Whether `s`'s data is in another file, which a reader may draw instead of what is here: a `/F`
|
||||
/// that isn't null, through any references (a null entry is as if absent, and a reference to no
|
||||
/// object is null, ISO 32000-2 §7.3.7 and §7.3.10). A ring of references counts as a file.
|
||||
fn elsewhere(&mut self, doc: &Document, s: &Stream) -> Result<bool, crate::RedactError> {
|
||||
let Some(f) = s.dict.get(b"F") else { return Ok(false) };
|
||||
let read = |_: &mut Self, _: &Document, o: &Object| Ok(Object::Bool(!matches!(o, Object::Null)));
|
||||
Ok(matches!(*self.follow(doc, f, How::Present, read)?, Object::Bool(true)))
|
||||
}
|
||||
|
||||
/// Whether `s`'s colour space is known to have more than one component, through any references:
|
||||
/// a JBIG2 image is drawn as one whatever its dictionary says, so such an image read as one bit
|
||||
/// would show otherwise. An unknown colour space isn't known to have more.
|
||||
fn several_components(&mut self, doc: &Document, s: &Stream) -> Result<bool, crate::RedactError> {
|
||||
let Some(cs) = s.dict.get(b"ColorSpace") else { return Ok(false) };
|
||||
fn profile(_: &mut Run, _: &Document, o: &Object) -> Result<Object, crate::RedactError> {
|
||||
// /N lives in the ICC profile stream, and shall be 1, 3 or 4 (ISO 32000-2 §8.6.5.5).
|
||||
let n = match o {
|
||||
Object::Stream(icc) => icc.dict.int(b"N").filter(|n| matches!(n, 1 | 3 | 4)),
|
||||
_ => None,
|
||||
};
|
||||
Ok(n.map_or(Object::Null, Object::Int))
|
||||
}
|
||||
let read = |run: &mut Self, doc: &Document, o: &Object| {
|
||||
let name = match o {
|
||||
Object::Name(n) => n.as_slice(),
|
||||
Object::Array(a) => match a.first().and_then(Object::as_name) {
|
||||
Some(n) => n,
|
||||
None => return Ok(Object::Bool(false)),
|
||||
},
|
||||
_ => return Ok(Object::Bool(false)),
|
||||
};
|
||||
Ok(Object::Bool(match name {
|
||||
b"DeviceRGB" | b"RGB" | b"CalRGB" | b"Lab" | b"DeviceCMYK" | b"CMYK" => true,
|
||||
b"ICCBased" => match o.as_array().and_then(|a| a.get(1)) {
|
||||
Some(entry) => run.follow(doc, entry, How::Profile, profile)?.as_int().is_some_and(|n| n > 1),
|
||||
None => false,
|
||||
},
|
||||
_ => false,
|
||||
}))
|
||||
};
|
||||
Ok(matches!(*self.follow(doc, cs, How::Components, read)?, Object::Bool(true)))
|
||||
}
|
||||
|
||||
/// `s` with what `decoded_image` and `decoded_within` read as given made direct: its size,
|
||||
/// filters and their parameters, each kept small, however often the file refers to a large object.
|
||||
pub(crate) fn readable(&mut self, doc: &Document, s: &Stream) -> Result<Stream, crate::RedactError> {
|
||||
let mut readable = s.clone();
|
||||
for key in [b"Width".as_slice(), b"Height"] {
|
||||
if let Some(v) = s.dict.get(key) {
|
||||
let number = self.number(doc, v)?;
|
||||
readable.dict.set(key.to_vec(), number);
|
||||
}
|
||||
}
|
||||
if let Some(v) = s.dict.get(b"Filter") {
|
||||
let filter = self.filter(doc, v)?;
|
||||
readable.dict.set(b"Filter".to_vec(), filter);
|
||||
}
|
||||
if let Some(v) = s.dict.get(b"DecodeParms") {
|
||||
let filters = match readable.dict.get(b"Filter") {
|
||||
Some(Object::Array(a)) => a.len(),
|
||||
Some(Object::Name(_)) => 1,
|
||||
_ => 0,
|
||||
};
|
||||
let parms = self.parms(doc, v, filters)?;
|
||||
readable.dict.set(b"DecodeParms".to_vec(), parms);
|
||||
}
|
||||
Ok(readable)
|
||||
}
|
||||
|
||||
/// Counts what was decoded: a unit, and one more for each whole 4 KiB.
|
||||
pub(crate) fn decoded(&mut self, bytes: usize) -> Result<(), crate::RedactError> {
|
||||
self.spend(bytes / 4096 + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// What dictionary `num` holds, to know a copy of it by: a file put together from pages split apart
|
||||
/// has one per page, each byte for byte the others.
|
||||
///
|
||||
/// Decoded within `images`, the run's image allowance, which pays for what every filter stage reads
|
||||
/// and makes: what the last one makes says nothing of the work (a predictor over what three Flates
|
||||
/// inflate, which hex then skips as white space, ends at nothing). Past it the run is refused
|
||||
/// ([`RedactError::ImagesPastAllowance`](crate::RedactError::ImagesPastAllowance)).
|
||||
pub(crate) fn content(run: &mut Run, doc: &Document, num: u32, images: &mut ImageBudget) -> Result<Option<u64>, crate::RedactError> {
|
||||
let Ok(object) = doc.try_get(num) else { return Ok(None) };
|
||||
let Object::Stream(s) = &*object else { return Ok(None) };
|
||||
let readable = run.readable(doc, s)?;
|
||||
let refused = images.refused();
|
||||
let data = images.decode_filters(doc, &readable, MAX_JBIG2_GLOBALS);
|
||||
if data.is_err() && images.refused() > refused {
|
||||
return Err(crate::RedactError::ImagesPastAllowance);
|
||||
}
|
||||
let mut hash = DefaultHasher::new();
|
||||
match &data {
|
||||
Ok(data) => {
|
||||
run.decoded(data.len())?;
|
||||
run.sizes.insert(num, data.len());
|
||||
data.hash(&mut hash);
|
||||
}
|
||||
// What it took to find out it can't be read: as much as it was allowed to decode.
|
||||
Err(_) => {
|
||||
run.decoded(MAX_JBIG2_GLOBALS)?;
|
||||
run.sizes.insert(num, MAX_JBIG2_GLOBALS);
|
||||
s.raw[..].hash(&mut hash);
|
||||
}
|
||||
}
|
||||
Ok(Some(hash.finish()))
|
||||
}
|
||||
|
||||
/// JBIG2 stream `s` stored alone, as a cleared copy is: what it shows unchanged and no dictionary
|
||||
/// referred to. `None` for a stream that isn't JBIG2, whose decoder took no dictionary and whose
|
||||
/// samples another filter may give otherwise; for one past the size of an image redaction clears,
|
||||
/// or known to have more than one component, which JBIG2 is drawn as one of whatever the dictionary
|
||||
/// says; for one whose data is in another file (`/F`), which readers draw instead of what is here;
|
||||
/// and for one that can't be decoded.
|
||||
pub(crate) fn without_dictionary(run: &mut Run, doc: &Document, s: &Stream, images: &mut ImageBudget) -> Result<Option<Stream>, crate::RedactError> {
|
||||
if run.elsewhere(doc, s)? {
|
||||
return Ok(None);
|
||||
}
|
||||
let readable = run.readable(doc, s)?;
|
||||
let jbig2 = match readable.dict.get(b"Filter") {
|
||||
Some(Object::Array(a)) => a.iter().any(|f| f.as_name() == Some(b"JBIG2Decode".as_slice())),
|
||||
Some(f) => f.as_name() == Some(b"JBIG2Decode".as_slice()),
|
||||
None => false,
|
||||
};
|
||||
if !jbig2 || run.several_components(doc, s)? {
|
||||
return Ok(None);
|
||||
}
|
||||
let side = |key: &[u8]| readable.dict.int(key).and_then(|v| usize::try_from(v).ok());
|
||||
let (Some(w), Some(h)) = (side(b"Width"), side(b"Height")) else { return Ok(None) };
|
||||
if (w as u64).saturating_mul(h as u64) > crate::image::MAX_PIXELS {
|
||||
return Ok(None);
|
||||
}
|
||||
// As for `clear`: the filters before the JBIG2 decoder may give up to three bytes for each one
|
||||
// of the raster and a byte a row.
|
||||
let Some(allowance) =
|
||||
w.div_ceil(8).checked_mul(h).and_then(|n| n.checked_add(h)).and_then(|n| n.checked_mul(3)).and_then(|n| n.checked_add(4096))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Decoding it undoes the filters of each dictionary it names again when cos doesn't hold their
|
||||
// bytes: charged as much as each was found to decode to.
|
||||
let mut named = std::collections::BTreeSet::new();
|
||||
for (list, _) in run.names_of(doc, s)? {
|
||||
named.extend(list.iter().copied());
|
||||
}
|
||||
let undone = named.iter().filter_map(|n| run.sizes.get(n)).fold(0usize, |sum, size| sum.saturating_add(*size));
|
||||
if undone > 0 {
|
||||
run.decoded(undone)?;
|
||||
}
|
||||
// Through the run's image allowance, which the decoder's own work, the dictionary's filters
|
||||
// when they are undone again and the pixels are paid from. Past it the run is refused, as one
|
||||
// clearing too many images is.
|
||||
let refused = images.refused();
|
||||
let data = match images.decode(doc, &readable, allowance) {
|
||||
Ok(data) => data,
|
||||
Err(_) if images.refused() > refused => return Err(crate::RedactError::ImagesPastAllowance),
|
||||
Err(_) => {
|
||||
// What it took to find out it can't be read: as much as it was allowed to decode.
|
||||
run.decoded(allowance)?;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
run.decoded(data.len())?;
|
||||
let mut dict = readable.dict.clone();
|
||||
// `/FFilter` and `/FDecodeParms` too: with no data in another file they mean nothing, but
|
||||
// `names_of` reads them.
|
||||
for key in [b"Length".as_slice(), b"DecodeParms", b"DP", b"FFilter", b"FDecodeParms"] {
|
||||
dict.remove(key);
|
||||
}
|
||||
dict.set(b"BitsPerComponent".to_vec(), Object::Int(1));
|
||||
Ok(Some(crate::image::smallest(dict, &data, w, h, true, true)))
|
||||
}
|
||||
|
||||
/// Whether dictionary `n` is one that left (`left`), or a copy of one (`contents`); what each
|
||||
/// object holds is found once a run (`read`).
|
||||
fn matches_left(
|
||||
run: &mut Run,
|
||||
doc: &Document,
|
||||
n: u32,
|
||||
left: &HashSet<u32>,
|
||||
contents: &HashSet<u64>,
|
||||
read: &mut HashMap<u32, Option<u64>>,
|
||||
images: &mut ImageBudget,
|
||||
) -> Result<bool, crate::RedactError> {
|
||||
if left.contains(&n) {
|
||||
return Ok(true);
|
||||
}
|
||||
let held = match read.get(&n) {
|
||||
Some(held) => *held,
|
||||
None => {
|
||||
let held = content(run, doc, n, images)?;
|
||||
read.insert(n, held);
|
||||
held
|
||||
}
|
||||
};
|
||||
Ok(held.is_some_and(|c| contents.contains(&c)))
|
||||
}
|
||||
|
||||
/// Takes the `/JBIG2Globals` dictionaries of what left the document since `before` ([`kept`]) out of
|
||||
/// the file: everything a full save would keep that names one, or a copy of one, is stored again
|
||||
/// without it, in place, so what refers to that stream stays as it is. Gives the number stored
|
||||
/// again. A copy is found byte for byte; another dictionary that happens to hold the same shapes isn't.
|
||||
///
|
||||
/// What left, not what a run says it removed: an image goes with a stamp, a widget, a Type 3 glyph,
|
||||
/// an `/Alternates` entry or a form that can't be read, none of which it was drawn through directly.
|
||||
/// An image another page still draws hasn't left: it keeps its pixels, so its dictionary keeps nothing
|
||||
/// more.
|
||||
///
|
||||
/// What it reads and decodes is counted against [`RUN_BUDGET`] for the whole run, and each
|
||||
/// dictionary and image is decoded within `images`, the run's image allowance.
|
||||
///
|
||||
/// Errors: [`RedactError::SharedJbig2Globals`](crate::RedactError::SharedJbig2Globals) when a dictionary would
|
||||
/// still be kept, as what names it can't be stored without it (counted) or something else refers to
|
||||
/// it (0 when that is all); [`RedactError::SharedJbig2Budget`](crate::RedactError::SharedJbig2Budget) when
|
||||
/// finding that would spend more than the budget; [`RedactError::ImagesPastAllowance`](crate::RedactError::ImagesPastAllowance)
|
||||
/// when decoding the dictionaries and images would spend more than `images` has left. Such an image may still be drawn, on a page no mark
|
||||
/// reached, so it isn't removed, and `doc` is part changed by then: a caller works on a copy it can drop.
|
||||
pub(crate) fn unshare(doc: &mut Document, before: &HashSet<u32>, images: &mut ImageBudget) -> Result<usize, crate::RedactError> {
|
||||
let now = kept(doc)?;
|
||||
let mut run = Run::new(RUN_BUDGET);
|
||||
let mut dictionaries: HashSet<u32> = HashSet::new();
|
||||
// A list the file refers to is added once, however many streams share it.
|
||||
let mut added: HashSet<Key> = HashSet::new();
|
||||
for &num in before.difference(&now) {
|
||||
if let Ok(object) = doc.try_get(num)
|
||||
&& let Object::Stream(s) = &*object
|
||||
{
|
||||
for (list, key) in run.names_of(doc, s)? {
|
||||
if key.is_none_or(|k| added.insert(k)) {
|
||||
dictionaries.extend(list.iter());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if dictionaries.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut contents: HashSet<u64> = HashSet::new();
|
||||
for &n in &dictionaries {
|
||||
if let Some(c) = content(&mut run, doc, n, images)? {
|
||||
contents.insert(c);
|
||||
}
|
||||
}
|
||||
let mut read: HashMap<u32, Option<u64>> = HashMap::new();
|
||||
// Whether a name matches doesn't depend on order: it is a dictionary that left, or a copy of one
|
||||
// (what is added below is only ever such a copy). So what a shared list matches is found once.
|
||||
let left = dictionaries.clone();
|
||||
let mut matches: HashMap<Key, Rc<[u32]>> = HashMap::new();
|
||||
added.clear();
|
||||
let mut users = Vec::new();
|
||||
for (r, object) in doc.scan_objects() {
|
||||
let Object::Stream(s) = &*object else { continue };
|
||||
if !now.contains(&r.num) {
|
||||
continue;
|
||||
}
|
||||
// Only what matched has to leave, a copy found here included. Another dictionary this also
|
||||
// names was shared with nothing that left: what else uses it may keep it, whichever comes
|
||||
// first in the file.
|
||||
let mut uses = false;
|
||||
for (list, key) in run.names_of(doc, s)? {
|
||||
let matched = match key.and_then(|k| matches.get(&k)) {
|
||||
Some(hit) => hit.clone(),
|
||||
None => {
|
||||
let mut found = Vec::new();
|
||||
for &n in list.iter() {
|
||||
if matches_left(&mut run, doc, n, &left, &contents, &mut read, images)? {
|
||||
found.push(n);
|
||||
}
|
||||
}
|
||||
let found: Rc<[u32]> = found.into();
|
||||
if let Some(k) = key {
|
||||
run.spend(found.len())?;
|
||||
matches.insert(k, found.clone());
|
||||
}
|
||||
found
|
||||
}
|
||||
};
|
||||
if !matched.is_empty() {
|
||||
uses = true;
|
||||
if key.is_none_or(|k| added.insert(k)) {
|
||||
dictionaries.extend(matched.iter());
|
||||
}
|
||||
}
|
||||
}
|
||||
if uses {
|
||||
users.push(r);
|
||||
}
|
||||
}
|
||||
let (mut stored_again, mut stuck) = (0, 0);
|
||||
for &r in &users {
|
||||
let object = doc.get(r);
|
||||
let Object::Stream(s) = &*object else { continue };
|
||||
match without_dictionary(&mut run, doc, s, images)? {
|
||||
Some(stored) => {
|
||||
// In place, with both generations of a slot whose cross-reference row and object
|
||||
// header disagree, which cos reads either way (#1143): `set` would keep only `r`'s.
|
||||
doc.set_existing(r, Object::Stream(stored))?;
|
||||
stored_again += 1;
|
||||
}
|
||||
// A dictionary that names itself isn't something that uses one.
|
||||
None if dictionaries.contains(&r.num) => {}
|
||||
None => stuck += 1,
|
||||
}
|
||||
}
|
||||
let after = kept(doc)?;
|
||||
if dictionaries.iter().any(|n| after.contains(n)) {
|
||||
return Err(crate::RedactError::SharedJbig2Globals(stuck));
|
||||
}
|
||||
// What couldn't be stored has left with the dictionaries, a dictionary that names itself say:
|
||||
// it isn't counted.
|
||||
Ok(stored_again)
|
||||
}
|
||||
+24
-46
@@ -11,11 +11,12 @@ use pdfcraft_content::{Matrix, overlaps};
|
||||
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream};
|
||||
|
||||
/// Most pixels an image may have to be cleared in place (larger ones are removed), or to be stored
|
||||
/// again without shared `/JBIG2Globals` (see [`without_globals`]).
|
||||
/// again without shared `/JBIG2Globals` (see [`crate::globals::without_dictionary`]).
|
||||
pub(crate) const MAX_PIXELS: u64 = pdfcraft_cos::MAX_REDACTED_IMAGE_PIXELS;
|
||||
|
||||
fn components(doc: &Document, s: &Stream) -> Option<usize> {
|
||||
let cs = doc.resolve(s.dict.get(b"ColorSpace")?);
|
||||
/// The components of image `s`'s colour space, through any references (`None` when unknown).
|
||||
pub(crate) fn components(doc: &Document, s: &Stream) -> Option<usize> {
|
||||
let cs = crate::globals::followed(doc, s.dict.get(b"ColorSpace")?);
|
||||
let cs = &*cs;
|
||||
let name = match cs {
|
||||
Object::Name(n) => n.as_slice(),
|
||||
@@ -28,7 +29,7 @@ fn components(doc: &Document, s: &Stream) -> Option<usize> {
|
||||
b"DeviceCMYK" | b"CMYK" => 4,
|
||||
b"ICCBased" => {
|
||||
// /N lives in the ICC profile stream, and shall be 1, 3 or 4 (ISO 32000-2 §8.6.5.5).
|
||||
match &*doc.resolve(cs.as_array()?.get(1)?) {
|
||||
match &*crate::globals::followed(doc, cs.as_array()?.get(1)?) {
|
||||
Object::Stream(icc) => usize::try_from(icc.dict.int(b"N")?).ok().filter(|n| matches!(n, 1 | 3 | 4))?,
|
||||
_ => return None,
|
||||
}
|
||||
@@ -126,9 +127,9 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
|
||||
data.truncate(bytes);
|
||||
// Image masks: a sample of 0 paints unless /Decode is [1 0]; cleared samples must not paint.
|
||||
let clear_bit = if mask {
|
||||
// The array, and its first number, given by reference too.
|
||||
let decode = s.dict.get(b"Decode").map(|d| doc.resolve(d));
|
||||
let first = decode.as_deref().and_then(Object::as_array).and_then(|d| d.first()).map(|v| doc.resolve(v));
|
||||
// The array, and its first number, given by reference too, through any references.
|
||||
let decode = s.dict.get(b"Decode").map(|d| crate::globals::followed(doc, d));
|
||||
let first = decode.as_deref().and_then(Object::as_array).and_then(|d| d.first()).map(|v| crate::globals::followed(doc, v));
|
||||
let inverted = first.as_deref().and_then(Object::as_f64) == Some(1.0);
|
||||
!inverted
|
||||
} else {
|
||||
@@ -170,19 +171,31 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
|
||||
return Some(None);
|
||||
}
|
||||
let mut dict = s.dict.clone();
|
||||
dict.remove(b"Length");
|
||||
dict.remove(b"DecodeParms");
|
||||
dict.remove(b"Alternates");
|
||||
for key in [b"Length".as_slice(), b"DecodeParms", b"DP", b"Alternates"] {
|
||||
dict.remove(key);
|
||||
}
|
||||
if !data_elsewhere(doc, s) {
|
||||
// Parameters for data in another file mean nothing without `/F`, but may name a dictionary.
|
||||
dict.remove(b"FFilter");
|
||||
dict.remove(b"FDecodeParms");
|
||||
}
|
||||
Some(Some(smallest(dict, &data, w, h, ncomp == 1 && bpc == 1, is_jbig2)))
|
||||
}
|
||||
|
||||
/// Whether `s`'s data is in another file, which a reader may draw instead of what is here: a `/F`
|
||||
/// that isn't null, through any references (a null entry is as if absent, and a reference to no
|
||||
/// object is null, ISO 32000-2 §7.3.7 and §7.3.10). A ring of references counts as a file.
|
||||
pub(crate) fn data_elsewhere(doc: &Document, s: &Stream) -> bool {
|
||||
s.dict.get(b"F").is_some_and(|f| !matches!(&*crate::globals::followed(doc, f), Object::Null))
|
||||
}
|
||||
|
||||
/// The cleared samples `data` of a `w` × `h` image as a stream with `dict`: Flate, or, for one bit
|
||||
/// of one component (`bilevel`, a scan as a rule, which Flate stores several times larger), CCITT
|
||||
/// Group 4, or JBIG2 when the image was JBIG2 (`jbig2`; JBIG2 is PDF 1.4, which such a file already
|
||||
/// is), whichever is smallest as stored, dictionary included (a tiny image can lose by Group 4's
|
||||
/// parameters). Both decode to `data`, cleared before it is coded, so nothing of the original's
|
||||
/// code is carried over; Group 4 or JBIG2 that can't code the rows (too wide) is left out.
|
||||
fn smallest(dict: Dict, data: &[u8], w: usize, h: usize, bilevel: bool, jbig2: bool) -> Stream {
|
||||
pub(crate) fn smallest(dict: Dict, data: &[u8], w: usize, h: usize, bilevel: bool, jbig2: bool) -> Stream {
|
||||
let flate = Stream::flate(dict.clone(), data);
|
||||
let Some((columns, rows)) = u32::try_from(w).ok().zip(u32::try_from(h).ok()).filter(|_| bilevel) else { return flate };
|
||||
let g4 = pdfcraft_filters::encode_g4(data, columns, rows).ok().map(|coded| {
|
||||
@@ -209,38 +222,3 @@ fn stored_len(s: &Stream) -> usize {
|
||||
pdfcraft_cos::serialize(&Object::Dict(s.dict.clone()), &mut dict);
|
||||
dict.len().saturating_add(s.raw.len())
|
||||
}
|
||||
|
||||
/// JBIG2 image `s` stored again without its `/JBIG2Globals`: decoded through them, as it is drawn,
|
||||
/// and written as the same samples, Flate, Group 4 or JBIG2 coded alone, whichever is smallest
|
||||
/// (`smallest`), its other entries kept. `None` when it can't be decoded for certain (too large,
|
||||
/// past what is left of `budget`, data that doesn't decode, a JBIG2 page of another size than the
|
||||
/// image, or not a one-bit image of one component). Redaction does this to every image that
|
||||
/// shares a redacted image's globals, since their symbol dictionary holds the shapes of marks drawn
|
||||
/// under the area too.
|
||||
pub(crate) fn without_globals(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Option<Stream> {
|
||||
let (w, h) = (s.dict.int(b"Width")?, s.dict.int(b"Height")?);
|
||||
if w <= 0 || h <= 0 || (w as u64) * (h as u64) > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
let mask = matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true)));
|
||||
// JBIG2 codes one-bit images of one component (ISO 32000-2 §7.4.7).
|
||||
if !mask && (components(doc, s)?, s.dict.int(b"BitsPerComponent")?) != (1, 1) {
|
||||
return None;
|
||||
}
|
||||
let bytes = w.div_ceil(8).checked_mul(h)?;
|
||||
// As for `clear`: the filters before the JBIG2 decoder may give up to three bytes for each one
|
||||
// of the raster and a byte a row.
|
||||
let allowance = bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)?;
|
||||
let mut data = budget.decode(doc, s, allowance).ok()?;
|
||||
if data.len() < bytes {
|
||||
return None;
|
||||
}
|
||||
data.truncate(bytes);
|
||||
let mut dict = s.dict.clone();
|
||||
dict.remove(b"Length");
|
||||
// The globals are named in the parameters; the copy is coded alone, as `clear`'s are.
|
||||
dict.remove(b"DecodeParms");
|
||||
dict.remove(b"Alternates");
|
||||
Some(smallest(dict, &data, w, h, true, true))
|
||||
}
|
||||
+34
-78
@@ -13,7 +13,9 @@
|
||||
//! under a region the whole operation fails (callers keep the previous document: fail-closed).
|
||||
//! Finally, document metadata (`/Info` entries, XMP) that repeats the removed text is removed.
|
||||
//! Places it leaves as they are (bookmark titles, attachments, remaining comments, other XMP)
|
||||
//! are only counted when they still hold that text ([`Report::still_holding`]).
|
||||
//! are only counted when they still hold that text ([`Report::still_holding`]). Last, a shared
|
||||
//! JBIG2 symbol dictionary (`/JBIG2Globals`) that anything gone by then used leaves the file too:
|
||||
//! what still names it is stored again without it, or the redaction is refused.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
@@ -23,6 +25,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
|
||||
|
||||
pub mod codes;
|
||||
mod elsewhere;
|
||||
mod globals;
|
||||
mod identification;
|
||||
mod image;
|
||||
mod interp;
|
||||
@@ -96,12 +99,22 @@ pub enum RedactError {
|
||||
/// take what no mark covers with it, unreported.
|
||||
#[error("the images under these marks are more than one redaction can decode, so nothing was changed; redact fewer pages at a time")]
|
||||
ImagesPastAllowance,
|
||||
/// A JBIG2 image shares its `/JBIG2Globals` with a redacted one and can't be stored again
|
||||
/// without them, so the shapes they hold, those of marks under the area included, would stay.
|
||||
#[error(
|
||||
"{0} JBIG2 image(s) share their symbol data with a redacted image and can't be decoded to be stored without it, so that data, which can show what is under the marks, would stay in the file; nothing was redacted"
|
||||
)]
|
||||
/// `/JBIG2Globals` used by something removed would stay: something that names them can't be
|
||||
/// stored again without them, or something else refers to them. Either way the shapes they
|
||||
/// hold, those of marks under the area included, would stay. Counts what can't be stored without
|
||||
/// them: 0 when only something else keeps them.
|
||||
#[error("{}", shared_jbig2_text(*.0))]
|
||||
SharedJbig2Globals(usize),
|
||||
/// Finding what shares JBIG2 symbol data with what is being removed would read more of the file
|
||||
/// than one run allows (`globals::RUN_BUDGET`): its images and parameters refer to the same
|
||||
/// objects in more ways than are checked, or hold more than is decoded. Removing hidden
|
||||
/// information says so too when the dictionaries it reads to know copies, or the images it
|
||||
/// stores again, would decode more than its image allowance, which no choice of pages makes
|
||||
/// smaller.
|
||||
#[error(
|
||||
"finding and storing again what shares JBIG2 symbol data with something being removed would read more of this file than one run allows, so it can't be shown that the data leaves with it; this was refused"
|
||||
)]
|
||||
SharedJbig2Budget,
|
||||
#[error(transparent)]
|
||||
Edit(#[from] pdfcraft_edit::EditError),
|
||||
#[error(transparent)]
|
||||
@@ -110,6 +123,15 @@ pub enum RedactError {
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
}
|
||||
|
||||
/// [`RedactError::SharedJbig2Globals`]'s text, which says what keeps the data.
|
||||
fn shared_jbig2_text(stuck: usize) -> String {
|
||||
let why = match stuck {
|
||||
0 => "something else in the file refers to JBIG2 symbol data shared with something being removed".to_string(),
|
||||
n => format!("{n} item(s) share JBIG2 symbol data with something being removed and can't be stored without it"),
|
||||
};
|
||||
format!("{why}, so that data, which can show what was removed, would stay in the file; this was refused")
|
||||
}
|
||||
|
||||
/// A redaction mark on a page.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Mark {
|
||||
@@ -167,7 +189,7 @@ pub struct Report {
|
||||
pub images_removed: usize,
|
||||
pub images_cleared: usize,
|
||||
/// JBIG2 images, anywhere in the document, stored again (as a cleared copy is) without the
|
||||
/// `/JBIG2Globals` they shared with a redacted image, so those globals leave the file.
|
||||
/// `/JBIG2Globals` they shared with something this removed, so those globals leave the file.
|
||||
pub images_restored: usize,
|
||||
pub paths_removed: usize,
|
||||
pub paths_clipped: usize,
|
||||
@@ -484,6 +506,7 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co
|
||||
pdfcraft_fonts::undrawable_message(&format!("the overlay text of a redaction mark on page {}", m.page + 1), c)
|
||||
)));
|
||||
}
|
||||
let kept_before = globals::kept(doc)?;
|
||||
let mut report = Report { marks: chosen.len(), ..Report::default() };
|
||||
// `marks` visits pages in order. Keep slices of that order for appearance generation,
|
||||
// content rewriting and verification instead of scanning all marks for every page.
|
||||
@@ -645,7 +668,6 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co
|
||||
interp::unlink_doomed(doc, &doomed_xobjects, &retired_properties)?;
|
||||
// Likewise a pruned pattern: a /Pages node above the page can still name its cell.
|
||||
interp::unlink_pruned_patterns(doc, &pruned_patterns)?;
|
||||
report.images_restored = restore_shared_jbig2(doc, &doomed_xobjects, &mut images)?;
|
||||
|
||||
// 5. Verify: nothing readable may remain under a region.
|
||||
let mut residue = 0;
|
||||
@@ -694,6 +716,10 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co
|
||||
let scrubbed = metadata::scrub(doc, &removed_text, !removed_cut)?;
|
||||
report.metadata = scrubbed.removed;
|
||||
report.identification = scrubbed.identification;
|
||||
// Last of what leaves: an image that left with any of the above, the metadata removed just
|
||||
// now included, is a user of a dictionary too. Storing images again changes no page's content
|
||||
// or pattern cells, so the checks above hold for what follows.
|
||||
report.images_restored = globals::unshare(doc, &kept_before, &mut images)?;
|
||||
// What it doesn't scrub is counted, never changed.
|
||||
report.still_holding = elsewhere::find(doc, &removed_text, !removed_cut);
|
||||
// The previous revision still holds the removed content: the next save must rewrite, and the
|
||||
@@ -702,76 +728,6 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Most XObjects followed down from the removed and replaced ones to find their JBIG2 images.
|
||||
const MAX_JBIG2_WALK: usize = 100_000;
|
||||
|
||||
/// The `/JBIG2Globals` of a redacted JBIG2 image hold the symbol dictionary of every image that
|
||||
/// shares them (`jbig2enc -s` and most scanners give one to all the pages of a scan): the bitmaps
|
||||
/// of marks drawn only under the area too. The image itself is replaced or removed, but the globals
|
||||
/// would stay for as long as another image uses them. So every image that uses the globals of a
|
||||
/// removed or replaced image (`doomed`), or of one drawn in a removed or replaced form, is decoded
|
||||
/// and stored again in place without them, as a cleared copy is, and a full save then leaves them
|
||||
/// out. An image that can't be stored again refuses the redaction (nothing changes, as `apply`
|
||||
/// works on a copy), but for a removed or replaced one, which is either unlinked or still drawn
|
||||
/// whole on another page. They are decoded within what is left of the run's allowance (`images`);
|
||||
/// past it the redaction is refused as one clearing too many images is. Returns how many images
|
||||
/// that weren't removed or replaced were stored again.
|
||||
fn restore_shared_jbig2(doc: &mut Document, doomed: &[ObjRef], images: &mut pdfcraft_cos::ImageBudget) -> Result<usize, RedactError> {
|
||||
let mut globals = HashSet::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut stack: Vec<ObjRef> = doomed.to_vec();
|
||||
while let Some(r) = stack.pop() {
|
||||
if !seen.insert(r) || seen.len() > MAX_JBIG2_WALK {
|
||||
continue;
|
||||
}
|
||||
let object = doc.get(r);
|
||||
let Object::Stream(s) = &*object else { continue };
|
||||
if let Some(g) = doc.jbig2_globals_ref(s) {
|
||||
globals.insert(g);
|
||||
}
|
||||
// A form's images, and its forms' (an image's /SMask is a stream of its own, found below).
|
||||
let resources = s.dict.get(b"Resources").map(|r| doc.resolve(r));
|
||||
let xobjects = resources.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"XObject")).map(|x| doc.resolve(x));
|
||||
if let Some(x) = xobjects.as_deref().and_then(Object::as_dict) {
|
||||
stack.extend(x.iter().filter_map(|(_, v)| v.as_ref()));
|
||||
}
|
||||
if let Some(m) = s.dict.get(b"SMask").and_then(Object::as_ref) {
|
||||
stack.push(m);
|
||||
}
|
||||
}
|
||||
if globals.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let sharing: Vec<ObjRef> = doc
|
||||
.scan_objects()
|
||||
.filter(|(_, o)| matches!(&**o, Object::Stream(s) if doc.jbig2_globals_ref(s).is_some_and(|g| globals.contains(&g))))
|
||||
.map(|(r, _)| r)
|
||||
.collect();
|
||||
let doomed: HashSet<ObjRef> = doomed.iter().copied().collect();
|
||||
let (mut restored, mut stuck, mut past_allowance) = (0, 0, false);
|
||||
for r in sharing {
|
||||
let object = doc.get(r);
|
||||
let Object::Stream(s) = &*object else { continue };
|
||||
let refused = images.refused();
|
||||
match image::without_globals(doc, s, images) {
|
||||
Some(new) => {
|
||||
doc.set(r, Object::Stream(new));
|
||||
restored += usize::from(!doomed.contains(&r));
|
||||
}
|
||||
None if doomed.contains(&r) => {}
|
||||
None if images.refused() > refused => past_allowance = true,
|
||||
None => stuck += 1,
|
||||
}
|
||||
}
|
||||
if past_allowance {
|
||||
return Err(RedactError::ImagesPastAllowance);
|
||||
}
|
||||
if stuck > 0 {
|
||||
return Err(RedactError::SharedJbig2Globals(stuck));
|
||||
}
|
||||
Ok(restored)
|
||||
}
|
||||
|
||||
/// What applying the marks collects across pages.
|
||||
struct Applying {
|
||||
report: Report,
|
||||
|
||||
@@ -814,6 +814,20 @@ pub fn scan_only(doc: &Document, which: &[Hidden]) -> Vec<(Hidden, usize)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`crate::globals::unshare`] for what left without marks: past `images` it is refused as finding
|
||||
/// what shares the dictionaries is, since the error redaction gives (redact fewer pages at a time)
|
||||
/// means nothing when no page was chosen.
|
||||
pub(crate) fn unshare_unmarked(
|
||||
doc: &mut Document,
|
||||
before: &std::collections::HashSet<u32>,
|
||||
images: &mut pdfcraft_cos::ImageBudget,
|
||||
) -> Result<usize, RedactError> {
|
||||
crate::globals::unshare(doc, before, images).map_err(|e| match e {
|
||||
RedactError::ImagesPastAllowance => RedactError::SharedJbig2Budget,
|
||||
e => e,
|
||||
})
|
||||
}
|
||||
|
||||
/// Take from the outline of catalog `cat` every action that does more than go to a place in this
|
||||
/// document (see [`outline_without_actions`]): bookmarks that stay keep where they go, not what
|
||||
/// they run. An outline too large or nested too deep to follow is refused, not left half cleaned:
|
||||
@@ -923,6 +937,9 @@ pub(crate) fn remove_counted(
|
||||
let count = |h: Hidden| counts.iter().find(|c| c.0 == h).map_or(0, |c| c.1);
|
||||
let (root, _) = catalog(doc).ok_or(RedactError::NothingToApply)?;
|
||||
let mut done = Vec::new();
|
||||
// What a full save keeps before anything is removed: an image that leaves, by whatever route
|
||||
// below, takes a shared JBIG2 dictionary with it (`unshare_unmarked`, last).
|
||||
let kept_before = crate::globals::kept(doc)?;
|
||||
let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm"));
|
||||
let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform);
|
||||
// Pages' annotation lists change only for these; otherwise every page is left as it is.
|
||||
@@ -1041,6 +1058,9 @@ pub(crate) fn remove_counted(
|
||||
if done.is_empty() && unlinked == 0 {
|
||||
return Err(RedactError::NothingToApply);
|
||||
}
|
||||
// Images stored again without a dictionary, and the dictionaries read to know copies, are
|
||||
// decoded within an allowance of their own.
|
||||
unshare_unmarked(doc, &kept_before, &mut pdfcraft_cos::ImageBudget::new(doc))?;
|
||||
// The rewrite drops the revisions that held the removed content, so it is a different file from
|
||||
// its source: it gets a new identifier, whoever asked for the removal (the remove-hidden edit
|
||||
// and the optimizer's discard option call this function directly, not through [`sanitize`]).
|
||||
|
||||
+1123
-6
File diff suppressed because it is too large.
Load diff
@@ -5943,8 +5943,8 @@ milestone = "M8"
|
||||
status = "shipped"
|
||||
commands = ["redact.apply"]
|
||||
tools = ["redact_apply"]
|
||||
evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres", "crates/redact/src/tests.rs::a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared", "crates/redact/src/tests.rs::an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared", "crates/redact/src/tests.rs::a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole", "crates/redact/src/tests.rs::a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals", "crates/redact/src/tests.rs::a_cleared_black_and_white_scan_is_stored_as_group_4", "crates/redact/src/tests.rs::globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file"]
|
||||
notes = "intersected pixel footprints are cleared conservatively in a copy, CCITT fax and JBIG2 images (scans stored as Group 4 or JBIG2, with their /JBIG2Globals) included; DCT/JPX images, Group 3 on bytes (which the bootstrap renderer may draw as other rows than the decoder reads), and a JBIG2 image whose page isn't its size, removed whole. The copy is Flate, or for a one-bit image of one component Group 4, or JBIG2 when it was JBIG2, whichever is smallest as stored, and refers to no globals; every other image using a redacted image's globals is stored again without them (Flate, Group 4 or JBIG2, whichever is smallest), so they leave the file (one that can't be decoded refuses the redaction)"
|
||||
evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::a_redacted_scans_shared_jbig2_dictionary_leaves_the_file", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres", "crates/redact/src/tests.rs::a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared", "crates/redact/src/tests.rs::an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared", "crates/redact/src/tests.rs::a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole", "crates/redact/src/tests.rs::a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals", "crates/redact/src/tests.rs::a_cleared_black_and_white_scan_is_stored_as_group_4", "crates/redact/src/tests.rs::globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file"]
|
||||
notes = "intersected pixel footprints are cleared conservatively in a copy, CCITT fax and JBIG2 images (scans stored as Group 4 or JBIG2, with their /JBIG2Globals) included; DCT/JPX images, Group 3 on bytes (which the bootstrap renderer may draw as other rows than the decoder reads), and a JBIG2 image whose page isn't its size, removed whole. The copy is Flate, or for a one-bit image of one component Group 4, or JBIG2 when it was JBIG2, whichever is smallest as stored, and refers to no globals; whatever left the file takes its globals with it: what else names them, or an exact copy, is stored again without them (Flate, Group 4 or JBIG2, whichever is smallest), and apply refuses when one can't be. Not found: a dictionary holding the same shapes in other bytes. An image that hasn't left the file keeps its globals with its pixels: one another page draws, or one a pattern, soft mask or structure element refers to"
|
||||
|
||||
[[feature]]
|
||||
id = "protect.redact-vectors"
|
||||
|
||||
Reference in new issue
Block a user