Merge pull request #6289 from Piohrun/fix/partial-content-rewrite

M2.6: never rewrite a content stream from the part before its damage
This commit is contained in:
FredApps
2026-11-08 13:10:56 +01:00
committed by GitHub
38 changed files with 1797 additions and 276 deletions
Generated
-1
View File
@@ -4265,7 +4265,6 @@ version = "0.18.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-filters",
"pdfcraft-fonts",
"pdfcraft-model",
"thiserror 2.0.21",
-1
View File
@@ -14,6 +14,5 @@ workspace = true
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-content = { workspace = true }
pdfcraft-filters = { workspace = true }
pdfcraft-fonts = { workspace = true }
thiserror.workspace = true
+74 -20
View File
@@ -25,6 +25,8 @@ pub enum AltError {
NotAFigure(u32),
#[error("the page has more content than can be edited")]
TooLarge,
#[error("the figure's content may be in a damaged or unreadable content stream, or after the damage, so nothing was changed")]
Damaged,
#[error("{0}")]
Cos(#[from] pdfcraft_cos::CosError),
}
@@ -52,6 +54,8 @@ pub enum ArtifactError {
ParentTree(String),
#[error("the page has more content than can be edited")]
TooLarge,
#[error("the tag's marked content may be in a damaged or unreadable content stream, or after the damage, so nothing was changed")]
Damaged,
#[error(transparent)]
Cos(#[from] pdfcraft_cos::CosError),
}
@@ -372,7 +376,19 @@ fn content_entries(doc: &Document, page: &Dict) -> Vec<Object> {
}
}
type DecodedStream = (Object, Option<(Stream, Vec<u8>)>);
/// How much of a content stream decoded.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Read {
Whole,
/// Damaged partway: the data is what decoded before the damage. What follows it is unknown,
/// the syntax the later streams start in too.
Damaged,
/// Not at all (a filter this reader lacks, say): the data is empty.
Unread,
}
/// A `/Contents` entry and, for a stream, the stream, its decoded data and how much of it decoded.
type DecodedStream = (Object, Option<(Stream, Vec<u8>, Read)>);
/// Decode the page's content streams within the page decode budget. `None` when a stream is
/// rejected because the budget is exhausted.
@@ -383,14 +399,14 @@ fn decode_page_streams(doc: &Document, page: &Dict) -> Option<Vec<DecodedStream>
for entry in entries {
let decoded = match &*doc.resolve(&entry) {
Object::Stream(s) => {
let data = match budget.decode(s) {
Ok(data) => data,
let (data, read) = match budget.decode_with_damage(doc, s, usize::MAX) {
Ok((data, damaged)) => (data, if damaged { Read::Damaged } else { Read::Whole }),
// Content past the budget may hold more of the tag: editing the rest
// would leave that part marked as a tag the tree no longer has.
Err(_) if budget.exhausted() => return None,
Err(_) => Vec::new(),
Err(_) => (Vec::new(), Read::Unread),
};
Some((s.clone(), data))
Some((s.clone(), data, read))
}
_ => None,
};
@@ -403,6 +419,18 @@ fn marked_id(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &Hash
(op.op == b"BDC").then(|| bdc_mcid(doc, props, op)).flatten().filter(|n| ids.contains(n))
}
/// What [`artifact_marked_content`] did.
#[derive(Debug, PartialEq)]
enum Marked {
/// The streams holding the content were rewritten.
Done,
/// None of the content is on the page, or with `require_all` not all of it: nothing changed.
NotFound,
/// Some of it is, or may be, in a stream damaged partway or one that doesn't decode, neither of
/// which can be rewritten without losing what wasn't read, or after the damage: nothing changed.
Damaged,
}
/// Turn the tag's marked-content blocks into artifacts, rewriting only the streams that
/// contain them. Other content streams, and unknown entries on a rewritten stream, stay.
/// With `require_all`, every id in `ids` must appear on the page before any stream is rewritten.
@@ -412,25 +440,45 @@ fn artifact_marked_content(
streams: Vec<DecodedStream>,
ids: &HashSet<i64>,
require_all: bool,
) -> Result<bool, pdfcraft_cos::CosError> {
) -> Result<Marked, pdfcraft_cos::CosError> {
let props = page_properties(doc, &page.dict);
let decoded: Vec<&[u8]> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, bytes)| bytes.as_slice())).collect();
// The pieces are the streams, in order.
let decoded: Vec<&[u8]> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, bytes, _)| bytes.as_slice())).collect();
let read: Vec<Read> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, _, read)| *read)).collect();
let damage = read.iter().position(|r| *r == Read::Damaged);
let pieces = pdfcraft_content::Pieces::join(&decoded);
let mut found = HashSet::new();
let mut edits = Vec::new();
for op in pieces.parse().ops {
if let Some(id) = marked_id(doc, &props, &op, ids) {
// What follows the damage is unknown: a string or comment the damage left open runs
// on into the next stream, so what reads as an operator after it may not be one. An
// operator whose operands start before a stream not read whole and whose keyword comes
// after it takes them from around what wasn't read: that stream may end them itself.
let (first, last) = pieces.pieces_of(&op);
if damage.is_some_and(|d| last > d) || read.get(first..last).is_some_and(|r| r.iter().any(|r| *r != Read::Whole)) {
return Ok(Marked::Damaged);
}
found.insert(id);
edits.push((op.span, b"/Artifact BMC".to_vec()));
}
}
if if require_all { found != *ids } else { found.is_empty() } {
return Ok(false);
// Content not found may be past the damage, or in a stream that doesn't decode: whether it
// is on the page isn't known.
if found != *ids && read.iter().any(|r| *r != Read::Whole) {
return Ok(Marked::Damaged);
}
let mut spliced = pieces.splice(edits).into_iter();
if if require_all { found != *ids } else { found.is_empty() } {
return Ok(Marked::NotFound);
}
let spliced = pieces.splice(edits);
if read.iter().zip(&decoded).zip(&spliced).any(|((read, old), new)| *read != Read::Whole && *old != new.as_slice()) {
return Ok(Marked::Damaged);
}
let mut spliced = spliced.into_iter();
let mut next = Vec::with_capacity(streams.len());
for (entry, stream) in streams {
let Some((stream, old)) = stream else {
let Some((stream, old, _)) = stream else {
next.push(entry);
continue;
};
@@ -456,7 +504,7 @@ fn artifact_marked_content(
_ => Object::Array(next),
};
doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?;
Ok(true)
Ok(Marked::Done)
}
/// Decorative figure: its marked content on the page becomes an artifact, and the element
@@ -468,11 +516,15 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError>
let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page));
if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) {
let streams = decode_page_streams(doc, &page.dict).ok_or(AltError::TooLarge)?;
// The parent tree no longer points those ids at the figure.
if artifact_marked_content(doc, page, streams, &ids, false)?
&& let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int)
{
clear_parent_tree(doc, key, obj)?;
match artifact_marked_content(doc, page, streams, &ids, false)? {
// The parent tree no longer points those ids at the figure.
Marked::Done => {
if let Some(key) = page.dict.get(b"StructParents").and_then(|o| doc.resolve(o).as_int()) {
clear_parent_tree(doc, key, obj)?;
}
}
Marked::NotFound => {}
Marked::Damaged => return Err(AltError::Damaged),
}
}
// Out of its parent's kids.
@@ -513,10 +565,12 @@ pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), Artifact
let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?;
let replacement = replacement_kids(doc, parent, obj)?;
let streams = decode_page_streams(doc, &page.dict).ok_or(ArtifactError::TooLarge)?;
if !artifact_marked_content(doc, page, streams, &ids, true)? {
return Err(ArtifactError::ContentNotFound);
match artifact_marked_content(doc, page, streams, &ids, true)? {
Marked::Done => {}
Marked::NotFound => return Err(ArtifactError::ContentNotFound),
Marked::Damaged => return Err(ArtifactError::Damaged),
}
if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) {
if let Some(key) = page.dict.get(b"StructParents").and_then(|o| doc.resolve(o).as_int()) {
clear_parent_tree(doc, key, obj).map_err(|e| ArtifactError::ParentTree(e.to_string()))?;
}
doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?;
+17 -11
View File
@@ -4,7 +4,7 @@
//! which can then be retagged with the existing tag-property tool after human review.
use pdfcraft_content::{Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
const MAX_PAGES: usize = 1_000;
const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024;
@@ -57,18 +57,24 @@ fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> R
let Object::Stream(stream) = &*value else { return Err(unsupported(index, "contents include a non-stream object")) };
let remaining = MAX_PAGE_BYTES.saturating_sub(all.len().saturating_add(1));
if remaining == 0 {
return Err(unsupported(index, "decoded content exceeds 16 MiB"));
return Err(unsupported(index, "decoding the content takes more than 16 MiB"));
}
let chain = stream.filters();
let decoded = if chain.is_empty() {
if stream.raw.len() > remaining {
return Err(unsupported(index, "decoded content exceeds 16 MiB"));
// Strictly, and with `/DecodeParms` given by reference: the content is written back from it.
let mut budget = DecodeBudget::new(remaining, 0);
let decoded = budget.decode_strict(doc, stream).map_err(|e| {
// The allowance counts what every filter stage decodes, as a page's does.
if budget.remaining() == 0 {
unsupported(index, "decoding the content takes more than 16 MiB")
} else {
// The decoder's own reason: `CosError::Filter`'s text would say again that the data
// couldn't be decoded.
let why = match e {
pdfcraft_cos::CosError::Filter(why) => why,
other => other.to_string(),
};
unsupported(index, format!("content cannot be decoded strictly: {why}"))
}
stream.raw.to_vec()
} else {
pdfcraft_filters::decode(&chain, &stream.raw, remaining)
.map_err(|e| unsupported(index, format!("content cannot be decoded strictly: {e}")))?
};
})?;
all.extend_from_slice(&decoded);
all.push(b' ');
}
+241
View File
@@ -79,6 +79,68 @@ fn autotag_text_writes_marked_content_and_parent_tree_across_pages() {
assert_eq!(status(&check(&reopened, &Options::default()), Rule::TaggedContent), Status::Passed);
}
/// Page content behind a predictor whose `/DecodeParms` are given by reference is read through
/// them: autotagging writes the text back as it was, marked, not with the predictor's tag bytes in
/// it (a `\0` every eight bytes, which split `24` into `2 4`).
#[test]
fn autotag_text_reads_content_with_parameters_given_by_reference() {
use pdfcraft_cos::{Dict, ObjRef};
let mut content = b"BT /F1 24 Tf 72 700 Td (Hello World) Tj ET".to_vec();
content.resize(content.len().div_ceil(8) * 8, b' ');
// PNG rows of eight bytes, each tagged 0 (no prediction).
let rows: Vec<u8> = content.chunks(8).flat_map(|row| std::iter::once(0).chain(row.iter().copied())).collect();
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream(""),
FONT.into(),
],
"",
);
let parms: Dict = [(b"Predictor".to_vec(), Object::Int(12)), (b"Columns".to_vec(), Object::Int(8))].into_iter().collect();
let parms = doc.add(Object::Dict(parms));
let mut predicted = pdfcraft_cos::Stream::flate(Dict::new(), &rows);
predicted.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms));
doc.set(ObjRef::new(4, 0), predicted);
assert_eq!(autotag_text(&mut doc).unwrap(), AutoTagResult { pages: 1, paragraphs: 1 });
let page = &pdfcraft_model::pages(&doc)[0];
let written = doc.resolve(page.dict.get(b"Contents").unwrap());
let Object::Stream(written) = &*written else { panic!("page content") };
let text = String::from_utf8_lossy(&written.decoded_strict().unwrap()).into_owned();
assert!(text.contains("/F1 24 Tf") && text.contains("(Hello World) Tj"), "{text}");
}
/// Autotagging refuses a page whose content it can't read whole, and says why: content past
/// 16 MiB, or content that doesn't decode strictly, with the decoder's reason right after.
#[test]
fn autotag_text_says_why_it_cannot_read_a_page() {
use pdfcraft_cos::{Dict, ObjRef, Stream};
let reason = |content: Stream| {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream(""),
FONT.into(),
],
"",
);
doc.set(ObjRef::new(4, 0), content);
match autotag_text(&mut doc) {
Err(AutoTagError::Unsupported { reason, .. }) => reason,
other => panic!("{other:?}"),
}
};
assert_eq!(reason(Stream::from_raw(Dict::new(), vec![b' '; 17 << 20])), "decoding the content takes more than 16 MiB");
let mut corrupt = Stream::from_raw(Dict::new(), b"not zlib data".to_vec());
corrupt.dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let why = reason(corrupt);
assert!(why.starts_with("content cannot be decoded strictly: FlateDecode: "), "{why}");
}
#[test]
fn autotag_text_refuses_paint_and_does_not_modify_the_document() {
let mut doc = pdf(
@@ -1087,6 +1149,185 @@ fn mark_tag_artifact_validates_parent_and_all_mcids_before_mutating_document() {
assert_eq!(*absent.get(ObjRef::new(9, 0)), before_pt);
}
/// zlib data that reads as `readable` and is then damaged, with more of the stream after the
/// damage: a stored block, then a block of the reserved type (RFC 1951 §3.2.3), then `rest`.
fn damaged_flate(readable: &[u8], rest: &[u8]) -> pdfcraft_cos::Stream {
let len = u16::try_from(readable.len()).unwrap();
let mut raw = vec![0x78, 0x01, 0x00];
raw.extend_from_slice(&len.to_le_bytes());
raw.extend_from_slice(&(!len).to_le_bytes());
raw.extend_from_slice(readable);
raw.push(0x07);
raw.extend_from_slice(rest);
let mut dict = pdfcraft_cos::Dict::new();
dict.set(b"Filter".to_vec(), pdfcraft_cos::Object::name("FlateDecode"));
pdfcraft_cos::Stream::from_raw(dict, raw)
}
/// A tag's content in a stream damaged partway stays tagged: rewriting the stream from what
/// decoded would lose what follows the damage. Nothing changes, the structure tree and the stream
/// included. Content in a whole stream before a damaged one is marked, and the damaged one kept
/// as stored.
#[test]
fn a_tags_content_in_a_stream_damaged_partway_is_not_rewritten() {
use pdfcraft_cos::{ObjRef, Object};
let content = b"/Figure << /MCID 0 >> BDC q 40 0 0 20 10 150 cm /Im Do Q EMC /Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC\n";
let damaged = damaged_flate(content, b"0 0 1 rg 0 0 10 10 re f");
let mut doc = figures_doc();
doc.set(ObjRef::new(4, 0), damaged.clone());
let contents = |doc: &Document| doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned();
let state = |doc: &Document| (contents(doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)), doc.get(ObjRef::new(4, 0)));
let before = state(&doc);
assert_eq!(figures(&doc).len(), 2, "read up to the damage");
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged));
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged));
assert_eq!(state(&doc), before);
let mut doc = figures_doc();
let other = doc.add(damaged_flate(b"0 g\n", b"0 0 1 rg 0 0 10 10 re f"));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(other)])))
.unwrap();
mark_tag_artifact(&mut doc, ObjRef::new(7, 0)).unwrap();
let Some(Object::Array(list)) = contents(&doc) else { panic!("contents {:?}", contents(&doc)) };
assert_ne!(list.first(), Some(&Object::Ref(ObjRef::new(4, 0))), "the tag's stream is rewritten");
assert_eq!(list.get(1), Some(&Object::Ref(other)), "kept as stored");
}
/// What follows the damage is unknown, the syntax the next stream starts in too, so a tag's
/// content in a stream after a damaged one stays tagged. Here a comment the damage leaves open
/// runs on into the next stream, up to its first end of line, and a string starting there reads
/// as operators, among them what looks like the figure's content: rewritten, it would change the
/// string. Nothing changes, the structure tree included.
#[test]
fn a_tags_content_after_a_damaged_stream_is_not_rewritten() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let in_a_string = b"(a note\n/Figure << /MCID 0 >> BDC 0 g EMC\n) pop /Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC";
for (damaged, after) in [(b"0 g\n".as_slice(), None), (b"0 g % a comment".as_slice(), Some(in_a_string.as_slice()))] {
let mut doc = figures_doc();
if let Some(after) = after {
doc.set(ObjRef::new(4, 0), Stream::from_raw(Dict::new(), after.to_vec()));
}
let first = doc.add(damaged_flate(damaged, b"\n0 0 1 rg 0 0 10 10 re f"));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(ObjRef::new(4, 0))])))
.unwrap();
let state = |doc: &Document| {
(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)))
};
let before = state(&doc);
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged), "{damaged:?}");
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged), "{damaged:?}");
assert_eq!(state(&doc), before, "{damaged:?}");
}
}
/// A page may split its content between an operator's operands and its keyword. When the operands
/// of the figure's `BDC` end the stream before one damaged from its start or one that doesn't
/// decode, the `BDC` the page reads is in what wasn't read: joined without it, those operands and
/// the next tag's read as one `BDC`, which reaches into the stream after it. Neither marking
/// changes anything, the structure tree included.
#[test]
fn a_tags_operands_before_a_stream_not_read_whole_are_not_rewritten() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let rest = b" BDC q 40 0 0 20 10 150 cm /Im Do Q EMC";
for damaged in [true, false] {
let mut doc = figures_doc();
doc.set(ObjRef::new(4, 0), Stream::from_raw(Dict::new(), b"/Figure << /MCID 0 >>".to_vec()));
let hidden = if damaged {
damaged_flate(b"", rest)
} else {
let filter = doc.add(Object::name("FlateDecode"));
let mut s = Stream::flate(Dict::new(), rest);
s.dict.set(b"Filter".to_vec(), Object::Ref(filter));
s
};
let hidden = doc.add(hidden);
let after = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"/Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC".to_vec())));
let listed = Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(hidden), Object::Ref(after)]);
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), listed)).unwrap();
let state = |doc: &Document| {
(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)))
};
let before = state(&doc);
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged), "damaged: {damaged}");
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged), "damaged: {damaged}");
assert_eq!(state(&doc), before, "damaged: {damaged}");
}
}
/// A tag's content not found on the page may be in a stream that doesn't decode: it isn't known
/// to be off the page, so neither marking changes anything, the structure tree included.
#[test]
fn a_tags_content_not_found_beside_a_stream_that_does_not_decode_is_not_taken_for_absent() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let mut doc = figures_doc();
doc.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Int(2))).unwrap();
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
let other = doc.add(Object::Stream(Stream::from_raw(dict, b"/Figure << /MCID 2 >> BDC 0 g EMC".to_vec())));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(other), Object::Ref(ObjRef::new(4, 0))])))
.unwrap();
let state = |doc: &Document| {
(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)))
};
let before = state(&doc);
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged));
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged));
assert_eq!(state(&doc), before);
}
/// A tag's content not found before the damage in a damaged stream may be past it: it isn't known
/// to be off the page, so neither marking changes anything.
#[test]
fn a_tags_content_past_the_damage_is_not_taken_for_absent() {
use pdfcraft_cos::ObjRef;
let damaged =
damaged_flate(b"/Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC\n", b"/Figure << /MCID 0 >> BDC q 40 0 0 20 10 150 cm /Im Do Q EMC");
let mut doc = figures_doc();
doc.set(ObjRef::new(4, 0), damaged);
let contents = |doc: &Document| doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned();
let before = (contents(&doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)));
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged));
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged));
assert_eq!((contents(&doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0))), before);
}
/// A page's `/StructParents` given by reference is read through it: the parent tree no longer
/// points the marked content at the tag.
#[test]
fn struct_parents_given_by_reference_are_read() {
use pdfcraft_cos::{ObjRef, Object};
let by_reference = || {
let mut doc = figures_doc();
let key = doc.add(Object::Int(0));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"StructParents".to_vec(), Object::Ref(key))).unwrap();
doc
};
let nums = |doc: &Document| doc.get(ObjRef::new(9, 0)).as_dict().and_then(|d| d.get(b"Nums").cloned());
let mut doc = by_reference();
mark_decorative(&mut doc, ObjRef::new(7, 0)).unwrap();
assert_eq!(nums(&doc), Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Null, Object::Ref(ObjRef::new(8, 0))])])));
let mut doc = by_reference();
mark_tag_artifact(&mut doc, ObjRef::new(8, 0)).unwrap();
assert_eq!(nums(&doc), Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Null])])));
}
/// A content stream that doesn't decode at all is kept as stored, and a tag in the page's other
/// streams is still marked: only running past the decode budget refuses.
#[test]
fn a_tag_beside_a_stream_that_does_not_decode_is_marked() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let mut doc = figures_doc();
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
let other = doc.add(Object::Stream(Stream::from_raw(dict, b"0 g".to_vec())));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(other), Object::Ref(ObjRef::new(4, 0))])))
.unwrap();
mark_tag_artifact(&mut doc, ObjRef::new(7, 0)).unwrap();
let contents = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned();
let Some(Object::Array(list)) = contents else { panic!("contents {contents:?}") };
assert_eq!(list.first(), Some(&Object::Ref(other)), "kept as stored");
}
fn ref_(n: u32) -> Option<pdfcraft_cos::ObjRef> {
Some(pdfcraft_cos::ObjRef::new(n, 0))
}
+2 -2
View File
@@ -283,7 +283,7 @@ impl<'a> Walker<'a> {
return Err(AuditError::TooManyForms);
}
// A form stream that cannot be decoded fails the audit: it might hide a cover.
let data = self.budget.decode_strict(stream)?;
let data = self.budget.decode_strict(self.doc, stream)?;
// The XObject matrix maps form space into the user space active at `Do`.
let mut sub_gs = gs.clone();
sub_gs.ctm = matrix.then(&gs.ctm);
@@ -326,7 +326,7 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, Audi
let mut streams = Vec::with_capacity(contents.len());
for c in &contents {
if let Object::Stream(s) = &*doc.resolve(c) {
streams.push(budget.decode_strict(s)?);
streams.push(budget.decode_strict(doc, s)?);
}
}
let pieces = Pieces::join(&streams);
+1 -1
View File
@@ -1290,7 +1290,7 @@ pub fn tools() -> Vec<ToolDef> {
"property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language", "summary", "scope"] },
"value": { "type": "string" },
}), &["doc", "object", "generation", "property"])),
t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, or content not found on its page are rejected. Undoable.")
t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, content not found on its page, or content that a damaged or undecodable content stream may hide or change are rejected. Undoable.")
.with(schema(json!({
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
+139 -23
View File
@@ -38,9 +38,9 @@ const JPEG_WORK: u64 = 2;
/// and images can share one, so reading it again for each has to count.
const JPEG_READ_WORK: u64 = 4;
/// A stream decode within a byte bound, with the bytes its filter stages decoded: the data or
/// the error, each with that count.
type Decoder = fn(&Stream, usize) -> Result<(Vec<u8>, usize), (FilterError, usize)>;
/// A stream decode within a byte bound, with the bytes its filter stages decoded: the data and
/// whether it stopped at damage, or the error, each with that count.
type Decoder = fn(&Stream, usize) -> Result<(Vec<u8>, bool, usize), (FilterError, usize)>;
/// What one operation over a document's images may still spend decoding them: exporting them,
/// optimizing them, and storing LZW streams with Flate as it does, clearing them for redaction.
@@ -263,45 +263,57 @@ impl DecodeBudget {
}
/// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without
/// decoding, once the allowance is spent.
/// decoding, once the allowance is spent. A stream damaged partway gives what decoded before
/// the damage, so code that writes the stream back reads it with
/// [`DecodeBudget::decode_with_damage`] instead.
pub fn decode(&mut self, stream: &Stream) -> Result<Vec<u8>, CosError> {
self.charge(stream, usize::MAX, Stream::decoded_within_counted)
self.charge(stream, usize::MAX, Stream::decoded_within_counted).map(|(data, _)| data)
}
/// Like [`DecodeBudget::decode`], for a stream with a tighter bound of its own: decoding
/// past `max` bytes is an error that charges `max`, the work it took to find out.
pub fn decode_within(&mut self, stream: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
self.charge(stream, max, Stream::decoded_within_counted)
self.charge(stream, max, Stream::decoded_within_counted).map(|(data, _)| data)
}
/// [`DecodeBudget::decode_within`] (`usize::MAX` for no bound of its own), with the stream's
/// `/DecodeParms` given by reference read in `doc`, also saying whether the stream is damaged:
/// `true` when a filter stopped at corrupt data, so the bytes are only what decoded before it.
/// A stream written back from them loses everything after the damage, which other readers may
/// still read.
pub fn decode_with_damage(&mut self, doc: &Document, stream: &Stream, max: usize) -> Result<(Vec<u8>, bool), CosError> {
self.charge(&doc.decoding_view(stream), max, Stream::decoded_within_counted)
}
/// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to
/// the allowance.
pub fn decode_strict(&mut self, stream: &Stream) -> Result<Vec<u8>, CosError> {
self.charge(stream, usize::MAX, Stream::decoded_strict_within_counted)
/// the allowance, with its `/DecodeParms` given by reference read in `doc`.
pub fn decode_strict(&mut self, doc: &Document, stream: &Stream) -> Result<Vec<u8>, CosError> {
let strict: Decoder = |s, max| s.decoded_strict_within_counted(max).map(|(data, spent)| (data, false, spent));
self.charge(&doc.decoding_view(stream), usize::MAX, strict).map(|(data, _)| data)
}
/// Decodes `stream` within `max` and the allowance, charging what every filter stage
/// decoded: a stage's output counts even when a later stage shrinks it, and a corrupt stream
/// pays for the bytes decoded before the error, so referencing a stream again and again
/// spends the allowance by the work each decode does.
fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result<Vec<u8>, CosError> {
fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result<(Vec<u8>, bool), CosError> {
if self.bytes == 0 && !stream.raw.is_empty() {
return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()));
}
if max < self.bytes {
let result = decode(stream, max);
let spent = match &result {
Ok((_, spent)) => *spent,
Ok((_, _, spent)) => *spent,
Err((FilterError::LimitExceeded(_), _)) => max,
Err((_, spent)) => *spent,
};
self.bytes = self.bytes.saturating_sub(spent);
return result.map(|(data, _)| data).map_err(|(e, _)| CosError::Filter(e.to_string()));
return result.map(|(data, damaged, _)| (data, damaged)).map_err(|(e, _)| CosError::Filter(e.to_string()));
}
match decode(stream, self.bytes) {
Ok((data, spent)) => {
Ok((data, damaged, spent)) => {
self.bytes = self.bytes.saturating_sub(spent);
Ok(data)
Ok((data, damaged))
}
Err((FilterError::LimitExceeded(_), _)) => {
self.bytes = 0;
@@ -332,17 +344,24 @@ impl DecodeBudget {
self.seen.insert(r)
}
/// Each stream `contents` names, decoded against the allowance, in order. `contents` is a
/// page's `/Contents`: one stream, or an array whose non-stream entries are left out.
/// A stream that fails to decode, or arrives after the allowance is spent, is an `Err` in its
/// place, so the caller chooses between skipping it and giving up.
/// Each stream `contents` names, decoded against the allowance, in order, its `/DecodeParms`
/// given by reference read in `doc`. `contents` is a page's `/Contents`: one stream, or an
/// array whose non-stream entries are left out. A stream that fails to decode, or arrives
/// after the allowance is spent, is an `Err` in its place, so the caller chooses between
/// skipping it and giving up.
pub fn decode_contents(&mut self, doc: &Document, contents: &Object) -> Vec<Result<Vec<u8>, CosError>> {
self.decode_contents_with_damage(doc, contents).into_iter().map(|read| read.map(|(data, _)| data)).collect()
}
/// [`DecodeBudget::decode_contents`], each stream also saying whether it is damaged partway
/// ([`DecodeBudget::decode_with_damage`], which reads `/DecodeParms` given by reference).
pub fn decode_contents_with_damage(&mut self, doc: &Document, contents: &Object) -> Vec<Result<(Vec<u8>, bool), CosError>> {
match &*doc.resolve(contents) {
Object::Stream(s) => vec![self.decode(s)],
Object::Stream(s) => vec![self.decode_with_damage(doc, s, usize::MAX)],
Object::Array(list) => list
.iter()
.filter_map(|entry| match &*doc.resolve(entry) {
Object::Stream(s) => Some(self.decode(s)),
Object::Stream(s) => Some(self.decode_with_damage(doc, s, usize::MAX)),
_ => None,
})
.collect(),
@@ -769,7 +788,7 @@ mod tests {
assert_eq!(budget.remaining(), 0);
let mut budget = DecodeBudget::new((1 << 20) - 1, 1);
let Object::Stream(stream) = &*doc.get(bomb) else { panic!("not a stream") };
assert!(budget.decode_strict(stream).is_err());
assert!(budget.decode_strict(&doc, stream).is_err());
assert_eq!(budget.remaining(), 0);
let mut budget = DecodeBudget::new(10 << 20, 1);
@@ -793,14 +812,111 @@ mod tests {
let corrupt = Stream::from_raw(whole.dict.clone(), whole.raw.get(..cut).unwrap_or_default().to_vec());
let partial = corrupt.decoded().map(|d| d.len()).unwrap_or(0);
assert!(partial > 0, "a cut flate stream decodes in part");
let doc = Document::new_empty();
let mut budget = DecodeBudget::new(10 << 20, 1);
assert!(budget.decode_strict(&corrupt).is_err());
assert!(budget.decode_strict(&doc, &corrupt).is_err());
assert_eq!(budget.remaining(), (10 << 20) - partial);
let mut budget = DecodeBudget::new(10 << 20, 1);
assert!(budget.decode_within(&corrupt, 1 << 30).is_ok());
assert_eq!(budget.remaining(), (10 << 20) - partial);
}
/// A stream damaged partway decodes to what came before the damage, and says so, so that code
/// writing it back can leave it as it is. What every reader takes is no damage: a missing
/// checksum, no filter at all.
#[test]
fn a_stream_damaged_partway_says_so() {
let doc = Document::new_empty();
let read = |budget: &mut DecodeBudget, s: &Stream, max| budget.decode_with_damage(&doc, s, max).map(|(d, damaged)| (d.len(), damaged)).ok();
let whole = Stream::flate(Dict::new(), &vec![b' '; 1 << 20]);
let cut = Stream::from_raw(whole.dict.clone(), whole.raw.get(..whole.raw.len() / 2).unwrap_or_default().to_vec());
let partial = cut.decoded().map(|d| d.len()).unwrap_or(0);
assert!(partial > 0, "a cut flate stream decodes in part");
let mut budget = DecodeBudget::new(10 << 20, 1);
assert_eq!(read(&mut budget, &cut, usize::MAX), Some((partial, true)));
assert_eq!(budget.remaining(), (10 << 20) - partial);
// Within a bound of its own, under what is left.
assert_eq!(read(&mut budget, &cut, 4 << 20), Some((partial, true)));
assert_eq!(budget.decode(&cut).map(|d| d.len()).ok(), Some(partial));
let mut budget = DecodeBudget::new(10 << 20, 1);
assert_eq!(read(&mut budget, &whole, usize::MAX), Some((1 << 20, false)));
assert_eq!(read(&mut budget, &whole, 4 << 20), Some((1 << 20, false)));
let unchecked = Stream::from_raw(whole.dict.clone(), whole.raw.get(..whole.raw.len() - 4).unwrap_or_default().to_vec());
assert_eq!(read(&mut budget, &unchecked, usize::MAX), Some((1 << 20, false)));
assert_eq!(read(&mut budget, &Stream::from_raw(Dict::new(), b"0 0 m".to_vec()), usize::MAX), Some((5, false)));
// A later stage's damage is the stream's.
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")]));
let hex = Stream::from_raw(dict, Stream::flate(Dict::new(), b"4142zz4344>").raw.to_vec());
assert_eq!(budget.decode_with_damage(&doc, &hex, usize::MAX).ok(), Some((b"AB".to_vec(), true)));
// So is an earlier stage's, though the stage after it reads all it was given: a stored
// block of whole hex, then a block of the reserved type (RFC 1951 §3.2.3).
let readable = b"4142>";
let len = u16::try_from(readable.len()).unwrap_or_default();
let mut raw = vec![0x78, 0x01, 0x00];
raw.extend_from_slice(&len.to_le_bytes());
raw.extend_from_slice(&(!len).to_le_bytes());
raw.extend_from_slice(readable);
raw.push(0x07);
let flate_damaged = Stream::from_raw(hex.dict.clone(), raw);
assert_eq!(budget.decode_with_damage(&doc, &flate_damaged, usize::MAX).ok(), Some((b"AB".to_vec(), true)));
}
/// `/DecodeParms` given by reference, or a parameter given by reference in them, is read: a
/// predicted stream decodes as with them direct. Of the stream, decoding copies no more than
/// its filters and their parameters.
#[test]
fn parameters_given_by_reference_are_read() {
let content = b"q 10 0 0 10 0 0 cm /Im Do Q";
// PNG Up, one column: each byte a row of its own, tagged 2.
let mut rows = Vec::new();
let mut above = 0u8;
for &b in content {
rows.extend([2, b.wrapping_sub(above)]);
above = b;
}
let raw = Stream::flate(Dict::new(), &rows).raw;
let mut doc = Document::new_empty();
let direct: Dict = [(b"Predictor".to_vec(), Object::Int(12))].into_iter().collect();
let by_reference = Object::Ref(doc.add(Object::Dict(direct.clone())));
let predictor = doc.add(Object::Int(12));
let inner = Object::Dict([(b"Predictor".to_vec(), Object::Ref(predictor))].into_iter().collect());
for parms in [Object::Dict(direct), by_reference, inner] {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
dict.set(b"DecodeParms".to_vec(), parms.clone());
dict.set(b"Other".to_vec(), Object::Array(vec![Object::Int(0); 1000]));
let s = Stream::from_raw(dict, raw.to_vec());
assert_eq!(DecodeBudget::page().decode_with_damage(&doc, &s, usize::MAX).ok(), Some((content.to_vec(), false)), "{parms:?}");
assert_eq!(DecodeBudget::page().decode_strict(&doc, &s).ok(), Some(content.to_vec()), "{parms:?}");
let r = doc.add(Object::Stream(s.clone()));
let read = DecodeBudget::page().decode_contents_with_damage(&doc, &Object::Array(vec![Object::Ref(r)]));
assert_eq!(read, [Ok((content.to_vec(), false))], "{parms:?}");
assert_eq!(DecodeBudget::page().decode_contents(&doc, &Object::Ref(r)), [Ok(content.to_vec())], "{parms:?}");
let view = doc.decoding_view(&s);
assert!(matches!(view, std::borrow::Cow::Borrowed(_)) || view.dict.len() == 2, "{:?}", view.dict);
}
}
/// A page's content streams each say whether they are damaged, in their places.
#[test]
fn a_pages_streams_each_say_whether_they_are_damaged() {
let mut doc = Document::new_empty();
let whole = doc.add(Object::Stream(Stream::flate(Dict::new(), b"0 0 m 1 1 l S")));
let Object::Stream(s) = &*doc.get(whole) else { panic!("not a stream") };
let cut = Stream::from_raw(s.dict.clone(), s.raw.get(..s.raw.len() - 6).unwrap_or_default().to_vec());
let cut = doc.add(Object::Stream(cut));
let contents = Object::Array(vec![Object::Ref(whole), Object::Null, Object::Ref(cut)]);
let mut budget = DecodeBudget::page();
let read: Vec<_> = budget.decode_contents_with_damage(&doc, &contents).into_iter().map(|r| r.map(|(_, damaged)| damaged).ok()).collect();
assert_eq!(read, [Some(false), Some(true)]);
let one: Vec<_> =
budget.decode_contents_with_damage(&doc, &Object::Ref(cut)).into_iter().map(|r| r.map(|(_, damaged)| damaged).ok()).collect();
assert_eq!(one, [Some(true)]);
assert_eq!(budget.decode_contents(&doc, &contents).iter().filter(|r| r.is_ok()).count(), 2, "read up to the damage");
}
/// A stream that fails its reading as zlib, and then as raw deflate after decoding all of it,
/// gives nothing back, and each reference still pays for what it decoded.
#[test]
@@ -832,7 +948,7 @@ mod tests {
assert_eq!(budget.remaining(), (10 << 20) - charged);
assert!(budget.decode_within(&chained, 2 << 20).is_ok());
assert_eq!(budget.remaining(), (10 << 20) - 2 * charged);
assert!(budget.decode_strict(&chained).is_ok());
assert!(budget.decode_strict(&Document::new_empty(), &chained).is_ok());
assert_eq!(budget.remaining(), (10 << 20) - 3 * charged);
}
}
+25 -8
View File
@@ -323,7 +323,29 @@ impl Document {
/// ([`pdfcraft_filters::MAX_CHAIN`]). Only small values are copied, so a file can't have a
/// large object copied once for each key and filter.
pub fn with_direct_parms<'s>(&self, s: &'s Stream) -> Cow<'s, Stream> {
let Some(p) = s.dict.get(b"DecodeParms") else { return Cow::Borrowed(s) };
let Some(made) = self.direct_parms(s) else { return Cow::Borrowed(s) };
let mut s = s.clone();
s.dict.set(b"DecodeParms".to_vec(), made);
Cow::Owned(s)
}
/// [`Document::with_direct_parms`] for decoding alone: where that copies `s`, this makes a
/// stream of `s`'s data, shared, and a dictionary of its `/Filter` and the parameters made
/// direct, all its filters read. A stream named again and again, as a page's `/Contents` may
/// list one, isn't copied whole each time.
pub(crate) fn decoding_view<'s>(&self, s: &'s Stream) -> Cow<'s, Stream> {
let Some(made) = self.direct_parms(s) else { return Cow::Borrowed(s) };
let mut dict = Dict::new();
if let Some(f) = s.dict.get(b"Filter") {
dict.set(b"Filter".to_vec(), f.clone());
}
dict.set(b"DecodeParms".to_vec(), made);
Cow::Owned(Stream { dict, raw: s.raw.clone() })
}
/// The `/DecodeParms` [`Document::with_direct_parms`] gives `s`, or `None` when it keeps `s`.
fn direct_parms(&self, s: &Stream) -> Option<Object> {
let p = s.dict.get(b"DecodeParms")?;
let crypt = |f: &Object| matches!(f.as_name(), Some(b"Crypt"));
let filters: Vec<bool> = match s.dict.get(b"Filter") {
Some(Object::Array(a)) => a.iter().map(crypt).collect(),
@@ -331,7 +353,7 @@ impl Document {
None => Vec::new(),
};
if filters.iter().filter(|c| !**c).count() > pdfcraft_filters::MAX_CHAIN {
return Cow::Borrowed(s);
return None;
}
let mut changed = matches!(p, Object::Ref(_));
// Its numbers and flags kept, resolved; anything else is no filter's parameters (null).
@@ -358,12 +380,7 @@ impl Document {
}
_ => one(p, &mut changed),
};
if !changed {
return Cow::Borrowed(s);
}
let mut s = s.clone();
s.dict.set(b"DecodeParms".to_vec(), made);
Cow::Owned(s)
changed.then_some(made)
}
/// `o`, or the object it refers to, without copying either.
+10 -5
View File
@@ -297,13 +297,18 @@ impl Stream {
/// [`Stream::decoded_within`] for a caller charging an allowance: the filter error is kept
/// typed, so a caller can tell a spent limit from corrupt data, `max` bounds all filter
/// stages together, and the result gives the bytes they decoded (the work done, on success
/// and on failure).
pub(crate) fn decoded_within_counted(&self, max: usize) -> Result<(Vec<u8>, usize), (pdfcraft_filters::FilterError, usize)> {
/// stages together, and the result gives whether the data stopped at damage (it is then what
/// decoded before it) and the bytes the stages decoded (the work done, on success and on
/// failure).
pub(crate) fn decoded_within_counted(&self, max: usize) -> Result<(Vec<u8>, bool, usize), (pdfcraft_filters::FilterError, usize)> {
let mut damaged = false;
let decode = |chain: &[(Filter, Params)], data: &[u8], max| {
pdfcraft_filters::decode_tolerant_counted(chain, data, max).map(|(v, _, spent)| (v, spent))
pdfcraft_filters::decode_tolerant_counted(chain, data, max).map(|(v, partial, spent)| {
damaged = partial;
(v, spent)
})
};
self.decode_chain(max, decode)
self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent))
}
/// [`Stream::decoded_within`] spending from `work`, what an operation may still do across a
+4 -4
View File
@@ -208,7 +208,7 @@ pub(crate) fn restore(doc: &mut Document, page: &pdfcraft_model::Page) -> Result
let same = |w: &ObjRef| match &*doc.get(*w) {
Object::Stream(s) => {
[TAG, &b"PCMark"[..], b"Subtype"].iter().all(|k| s.dict.get(k).is_none())
&& DecodeBudget::new(SCAN_BYTES, 0).decode_strict(s).ok().as_deref() == Some(bytes.as_slice())
&& DecodeBudget::new(SCAN_BYTES, 0).decode_strict(doc, s).ok().as_deref() == Some(bytes.as_slice())
}
_ => false,
};
@@ -248,7 +248,7 @@ fn restored_copy(doc: &Document, page: &pdfcraft_model::Page, copy: ObjRef) -> O
return None;
}
let hash = note.get(b"Before")?.as_string()?;
let bytes = DecodeBudget::new(SCAN_BYTES, 0).decode_strict(s).ok()?;
let bytes = DecodeBudget::new(SCAN_BYTES, 0).decode_strict(doc, s).ok()?;
let parsed = pdfcraft_content::parse(head(&bytes));
// The `n` standing in for the fill, after exactly the operators that came before it.
let fill = parsed.ops.get(index)?;
@@ -384,11 +384,11 @@ fn scan(doc: &Document, p: &pdfcraft_model::Page, swap: Option<(ObjRef, &[u8])>)
if b.len() > SCAN_BYTES.saturating_sub(total) {
return None;
}
budget.decode_strict(&Stream::from_raw(Dict::new(), b.to_vec())).ok()?
budget.decode_strict(doc, &Stream::from_raw(Dict::new(), b.to_vec())).ok()?
}
// Tolerant decoding can return an empty or partial stream after corruption.
// That cannot prove which paint preceded the candidate backdrop.
_ => budget.decode_strict(s).ok()?,
_ => budget.decode_strict(doc, s).ok()?,
};
if bytes.len() > SCAN_BYTES.saturating_sub(total).saturating_sub(1) {
return None;
+2 -2
View File
@@ -1,7 +1,7 @@
//! A page's background colour: the colour the page is painted with before anything else, as
//! Edit a PDF ▸ Background and many producers do it, for exports that keep a page colour (#2992).
use pdfcraft_content::{Matrix, Op, Pieces};
use pdfcraft_content::{Matrix, Op};
use pdfcraft_cos::{Dict, Document};
use crate::{EditError, Rgb};
@@ -51,7 +51,7 @@ pub fn page_background(doc: &Document, page: usize) -> Result<Option<Rgb>, EditE
let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let gstates = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default();
let streams = crate::text::content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let pieces = crate::text::joined(&streams);
let ops = pieces.parse().ops;
let mut st = State { ctm: Matrix::IDENTITY, space: Space::Gray, fill: Some([0.0; 3]), alpha: 1.0, plain: true, clip: Some(Vec::new()) };
+59 -46
View File
@@ -4,12 +4,12 @@
//! or delete. Only the stream that draws it is rewritten, as a new object.
use kurbo::{BezPath, Point, Shape};
use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops};
use pdfcraft_content::{Matrix, Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use std::sync::Arc;
use crate::EditError;
use crate::text::content_streams;
use crate::text::{ContentStream, content_streams, joined};
/// The identity of a soft mask (`/SMask`) in the PDF graphics state.
#[derive(Clone, Debug)]
@@ -47,7 +47,7 @@ pub struct PageImage {
pub width: u32,
pub height: u32,
/// The content stream its `Do` (its first token) is in, and the `Do`'s index among the
/// operators of the page's streams joined ([`Pieces`]).
/// operators of the page's streams joined ([`pdfcraft_content::Pieces`]).
pub stream: usize,
op: usize,
/// CTM before `Do`, excluding a Form's own Matrix.
@@ -103,11 +103,6 @@ fn too_many_images() -> EditError {
EditError::Invalid(format!("the page draws more than {MAX_PAGE_IMAGES} images"))
}
/// The page's streams joined into the one stream they are: an operator may span two of them.
fn joined(streams: &[(Object, Vec<u8>)]) -> Pieces {
Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>())
}
fn page_of(doc: &Document, page: usize) -> Result<pdfcraft_model::Page, EditError> {
pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))
}
@@ -532,12 +527,17 @@ fn exclusive_clip(ops: &[Op], op: usize) -> Option<(usize, Matrix)> {
/// The `q cm … Q` whose only content after its `cm` is `start..=end` (a `Do`, or a balanced
/// `q … Q` scope), with the index and value of that `cm`. Another transform of the same
/// placement is folded into it, so repeated moves and resizes don't nest one more `q` each.
fn enclosing_transform(ops: &[Op], start: usize, end: usize) -> Option<(usize, Matrix)> {
/// None when its `Q` is at or past `hidden` ([`crate::text::first_op_after_damage`]): a stream
/// between that wasn't read may draw in that `cm` too.
fn enclosing_transform(ops: &[Op], start: usize, end: usize, hidden: usize) -> Option<(usize, Matrix)> {
let at = start.checked_sub(1)?;
let cm = ops.get(at)?;
if !ops.get(at.checked_sub(1)?)?.is("q") || !ops.get(end.checked_add(1)?)?.is("Q") || !cm.is("cm") || cm.operands.len() != 6 {
return None;
}
if end.checked_add(1)? >= hidden {
return None;
}
let m = Matrix(cm.nums::<6>()?);
m.0.iter().all(|v| v.is_finite()).then_some((at, m))
}
@@ -1057,7 +1057,9 @@ fn determine_arrange_spans(
has_other_paint: bool,
marked_unit: bool,
) -> Result<(std::ops::Range<usize>, usize, usize, usize), EditError> {
let scope = image_scopes(ops, target, "arranged")
// Nothing is hidden for Arrange: it takes out the scope it finds, so a scope that closes past a
// stream not read whole changes a stream past it, which `rewritten_contents` refuses.
let scope = image_scopes(ops, target, "arranged", ops.len())
.ok()
.and_then(|s| s.isolated)
.filter(|&(start, end, _)| start >= target_unit.start && end < target_unit.end);
@@ -1191,13 +1193,7 @@ fn build_relocated_unit_ops(
}
/// Change the stacking order of `img`, drawn on page `p` by its content streams `all`.
fn arrange_image(
doc: &mut Document,
p: &pdfcraft_model::Page,
all: &[(Object, Vec<u8>)],
img: &PageImage,
order: ArrangeOrder,
) -> Result<(), EditError> {
fn arrange_image(doc: &mut Document, p: &pdfcraft_model::Page, all: &[ContentStream], img: &PageImage, order: ArrangeOrder) -> Result<(), EditError> {
let pieces = joined(all);
let ops = pieces.parse().ops;
let target = img.op;
@@ -1264,7 +1260,7 @@ fn arrange_image(
ops.get(dest_unit.start).map(|op| pieces.piece_of(op.span.start))
};
let owned_destination = destination_piece
.filter(|piece| all.get(*piece).is_some_and(|(obj, _)| doc.resolve(obj).as_dict().is_some_and(|d| d.get(b"PCAdded").is_some())));
.filter(|piece| all.get(*piece).is_some_and(|s| doc.resolve(&s.entry).as_dict().is_some_and(|d| d.get(b"PCAdded").is_some())));
if let Some(piece) = owned_destination {
let whole_unit = ops.iter().enumerate().all(|(index, op)| {
let (first, last) = pieces.pieces_of(op);
@@ -1439,13 +1435,14 @@ fn arrange_image(
};
if let Some(piece) = owned_destination {
let insertion = piece.saturating_add(usize::from(insert_after));
crate::text::drawn_before_damage(all, insertion)?;
let changed = pieces.splice([(target_span, removal_replacement)]);
let contents = crate::text::rewritten_contents(doc, all, changed);
let contents = crate::text::rewritten_contents(doc, all, changed)?;
let mut list = match contents {
Object::Array(list) => list,
one => vec![one],
};
let insertion = piece.saturating_add(usize::from(insert_after));
if insertion > list.len() {
return Err(EditError::Invalid("the destination content stream is missing".into()));
}
@@ -1467,7 +1464,7 @@ fn arrange_image(
}
let changed = pieces.splice(edits);
let contents = crate::text::rewritten_contents(doc, all, changed);
let contents = crate::text::rewritten_contents(doc, all, changed)?;
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
@@ -1501,8 +1498,10 @@ impl ImageScopes {
}
}
/// [`ImageScopes`] of the `Do` at `target`. An image inside a text object cannot be `done`.
fn image_scopes(ops: &[Op], target: usize, done: &str) -> Result<ImageScopes, EditError> {
/// [`ImageScopes`] of the `Do` at `target`. An image inside a text object cannot be `done`. A scope
/// still open at op `hidden` ([`crate::text::first_op_after_damage`]) holds a stream that wasn't
/// read, which may paint there: it counts as painting something else.
fn image_scopes(ops: &[Op], target: usize, done: &str, hidden: usize) -> Result<ImageScopes, EditError> {
struct Scope {
start: usize,
matrix: Matrix,
@@ -1521,6 +1520,11 @@ fn image_scopes(ops: &[Op], target: usize, done: &str) -> Result<ImageScopes, Ed
let mut isolated = None;
let mut outermost = None;
for (index, op) in ops.iter().enumerate() {
if index == hidden {
for scope in &mut stack {
scope.other_paint = true;
}
}
match op.op.as_slice() {
b"q" => {
if stack.len() >= 256 {
@@ -1606,20 +1610,26 @@ fn app_crop_ranges(ops: &[Op], start: usize, target: usize) -> Vec<std::ops::Ran
}
/// Remove only crops written by PdfCraft, preserving the document author's clipping paths.
fn remove_image_crop(doc: &mut Document, p: &pdfcraft_model::Page, all: &[(Object, Vec<u8>)], img: &PageImage) -> Result<(), EditError> {
fn remove_image_crop(doc: &mut Document, p: &pdfcraft_model::Page, all: &[ContentStream], img: &PageImage) -> Result<(), EditError> {
let pieces = joined(all);
let ops = pieces.parse().ops;
let target = img.op;
let scope = image_scopes(&ops, target, "cropped")?;
let Some((start, _, _)) = scope.transform_scope(target) else {
return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into()));
let crops = |hidden| -> Result<Vec<std::ops::Range<usize>>, EditError> {
let scope = image_scopes(&ops, target, "cropped", hidden)?;
Ok(scope.transform_scope(target).map(|(start, _, _)| app_crop_ranges(&ops, start, target)).unwrap_or_default())
};
let crops = app_crop_ranges(&ops, start, target);
if crops.is_empty() {
return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into()));
let crops_there = crops(crate::text::first_op_after_damage(all, &pieces, &ops))?;
if crops_there.is_empty() {
// A crop in a scope that closes past a stream not read whole may clip what that stream draws.
return Err(EditError::Invalid(if crops(ops.len())?.is_empty() {
"the image has no PdfCraft crop to remove".into()
} else {
"the image's crop may also clip what a content stream that is damaged or can't be decoded draws, so it isn't removed".into()
}));
}
let crops = crops_there;
let changed = crate::text::splice(&pieces, &ops, |index| (Vec::new(), !crops.iter().any(|r| r.contains(&index))));
let contents = crate::text::rewritten_contents(doc, all, changed);
let contents = crate::text::rewritten_contents(doc, all, changed)?;
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
@@ -1666,7 +1676,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
}
};
let changed = crate::text::splice(&pieces, &ops, |index| if index == img.op { (replacement.clone(), false) } else { (Vec::new(), true) });
let contents = crate::text::rewritten_contents(doc, &all, changed);
let contents = crate::text::rewritten_contents(doc, &all, changed)?;
doc.update_dict(p.obj, |d| {
d.set(b"Contents".to_vec(), contents);
if let Some(res) = resources {
@@ -1687,7 +1697,7 @@ pub(crate) fn selectable_images(
Ok(page_images(doc, page)?
.into_iter()
.enumerate()
.filter(|(_, image)| !all.get(image.stream).and_then(|(o, _)| o.as_ref()).is_some_and(|r| excluded.contains(&r)))
.filter(|(_, image)| !all.get(image.stream).and_then(|s| s.entry.as_ref()).is_some_and(|r| excluded.contains(&r)))
.collect())
}
@@ -1700,7 +1710,7 @@ pub(crate) fn painted_over(doc: &Document, page: usize, rect: [f64; 4], from: us
fn transform_image(
doc: &mut Document,
page: &pdfcraft_model::Page,
streams: &[(Object, Vec<u8>)],
streams: &[ContentStream],
image: &PageImage,
transform: [f64; 6],
) -> Result<(), EditError> {
@@ -1715,8 +1725,9 @@ fn transform_image(
}
// A private path crop moves with its image regardless of intervening state operators.
// Shared crops retain the existing fixed-clip behavior, preserving neighbouring paint.
let hidden = crate::text::first_op_after_damage(streams, &pieces, &ops);
let (start, end, placement) =
image_scopes(&ops, target, "transformed")?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix)));
image_scopes(&ops, target, "transformed", hidden)?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix)));
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
let local = placement.then(&Matrix(transform)).then(&inverse);
if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
@@ -1725,7 +1736,7 @@ fn transform_image(
// Already alone in a `q cm … Q` (its own placement, or the wrapper an earlier edit added):
// change that `cm` in place. The CTM inside is `local · cm · outer` either way.
if let Some((at, cm)) = enclosing_transform(&ops, start, end) {
if let Some((at, cm)) = enclosing_transform(&ops, start, end, hidden) {
let folded = local.then(&cm);
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image transform is too large".into()));
@@ -1733,7 +1744,7 @@ fn transform_image(
let changed = crate::text::splice(&pieces, &ops, |index| {
if index == at { (vec![Op::new("cm", folded.0.map(Object::Real).to_vec())], false) } else { (Vec::new(), true) }
});
let contents = crate::text::rewritten_contents(doc, streams, changed);
let contents = crate::text::rewritten_contents(doc, streams, changed)?;
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?;
return Ok(());
}
@@ -1754,7 +1765,7 @@ fn transform_image(
(Vec::new(), true)
}
});
let contents = crate::text::rewritten_contents(doc, streams, changed);
let contents = crate::text::rewritten_contents(doc, streams, changed)?;
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
@@ -1766,7 +1777,7 @@ fn transform_image(
fn crop_image_impl(
doc: &mut Document,
page: &pdfcraft_model::Page,
streams: &[(Object, Vec<u8>)],
streams: &[ContentStream],
image: &PageImage,
keep: [f64; 4],
) -> Result<(), EditError> {
@@ -1790,8 +1801,9 @@ fn crop_image_impl(
if !ops.get(target).is_some_and(|op| op.is("Do")) {
return Err(EditError::Invalid("the image operation could not be located".into()));
}
let (start, end, placement) =
image_scopes(&ops, target, "cropped")?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix)));
let (start, end, placement) = image_scopes(&ops, target, "cropped", crate::text::first_op_after_damage(streams, &pieces, &ops))?
.transform_scope(target)
.unwrap_or((target, target, Matrix(image.draw_matrix)));
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y));
if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite() && x.abs() <= 1e12 && y.abs() <= 1e12) {
@@ -1844,7 +1856,7 @@ fn crop_image_impl(
(Vec::new(), true)
}
});
let contents = crate::text::rewritten_contents(doc, streams, changed);
let contents = crate::text::rewritten_contents(doc, streams, changed)?;
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
@@ -1872,6 +1884,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
}
let pieces = joined(&all);
let ops = pieces.parse().ops;
let hidden = crate::text::first_op_after_damage(&all, &pieces, &ops);
let mut inside_text = false;
for (index, op) in ops.iter().enumerate() {
if op.is("BT") {
@@ -1889,7 +1902,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
let mut folds = std::collections::BTreeMap::new();
for image in images.iter().filter(|image| image.has_editor_crop()) {
let Some(&(_, _, offset)) = plans.get(&image.op) else { continue };
let Some((start, end, placement)) = image_scopes(&ops, image.op, "moved")?.transform_scope(image.op) else {
let Some((start, end, placement)) = image_scopes(&ops, image.op, "moved", hidden)?.transform_scope(image.op) else {
return Err(EditError::Invalid("the image crop cannot be moved independently".into()));
};
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("a selected image has no area".into()))?;
@@ -1906,7 +1919,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
};
// An enclosing cm is before the captured placement; an inside-scope cm is after it.
// Their multiplication orders differ when that cm scales or shears a translation.
let folded = enclosing_transform(&ops, start, end)
let folded = enclosing_transform(&ops, start, end, hidden)
.map(|(at, cm)| (at, local.then(&cm)))
.or_else(|| leading_transform().map(|(at, cm)| (at, cm.then(&local))));
if let Some((at, folded)) = folded {
@@ -1942,7 +1955,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
// An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another.
for index in 0..ops.len() {
let Some(&(movement, _, _)) = plans.get(&index) else { continue };
let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue };
let Some((at, cm)) = enclosing_transform(&ops, index, index, hidden) else { continue };
let folded = movement.then(&cm);
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image move is too large".into()));
@@ -1977,7 +1990,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
out.push(Op::new("Q", vec![]));
(out, false)
});
let contents = crate::text::rewritten_contents(doc, &all, data);
let contents = crate::text::rewritten_contents(doc, &all, data)?;
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
+14 -9
View File
@@ -4,8 +4,9 @@
use std::collections::{BTreeMap, BTreeSet};
use pdfcraft_content::{Matrix, Op, Pieces};
use pdfcraft_cos::{Dict, Document, Object};
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
use crate::text::ContentStream;
use crate::{Content, EditError};
pub const MAX_MOVE_OBJECTS: usize = 1_000;
@@ -152,7 +153,7 @@ fn added_geometry(doc: &Document, page: usize) -> Result<(Pieces, Vec<AddedGeome
return Ok((Pieces::default(), Vec::new()));
}
let streams = checked_streams(doc, page)?;
let pieces = Pieces::join(&streams.iter().map(|(_, bytes)| bytes.as_slice()).collect::<Vec<_>>());
let pieces = crate::text::joined(&streams);
let ops = pieces.parse().ops;
let owned: BTreeSet<_> = added.iter().map(|a| a.obj).collect();
let mut geometry = BTreeMap::new();
@@ -161,7 +162,7 @@ fn added_geometry(doc: &Document, page: usize) -> Result<(Pieces, Vec<AddedGeome
let mut stack = Vec::new();
for (index, op) in ops.iter().enumerate() {
let (stream, end) = pieces.pieces_of(op);
let object = streams.get(stream).and_then(|(r, _)| r.as_ref());
let object = streams.get(stream).and_then(|s| s.entry.as_ref());
if object.is_some_and(|r| owned.contains(&r)) {
if let std::collections::btree_map::Entry::Vacant(entry) = first.entry(stream) {
if stream != end || !op.is("q") {
@@ -275,7 +276,7 @@ fn translate_added(doc: &mut Document, page: usize, indexes: &[usize], offset: [
})
.collect::<Result<Vec<_>, EditError>>()?;
let data = pieces.splice(edits);
let contents = crate::text::rewritten_contents(doc, &streams, data);
let contents = crate::text::rewritten_contents(doc, &streams, data)?;
let refs = match &contents {
Object::Array(a) => a.clone(),
one => vec![one.clone()],
@@ -288,9 +289,9 @@ fn translate_added(doc: &mut Document, page: usize, indexes: &[usize], offset: [
Ok(())
}
/// A missing or undecodable preceding stream can hide graphics state that affects every
/// A missing, undecodable or damaged preceding stream can hide graphics state that affects every
/// later item. Refuse group editing rather than inventing an inventory from partial data.
fn checked_streams(doc: &Document, page: usize) -> Result<Vec<(Object, Vec<u8>)>, EditError> {
fn checked_streams(doc: &Document, page: usize) -> Result<Vec<ContentStream>, EditError> {
let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?;
let contents = match p.dict.get(b"Contents") {
None => Vec::new(),
@@ -305,6 +306,7 @@ fn checked_streams(doc: &Document, page: usize) -> Result<Vec<(Object, Vec<u8>)>
},
};
let mut remaining = MAX_CONTENT_BYTES;
let mut decode = DecodeBudget::new(usize::MAX, 0);
let mut depth = 0usize;
let mut operations = 0usize;
let mut streams = Vec::with_capacity(contents.len());
@@ -313,9 +315,12 @@ fn checked_streams(doc: &Document, page: usize) -> Result<Vec<(Object, Vec<u8>)>
let Object::Stream(stream) = &*resolved else {
return Err(EditError::Invalid("a page content stream is missing".into()));
};
let bytes = stream
.decoded_within(remaining.min(64 * 1024 * 1024))
let (bytes, damaged) = decode
.decode_with_damage(doc, stream, remaining.min(64 * 1024 * 1024))
.map_err(|_| EditError::Invalid("the page content cannot be decoded within its size limit".into()))?;
if damaged {
return Err(EditError::Invalid("a page content stream is damaged partway".into()));
}
remaining =
remaining.checked_sub(bytes.len()).ok_or_else(|| EditError::Invalid("the page content is too large for group selection".into()))?;
let parsed = pdfcraft_content::parse(&bytes);
@@ -333,7 +338,7 @@ fn checked_streams(doc: &Document, page: usize) -> Result<Vec<(Object, Vec<u8>)>
return Err(EditError::Invalid("the page graphics state is nested too deeply".into()));
}
}
streams.push((object, bytes));
streams.push(ContentStream { entry: object, data: bytes, read: crate::text::Read::Whole });
}
Ok(streams)
}
+348
View File
@@ -5306,6 +5306,308 @@ fn the_graphics_state_carries_over_between_content_streams() {
assert!(close(l.rect[0], 100.0) && close(l.size, 10.0), "{l:?}");
}
/// zlib data that reads as `readable` and is then damaged, with more of the stream after the
/// damage: a stored block, then a block of the reserved type (RFC 1951 §3.2.3), then `rest`.
fn damaged_flate(readable: &[u8], rest: &[u8]) -> pdfcraft_cos::Stream {
let len = u16::try_from(readable.len()).unwrap();
let mut raw = vec![0x78, 0x01, 0x00];
raw.extend_from_slice(&len.to_le_bytes());
raw.extend_from_slice(&(!len).to_le_bytes());
raw.extend_from_slice(readable);
raw.push(0x07);
raw.extend_from_slice(rest);
let mut dict = pdfcraft_cos::Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
pdfcraft_cos::Stream::from_raw(dict, raw)
}
/// A page whose first content stream is damaged after its text, and whose second is whole.
fn damaged_page() -> (Document, pdfcraft_cos::Stream) {
let mut doc = split_page("", "BT /F1 12 Tf 72 600 Td (Whole) Tj ET");
let damaged = damaged_flate(b"BT /F1 12 Tf 72 700 Td (Damaged) Tj ET\n", b"BT /F1 12 Tf 72 500 Td (After the damage) Tj ET");
doc.set(pdfcraft_cos::ObjRef::new(4, 0), damaged.clone());
(doc, damaged)
}
/// An edit that would rewrite a content stream damaged partway is refused: written from what
/// decoded, it would lose what follows the damage, which other readers may still read. So is an
/// edit in a later stream: what the damage hides can change how that stream reads, a string or
/// comment it leaves open, or the graphics state. An edit in a stream before the damage goes on,
/// and leaves the damaged one as it is stored.
#[test]
fn an_edit_never_rewrites_a_content_stream_damaged_partway() {
let (mut doc, damaged) = damaged_page();
let texts = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::<Vec<_>>();
assert_eq!(texts(&doc), ["Damaged", "Whole"], "read up to the damage");
let contents = |doc: &Document| pdfcraft_model::pages(doc)[0].dict.get(b"Contents").cloned();
let listed = contents(&doc);
let refused = text::replace_line(&mut doc, 0, 0, "Edited").unwrap_err();
assert!(refused.to_string().contains("is damaged partway"), "{refused}");
assert_eq!(contents(&doc), listed);
let refused = text::rewrite_block(&mut doc, 0, 0, Some("Edited"), &text::BlockStyle::default()).unwrap_err();
assert!(refused.to_string().contains("is damaged partway"), "{refused}");
assert_eq!(contents(&doc), listed);
let refused = text::replace_line(&mut doc, 0, 1, "Edited").unwrap_err();
assert!(refused.to_string().contains("comes after a content stream that is damaged or can't be decoded"), "{refused}");
assert_eq!(contents(&doc), listed);
let mut doc = split_page("BT /F1 12 Tf 72 600 Td (Whole) Tj ET", "");
doc.set(pdfcraft_cos::ObjRef::new(5, 0), damaged.clone());
text::replace_line(&mut doc, 0, 0, "Edited").unwrap();
let doc = reopen(&doc);
assert_eq!(texts(&doc), ["Edited", "Damaged"]);
let Some(Object::Array(list)) = contents(&doc) else { panic!("contents {:?}", contents(&doc)) };
assert_eq!(list.get(1), Some(&Object::Ref(pdfcraft_cos::ObjRef::new(5, 0))));
let Object::Stream(kept) = &*doc.get(pdfcraft_cos::ObjRef::new(5, 0)) else { panic!("not a stream") };
assert_eq!(kept.raw, damaged.raw, "stored as it was");
}
/// A comment the damage leaves open runs on into the next stream, up to its first end of line,
/// so a string starting there reads as operators: a line of text inside it. That line is in a
/// stream after the damage, and isn't edited.
#[test]
fn text_a_damaged_stream_runs_on_into_is_not_edited() {
let mut doc = split_page("", "(note\nBT /F1 12 Tf 72 600 Td (Inside a string) Tj ET\n) pop");
doc.set(pdfcraft_cos::ObjRef::new(4, 0), damaged_flate(b"0 g % a comment", b"\n"));
let texts = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::<Vec<_>>();
assert_eq!(texts(&doc), ["Inside a string"], "the comment runs on into the next stream");
let listed = pdfcraft_model::pages(&doc)[0].dict.get(b"Contents").cloned();
let refused = text::replace_line(&mut doc, 0, 0, "Edited").unwrap_err();
assert!(refused.to_string().contains("comes after"), "{refused}");
assert_eq!(pdfcraft_model::pages(&doc)[0].dict.get(b"Contents").cloned(), listed);
}
/// A content stream that doesn't decode is never written over. A partial rewrite of the page's
/// last paragraph closes its marker past the last operator read, in the page's last stream, here
/// one that doesn't decode: the rewrite is refused, not that stream replaced by the close. An
/// empty stream there takes the close.
#[test]
fn a_content_stream_that_does_not_decode_is_never_written_over() {
let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET";
let mut doc = split_page(src, "");
let mut dict = pdfcraft_cos::Dict::new();
dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
doc.set(pdfcraft_cos::ObjRef::new(5, 0), pdfcraft_cos::Stream::from_raw(dict, b"0 0 1 rg 0 0 10 10 re f".to_vec()));
let contents = |doc: &Document| pdfcraft_model::pages(doc)[0].dict.get(b"Contents").cloned();
let listed = contents(&doc);
let refused = text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap_err();
assert!(refused.to_string().contains("can't be decoded"), "{refused}");
assert_eq!(contents(&doc), listed);
let mut doc = split_page(src, "");
text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap();
let texts: Vec<_> = text::text_blocks(&reopen(&doc), 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(texts, ["One. Alpha next row."]);
}
/// A content stream that doesn't decode at all is no damage to write over: it is kept as stored,
/// and the page's streams before it are read and edited. A stream of a few MB is read for
/// selecting objects.
#[test]
fn a_content_stream_that_does_not_decode_is_kept_and_edited_before() {
let mut doc = split_page("BT /F1 12 Tf 72 600 Td (Whole) Tj ET", "");
let mut dict = pdfcraft_cos::Dict::new();
dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
let unknown = pdfcraft_cos::Stream::from_raw(dict, b"BT /F1 12 Tf 72 700 Td (Unread) Tj ET".to_vec());
doc.set(pdfcraft_cos::ObjRef::new(5, 0), unknown.clone());
text::replace_line(&mut doc, 0, 0, "Edited").unwrap();
let doc = reopen(&doc);
assert_eq!(text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect::<Vec<_>>(), ["Edited"]);
let Object::Stream(kept) = &*doc.get(pdfcraft_cos::ObjRef::new(5, 0)) else { panic!("not a stream") };
assert_eq!(kept.raw, unknown.raw, "stored as it was");
let large = format!("{}0 0 m 10 10 l S", " ".repeat(2 << 20));
assert!(editable_objects(&split_page(&large, "BT /F1 12 Tf 72 600 Td (Whole) Tj ET"), 0).is_ok());
}
/// A content stream that doesn't decode hides its operators as damage does. Here its `/Filter` is
/// given by reference, which a file may do and which isn't read here, and it holds a `cm` that
/// moves what the page draws after it. Text and images there aren't edited, as the edit would be
/// worked out without the `cm`.
#[test]
fn content_after_a_stream_that_does_not_decode_is_not_edited() {
use pdfcraft_cos::{Dict, ObjRef, Stream};
let hidden_move = |doc: &mut Document| {
let filter = doc.add(Object::name("FlateDecode"));
let mut s = Stream::flate(Dict::new(), b"1 0 0 1 0 -300 cm");
s.dict.set(b"Filter".to_vec(), Object::Ref(filter));
doc.set(ObjRef::new(4, 0), s);
};
let contents = |doc: &Document| pdfcraft_model::pages(doc)[0].dict.get(b"Contents").cloned();
let mut doc = split_page("", "BT /F1 12 Tf 72 600 Td (After) Tj ET");
hidden_move(&mut doc);
let listed = contents(&doc);
let refused = text::replace_line(&mut doc, 0, 0, "Edited").unwrap_err();
assert!(refused.to_string().contains("comes after a content stream that is damaged or can't be decoded"), "{refused}");
assert_eq!(contents(&doc), listed);
let mut doc = two_stream_image_page("", "q 100 0 0 100 200 200 cm /Im1 Do Q");
hidden_move(&mut doc);
let listed = contents(&doc);
let shift = images::ImageChange::Transform([1.0, 0.0, 0.0, 1.0, 10.0, 0.0]);
let refused = images::change_image(&mut doc, 0, 0, &shift).unwrap_err();
assert!(refused.to_string().contains("comes after"), "{refused}");
assert_eq!(contents(&doc), listed);
}
/// A text object still open at a stream not read whole goes on in it: the text that stream shows
/// starts where the text before it ends, so editing that text would move it. The edit is refused.
/// A change before the text object, in the same stream, goes on. The same holds when the hidden
/// stream is the page's last.
#[test]
fn text_a_stream_not_read_whole_goes_on_from_is_not_edited() {
use pdfcraft_cos::{Dict, ObjRef, Stream};
let contents = |doc: &Document| pdfcraft_model::pages(doc)[0].dict.get(b"Contents").cloned();
// The hidden stream between the text and a stream closing it, or the page's last.
for (damaged, last) in [(false, false), (true, false), (false, true), (true, true)] {
let what = format!("damaged: {damaged}, last: {last}");
let shown: &[u8] = if last { b"(World) Tj ET" } else { b"(World) Tj" };
let mut doc = split_page("BT /F1 12 Tf 72 700 Td (Before) Tj ET BT /F1 12 Tf 72 600 Td (Hello) Tj", "ET");
let hidden = if damaged {
damaged_flate(b"", shown)
} else {
let filter = doc.add(Object::name("FlateDecode"));
let mut s = Stream::flate(Dict::new(), shown);
s.dict.set(b"Filter".to_vec(), Object::Ref(filter));
s
};
let hidden = Object::Ref(doc.add(hidden));
let mut listed = vec![Object::Ref(ObjRef::new(4, 0)), hidden];
if !last {
listed.push(Object::Ref(ObjRef::new(5, 0)));
}
let listed = Object::Array(listed);
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), listed.clone())).unwrap();
let lines: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(lines, ["Before", "Hello"], "{what}");
let refused = text::replace_line(&mut doc, 0, 1, "Hi").unwrap_err();
assert!(refused.to_string().contains("goes on in a content stream"), "{what}: {refused}");
assert_eq!(contents(&doc), Some(listed), "{what}");
text::replace_line(&mut doc, 0, 0, "Earlier").unwrap();
}
}
/// An image's `q cm … Q` that closes past a stream not read whole, one that doesn't decode or one
/// damaged partway, may hold what that stream draws (here a square, in the image's `cm`), also when
/// the square's operands end the stream before it and only its keyword is hidden. Moving the image
/// leaves that `cm` as it is and gives the `Do` a `cm` of its own, so the square stays, with a clip
/// on the image too, which then stays where it is as one the square may share. A crop goes around
/// the `Do` alone, and a crop PdfCraft put in that scope isn't removed. A scope that closes in a
/// damaged stream before its damage holds none of it: its `cm` takes the move.
#[test]
fn an_image_whose_scope_closes_past_a_stream_not_read_whole_moves_alone() {
use pdfcraft_cos::{Dict, ObjRef, Stream};
let contents = |doc: &Document| pdfcraft_model::pages(doc)[0].dict.get(b"Contents").cloned();
let first_stream = |doc: &Document| {
let Some(Object::Array(list)) = contents(doc) else { panic!("contents {:?}", contents(doc)) };
let Some(Object::Stream(first)) = list.first().map(|f| (*doc.resolve(f)).clone()) else { panic!("no first stream") };
String::from_utf8(first.decoded().unwrap()).unwrap()
};
let later_streams = |doc: &Document| match contents(doc) {
Some(Object::Array(list)) => list.get(1..).map(<[Object]>::to_vec),
other => panic!("contents {other:?}"),
};
// The image in `q 100 0 0 100 50 50 cm`, a stream not read whole drawing a square in that
// `cm`, then the scope's `Q`. Split, the square's operands come before the hidden keyword.
let page = |clip: &str, damaged: bool, split: bool| {
let operands = if split && !damaged { " 1.5 0 0.5 0.5" } else { "" };
let mut doc = two_stream_image_page(&format!("q 100 0 0 100 50 50 cm {clip}/Im1 Do{operands}"), "Q");
let hidden = match (damaged, split) {
(true, false) => damaged_flate(b"", b"0 0 1 rg 1.5 0 0.5 0.5 re f"),
(true, true) => damaged_flate(b"0 0 1 rg 1.5 0", b" 0.5 0.5 re f"),
(false, _) => {
let filter = doc.add(Object::name("FlateDecode"));
let mut s = Stream::flate(Dict::new(), if split { b"re f".as_slice() } else { b"0 0 1 rg 1.5 0 0.5 0.5 re f" });
s.dict.set(b"Filter".to_vec(), Object::Ref(filter));
s
}
};
let hidden = doc.add(hidden);
let listed = vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(hidden), Object::Ref(ObjRef::new(5, 0))];
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(listed))).unwrap();
doc
};
let shift = images::ImageChange::Transform([1.0, 0.0, 0.0, 1.0, 10.0, 0.0]);
for damaged in [false, true] {
for split in [false, true] {
for clip in ["", "0 0 1 1 re W n ", "/PdfCraftImageCrop MP 0 0 1 1 re W n "] {
let what = format!("damaged: {damaged}, split: {split}, clip: {clip:?}");
let mut doc = page(clip, damaged, split);
let later = later_streams(&doc);
images::change_image(&mut doc, 0, 0, &shift).unwrap();
assert_eq!(later_streams(&doc), later, "{what}: the streams after it as they were");
let first = first_stream(&doc);
assert!(first.contains("100 0 0 100 50 50 cm"), "{what}: the scope's cm stays: {first}");
assert!(close(images::page_images(&doc, 0).unwrap()[0].rect, [60.0, 50.0, 160.0, 150.0]), "{what}: the image moved");
let mut doc = page(clip, damaged, split);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Crop([60.0, 60.0, 140.0, 140.0])).unwrap();
assert_eq!(later_streams(&doc), later, "{what}: cropped, the streams after it as they were");
let first = first_stream(&doc);
assert!(first.starts_with("q 100 0 0 100 50 50 cm"), "{what}: nothing put at the scope's start: {first}");
if clip.contains("PdfCraftImageCrop") {
let mut doc = page(clip, damaged, split);
let listed = contents(&doc);
let refused = images::change_image(&mut doc, 0, 0, &images::ImageChange::RemoveCrop).unwrap_err();
assert!(refused.to_string().contains("may also clip"), "{what}: {refused}");
assert_eq!(contents(&doc), listed, "{what}");
}
}
}
}
let mut doc = two_stream_image_page("q 100 0 0 100 50 50 cm /Im1 Do", "");
doc.set(ObjRef::new(5, 0), damaged_flate(b"Q\n", b"0 0 1 rg 1.5 0 0.5 0.5 re f"));
images::change_image(&mut doc, 0, 0, &shift).unwrap();
let first = first_stream(&doc);
assert!(first.contains("100 0 0 100 60 50 cm") && !first.contains("50 50 cm"), "closed before the damage: {first}");
}
/// A content stream whose `/DecodeParms`, or a parameter in them, is given by reference is read
/// through the reference: its text is listed and edited, not read with the predictor's tag bytes
/// left in it.
#[test]
fn content_with_parameters_given_by_reference_is_read_and_edited() {
use pdfcraft_cos::{Dict, ObjRef, Stream};
let content = b"BT /F1 12 Tf 72 700 Td (Predicted) Tj ET";
// PNG Up, one column: each byte a row of its own, tagged 2.
let mut rows = Vec::new();
let mut above = 0u8;
for &b in content {
rows.extend([2, b.wrapping_sub(above)]);
above = b;
}
let texts = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::<Vec<_>>();
for given in ["parameters", "predictor"] {
let mut doc = split_page("", "BT /F1 12 Tf 72 600 Td (Whole) Tj ET");
let predictor = Object::Int(12);
let parms = if given == "parameters" {
Object::Ref(doc.add(Object::Dict([(b"Predictor".to_vec(), predictor)].into_iter().collect::<Dict>())))
} else {
Object::Dict([(b"Predictor".to_vec(), Object::Ref(doc.add(predictor)))].into_iter().collect())
};
let mut s = Stream::flate(Dict::new(), &rows);
s.dict.set(b"DecodeParms".to_vec(), parms);
doc.set(ObjRef::new(4, 0), s);
assert_eq!(texts(&doc), ["Predicted", "Whole"], "{given} by reference");
text::replace_line(&mut doc, 0, 0, "Edited").unwrap();
assert_eq!(texts(&reopen(&doc)), ["Edited", "Whole"], "{given} by reference");
}
}
/// Objects are selected only from content read whole: what a damaged stream hides past the
/// damage could change the graphics state of every later item.
#[test]
fn objects_are_not_listed_from_a_content_stream_damaged_partway() {
let (doc, _) = damaged_page();
let refused = editable_objects(&doc, 0).unwrap_err();
assert!(refused.to_string().contains("damaged partway"), "{refused}");
assert!(editable_objects(&split_page("", "BT /F1 12 Tf 72 600 Td (Whole) Tj ET"), 0).is_ok());
}
fn two_stream_image_page(first: &str, second: &str) -> Document {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
@@ -5356,6 +5658,52 @@ fn arrange_and_crop_image_in_second_content_stream_succeeds() {
assert_eq!(after_crop[1].visible_rect, Some(after_crop[1].rect));
}
/// What follows the damage in a content stream is unknown, the graphics state the later streams
/// draw in too: here it hides a `cm` that doubles them. An image after the damage isn't moved, as
/// the move is worked out without that `cm`, and one before it isn't arranged to after it, beside
/// an added item there. An image before the damage is moved.
#[test]
fn images_after_a_content_stream_damaged_partway_are_not_moved() {
use pdfcraft_cos::{Dict, ObjRef, Stream};
let image = "q 100 0 0 100 200 200 cm /Im1 Do Q";
let hidden_scale = || damaged_flate(b"0 g\n", b"2 0 0 2 0 0 cm");
let shift = images::ImageChange::Transform([1.0, 0.0, 0.0, 1.0, 10.0, 0.0]);
let contents = |doc: &Document| pdfcraft_model::pages(doc)[0].dict.get(b"Contents").cloned();
let mut doc = two_stream_image_page("", image);
doc.set(ObjRef::new(4, 0), hidden_scale());
let listed = contents(&doc);
let refused = images::change_image(&mut doc, 0, 0, &shift).unwrap_err();
assert!(refused.to_string().contains("comes after"), "{refused}");
assert_eq!(contents(&doc), listed);
let mut doc = two_stream_image_page(image, "");
let damaged = doc.add(hidden_scale());
let mut added = Dict::new();
added.set(b"PCAdded".to_vec(), Object::Dict(Dict::new()));
let added = doc.add(Object::Stream(Stream::from_raw(added, b"q 0 0 1 rg 0 0 10 10 re f Q".to_vec())));
doc.update_dict(ObjRef::new(3, 0), |p| {
p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(damaged), Object::Ref(added)]))
})
.unwrap();
let listed = contents(&doc);
let refused = images::change_image(&mut doc, 0, 0, &images::ImageChange::Arrange(images::ArrangeOrder::BringToFront)).unwrap_err();
assert!(refused.to_string().contains("comes after"), "{refused}");
assert_eq!(contents(&doc), listed);
// Arranged within the content before the damage, it goes on.
doc.update_dict(ObjRef::new(3, 0), |p| {
p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(added), Object::Ref(damaged)]))
})
.unwrap();
images::change_image(&mut doc, 0, 0, &images::ImageChange::Arrange(images::ArrangeOrder::BringToFront)).unwrap();
let mut doc = two_stream_image_page(image, "");
doc.set(ObjRef::new(5, 0), hidden_scale());
images::change_image(&mut doc, 0, 0, &shift).unwrap();
let moved = images::page_images(&reopen(&doc), 0).unwrap();
assert!(close(moved[0].rect, [210.0, 200.0, 310.0, 300.0]), "{:?}", moved[0].rect);
}
/// One page whose font draws a few Hebrew letters (ToUnicode): A ב, B ד, C י, D ו, E ק, F ש,
/// G ל, H ם. Content streams draw Hebrew in visual order, so "בדיוק" is `(EDCBA)`.
fn hebrew_page(content: &str) -> Document {
+4 -1
View File
@@ -55,7 +55,10 @@ use fallback::{add_type3_font, is_type3_fallback};
pub(crate) use fallback::{glyph_path, type3_font_dict, unique_font_name};
use interp::{Origin, Paint, union};
use lines::page_dict;
pub(crate) use lines::{FormBudget, content_streams, content_streams_within, dict_in_place, form_call, resource_dict, rewritten_contents, splice};
pub(crate) use lines::{
ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, first_op_after_damage, form_call,
joined, resource_dict, rewritten_contents, splice,
};
pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines};
pub use rewrite::{LineEdit, replace_line, rewrite_block};
use rewrite::{coincident_ops, rewrite_block_from};
+4 -4
View File
@@ -1,7 +1,7 @@
use super::interp::{Carry, interpret};
use super::lines::{content_streams, page_dict, reading_lines, rewritten_contents, splice, text_lines};
use super::lines::{content_streams, joined, page_dict, reading_lines, rewritten_contents, splice, text_lines};
use super::rewrite::{LineEdit, coincident_ops, rewrite_block};
use super::{Document, EditError, HashMap, Object, Op, Pieces, TextLine, group_blocks, regions};
use super::{Document, EditError, HashMap, Object, Op, TextLine, group_blocks, regions};
/// An editable text region: adjacent runs on visual rows, followed by aligned rows.
/// A region may contain several source fonts, sizes, colours and weights.
@@ -270,7 +270,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
let p = page_dict(doc, page)?;
let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let streams = content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let pieces = joined(&streams);
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?;
let mut patches = HashMap::new();
@@ -297,7 +297,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
patches.insert(s.op, replacement);
}
let data = splice(&pieces, &ops, |i| patches.remove(&i).map_or((Vec::new(), true), |v| (v, false)));
let contents = rewritten_contents(doc, &streams, data);
let contents = rewritten_contents(doc, &streams, data)?;
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
+132 -23
View File
@@ -61,16 +61,37 @@ impl TextLine {
}
}
/// The page's content streams as listed, each with its decoded data (empty when it doesn't
/// decode), within one page's [`DecodeBudget`]. An error when the content runs past it: reading
/// or rewriting a page from part of its content would lose the rest.
pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result<Vec<(Object, Vec<u8>)>, EditError> {
/// One entry of a page's `/Contents`, and what it decoded to.
pub(crate) struct ContentStream {
/// The entry as listed.
pub(crate) entry: Object,
/// Its decoded data, empty when it doesn't decode.
pub(crate) data: Vec<u8>,
/// How much of it decoded.
pub(crate) read: Read,
}
/// How much of a content stream decoded ([`rewritten_contents`] says what each allows).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Read {
Whole,
/// Damaged partway: the data is what decoded before the damage. What follows it is unknown,
/// the graphics state and syntax the later streams start in too.
Damaged,
/// Not at all (a filter this reader lacks, say): the data is empty.
Unread,
}
/// The page's content streams as listed, each with its decoded data, within one page's
/// [`DecodeBudget`]. An error when the content runs past it: reading or rewriting a page from
/// part of its content would lose the rest.
pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result<Vec<ContentStream>, EditError> {
content_streams_within(doc, page, &mut DecodeBudget::page())
}
/// [`content_streams`], decoded against `budget`, which a walk of the page's forms then goes on
/// spending.
pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Result<Vec<(Object, Vec<u8>)>, EditError> {
pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Result<Vec<ContentStream>, EditError> {
let list: Vec<Object> = match page.get(b"Contents") {
None => Vec::new(),
Some(c) => match &*doc.resolve(c) {
@@ -79,20 +100,26 @@ pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut D
},
};
let mut out = Vec::with_capacity(list.len());
for o in list {
let data = match &*doc.resolve(&o) {
Object::Stream(s) => match budget.decode(s) {
Ok(data) => data,
for entry in list {
let (data, read) = match &*doc.resolve(&entry) {
Object::Stream(s) => match budget.decode_with_damage(doc, s, usize::MAX) {
Ok((data, damaged)) => (data, if damaged { Read::Damaged } else { Read::Whole }),
Err(e) if budget.remaining() == 0 => return Err(EditError::Invalid(format!("the page content is too large to read: {e}"))),
Err(_) => Vec::new(),
Err(_) => (Vec::new(), Read::Unread),
},
_ => Vec::new(),
// Nothing to lose: a null or missing entry draws nothing.
_ => (Vec::new(), Read::Whole),
};
out.push((o, data));
out.push(ContentStream { entry, data, read });
}
Ok(out)
}
/// The page's streams joined into the one stream they are: an operator may span two of them.
pub(crate) fn joined(streams: &[ContentStream]) -> Pieces {
Pieces::join(&streams.iter().map(|s| s.data.as_slice()).collect::<Vec<_>>())
}
/// Rewrite a page's content (its streams joined, see [`Pieces`]): `edit(i)` gives the operators
/// to insert before operator `i` of `ops` (parsed from the joined pieces) and whether to keep it.
/// Everything else is copied byte for byte. A page's streams are one stream in pieces, split
@@ -120,26 +147,108 @@ pub(crate) fn splice(pieces: &Pieces, ops: &[Op], mut edit: impl FnMut(usize) ->
pieces.splice(edits)
}
/// An error when content at `/Contents` index `at` (a stream there, or one inserted there) is drawn
/// after a stream not read whole, damaged partway or not decoding at all: what wasn't read can
/// change how it reads, the graphics state it draws in among it.
pub(crate) fn drawn_before_damage(streams: &[ContentStream], at: usize) -> Result<(), EditError> {
if at > first_damaged(streams) {
return Err(EditError::Invalid(
"this change comes after a content stream that is damaged or can't be decoded, and what wasn't read can change how the rest of the page reads"
.into(),
));
}
Ok(())
}
/// The index of the first stream not read whole, or the number of streams.
fn first_damaged(streams: &[ContentStream]) -> usize {
streams.iter().position(|s| s.read != Read::Whole).unwrap_or(streams.len())
}
/// The index of the first of `ops`, parsed from `pieces` (the [`joined`] `streams`), drawn after a
/// stream not read whole, or `ops.len()`. A `q … Q` still open there may hold what wasn't read,
/// painting in the state it sets. An operator counts by its keyword: one whose operands start
/// before such a stream takes them from around what wasn't read.
pub(crate) fn first_op_after_damage(streams: &[ContentStream], pieces: &Pieces, ops: &[Op]) -> usize {
let damaged = first_damaged(streams);
ops.iter().position(|op| pieces.pieces_of(op).1 > damaged).unwrap_or(ops.len())
}
/// Whether `new` changes a text object still open at the first stream not read whole. The text
/// object goes on in it, and the text it shows there starts where this text ends: a change of this
/// text moves it. A change before the text object doesn't.
fn changes_text_open_into_damage(streams: &[ContentStream], new: &[Vec<u8>]) -> bool {
let upto = first_damaged(streams).saturating_add(1);
if upto > streams.len() {
return false;
}
let before = Pieces::join(&streams.iter().take(upto).map(|s| s.data.as_slice()).collect::<Vec<_>>());
let mut open = None;
for op in before.parse().ops {
match op.op.as_slice() {
b"BT" => open = Some(op.span.start),
b"ET" => open = None,
_ => {}
}
}
let Some(at) = open else { return false };
let after = Pieces::join(&new.iter().take(upto).map(Vec::as_slice).collect::<Vec<_>>());
// Unchanged from the text object's start to the stream not read whole: it ends the same.
!before.data().get(at..).is_some_and(|text| after.data().ends_with(text))
}
/// The page's `/Contents` after a [`splice`]: pieces whose bytes changed become new streams
/// (keeping their dictionaries), the others stay as they were.
pub(crate) fn rewritten_contents(doc: &mut Document, streams: &[(Object, Vec<u8>)], new: Vec<Vec<u8>>) -> Object {
let mut contents: Vec<Object> = Vec::with_capacity(streams.len());
for ((obj, old), data) in streams.iter().zip(new) {
if *old == data {
contents.push(obj.clone());
/// (keeping their dictionaries), the others stay as they were. An error, before anything is
/// added, when a piece that changed is a stream that didn't decode whole, or comes after one.
/// Written from what decoded, a damaged stream would lose everything after the damage, and one
/// that doesn't decode all of it. What follows the damage is unknown, so a later stream may not
/// read as it seems: a string or comment the damage left open runs on into it, and operators the
/// damage hides can change the graphics state it draws in. A stream that doesn't decode hides its
/// operators all the same.
pub(crate) fn rewritten_contents(doc: &mut Document, streams: &[ContentStream], new: Vec<Vec<u8>>) -> Result<Object, EditError> {
// The last stream that changed: when it is drawn before the damage, so are the others.
if let Some(i) = streams.iter().zip(&new).rposition(|(s, data)| s.data != *data) {
drawn_before_damage(streams, i)?;
}
if changes_text_open_into_damage(streams, &new) {
return Err(EditError::Invalid(
"this change is in a text object that goes on in a content stream that is damaged or can't be decoded, whose text starts where this text ends"
.into(),
));
}
for (s, data) in streams.iter().zip(&new) {
if s.data == *data {
continue;
}
let mut dict = match &*doc.resolve(obj) {
match s.read {
Read::Whole => {}
Read::Damaged => {
return Err(EditError::Invalid(
"this change is in a content stream that is damaged partway, and rewriting it would lose what follows the damage".into(),
));
}
Read::Unread => {
return Err(EditError::Invalid("this change is in a content stream that can't be decoded, and rewriting it would lose it".into()));
}
}
}
let mut contents: Vec<Object> = Vec::with_capacity(streams.len());
for (s, data) in streams.iter().zip(new) {
if s.data == data {
contents.push(s.entry.clone());
continue;
}
let mut dict = match &*doc.resolve(&s.entry) {
Object::Stream(s) => s.dict.clone(),
_ => Dict::new(),
};
dict.remove(b"Length");
contents.push(Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &data)))));
}
match <[Object; 1]>::try_from(contents) {
Ok(match <[Object; 1]>::try_from(contents) {
Ok([one]) => one,
Err(many) => Object::Array(many),
}
})
}
pub(crate) fn page_dict(doc: &Document, page: usize) -> Result<pdfcraft_model::Page, EditError> {
@@ -201,7 +310,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) ->
let mut carry = Carry::new();
// The streams are one stream in pieces (§7.8.2), split between any two tokens: parse them
// joined, so an operator whose operands end one piece still has them.
let pieces = Pieces::join(&content_streams(doc, &p.dict)?.into_iter().map(|(_, d)| d).collect::<Vec<_>>());
let pieces = joined(&content_streams(doc, &p.dict)?);
let ops = pieces.parse().ops;
let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default();
let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, actual_text.then_some(&properties))?;
@@ -608,7 +717,7 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine
// do: a comment, an operator's operands or a marked-content sequence (its /ActualText too)
// can run on from one stream into the next. Text still counts as the stream its operator
// starts in, which keeps lines from different streams apart.
let pieces = Pieces::join(&streams.into_iter().map(|(_, data)| data).collect::<Vec<_>>());
let pieces = joined(&streams);
let ops = pieces.parse().ops;
let stream_of = |i: usize| ops.get(i).map_or(0, |op| pieces.pieces_of(op).0);
walk(&mut w, &ops, &res, &mut carry, &stream_of)?;
+10 -10
View File
@@ -4,10 +4,10 @@ use super::fallback::{
source_program_completes, source_show, source_space, source_width, type3_encode, type3_font,
};
use super::interp::{Carry, Origin, TextState, interpret};
use super::lines::{content_streams, page_dict, rewritten_contents, splice, text_lines};
use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines};
use super::{
Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, Pieces, TextLine, TextSession, alignment_frame, group_blocks,
is_centred, parse, rewrite_styled_block,
Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, TextLine, TextSession, alignment_frame, group_blocks, is_centred,
parse, rewrite_styled_block,
};
/// What replacing a line did.
@@ -56,7 +56,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let streams = content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let pieces = joined(&streams);
let ops = pieces.parse().ops;
let first = target.ops.first().copied().filter(|i| *i < ops.len()).ok_or_else(|| EditError::Invalid("the page's content changed".into()))?;
// The line's own font, when it can show every character.
@@ -127,7 +127,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
}
// Rebuild: the line's first operator becomes the replacement, its others go.
let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) });
let contents = rewritten_contents(doc, &streams, new_data);
let contents = rewritten_contents(doc, &streams, new_data)?;
let page_ref = p.obj;
if substituted.is_some() {
res.set(b"Font".to_vec(), Object::Dict(fonts_res));
@@ -834,7 +834,7 @@ pub(crate) fn rewrite_block_from(
}
let upright = dispatch.upright;
let streams = content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let pieces = joined(&streams);
let ops = pieces.parse().ops;
let text = paragraph_text(text.unwrap_or(&b.text));
// The characters to show, for choosing fonts: the breaks are moves, not glyphs.
@@ -970,7 +970,7 @@ pub(crate) fn rewrite_block_from(
let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() };
(insert, !dropped.contains(&i))
});
let contents = rewritten_contents(doc, &streams, new_data);
let contents = rewritten_contents(doc, &streams, new_data)?;
let new_font = !reuse;
if new_font {
res.set(b"Font".to_vec(), Object::Dict(fonts_res));
@@ -1040,7 +1040,7 @@ pub(crate) fn selectable_blocks(
!b.lines
.iter()
.filter_map(|i| lines.get(*i))
.any(|l| streams.get(l.stream).and_then(|(o, _)| o.as_ref()).is_some_and(|r| excluded.contains(&r)))
.any(|l| streams.get(l.stream).and_then(|s| s.entry.as_ref()).is_some_and(|r| excluded.contains(&r)))
})
.collect())
}
@@ -1134,7 +1134,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize
}
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let pieces = joined(&streams);
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?;
let mut patches = HashMap::new();
@@ -1205,7 +1205,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize
return Err(EditError::Invalid("a selected text operation cannot be moved".into()));
}
let data = splice(&pieces, &ops, |i| patches.remove(&i).map_or((Vec::new(), true), |v| (v, false)));
let contents = rewritten_contents(doc, &streams, data);
let contents = rewritten_contents(doc, &streams, data)?;
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
+2 -2
View File
@@ -962,7 +962,7 @@ pub(crate) fn rewrite_styled_block(
};
let (rows, row_ranges, row_lines, row_soft, row_gap) = wrap_styled_rows(&chars, width, measure)?;
let streams = content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let pieces = crate::text::joined(&streams);
let ops = pieces.parse().ops;
// A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and
// glyph widths stay exactly as they were instead of being rebuilt from the text state. A row
@@ -1206,7 +1206,7 @@ pub(crate) fn rewrite_styled_block(
// when the rewrite splits one, and a kept row's operators come later in the stream.
let marker = if marked { None } else { paragraph_marker(block, lines, &ops) };
let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop);
let contents = rewritten_contents(doc, &streams, data);
let contents = rewritten_contents(doc, &streams, data)?;
res.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(p.obj, |d| {
d.set(b"Contents".to_vec(), contents);
+8 -5
View File
@@ -156,13 +156,13 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec<u64> {
let Some(program) = dict(doc, descendant.get(b"FontDescriptor")).and_then(|d| d.get(b"FontFile2").cloned()) else { return Vec::new() };
let program = doc.resolve(&program);
let Object::Stream(stream) = program.as_ref() else { return Vec::new() };
let Ok(bytes) = stream.decoded_within(16 << 20) else { return Vec::new() };
let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(16 << 20) else { return Vec::new() };
let Some(loca) = skrifa::FontRef::from_index(&bytes, 0).ok().and_then(|face| face.loca(None).ok()) else { return Vec::new() };
let outlined = |gid: u32| loca.get_raw(gid as usize).zip(loca.get_raw(gid as usize + 1)).is_some_and(|(start, end)| end > start);
match descendant.get(b"CIDToGIDMap").map(|o| doc.resolve(o)).as_deref() {
None | Some(Object::Name(_)) => bit_set_below_64k((0..loca.len().min(0x1_0000) as u32).filter(|&gid| outlined(gid))),
Some(Object::Stream(map)) => {
let Ok(map) = map.decoded_within(1 << 17) else { return Vec::new() };
let Ok(map) = doc.with_direct_parms(map).decoded_within(1 << 17) else { return Vec::new() };
bit_set_below_64k(
map.as_chunks::<2>()
.0
@@ -189,7 +189,7 @@ fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) {
let Some(object) = descriptor.get(key) else { continue };
let object = doc.resolve(object);
let Object::Stream(stream) = object.as_ref() else { continue };
let Ok(bytes) = stream.decoded_within(16 << 20) else { continue };
let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(16 << 20) else { continue };
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue };
let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em);
if upem == 0.0 {
@@ -691,7 +691,7 @@ impl Metrics {
m.codes = Codes::Two;
if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e))
&& let Object::Stream(s) = &*e
&& let Ok(data) = s.decoded_within(MAX_CMAP_BYTES)
&& let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES)
{
let (spaces, cids) = parse_cmap(&data);
if !spaces.is_empty() {
@@ -1293,7 +1293,10 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
}
match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() {
Some(Object::Stream(s)) => {
if let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) {
// With `/DecodeParms` given by reference, as every stream of the font is read here,
// and as the redaction proof reads the map: the two must get the same text, or a copy
// redaction rightly left counts as a survivor.
if let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES) {
parse_to_unicode(&data, &mut out);
}
}
+115 -18
View File
@@ -104,6 +104,18 @@ fn font(doc: &mut Document, entries: Vec<(&str, Object)>) -> Dict {
d
}
/// `data` behind Flate and a PNG predictor whose `/DecodeParms` is a reference: read without
/// it, every row of four bytes keeps its tag byte.
fn behind_parms_by_reference(doc: &mut Document, data: &[u8]) -> Object {
let parms = doc.add(Object::Dict(Dict::from_iter([(b"Predictor".to_vec(), Object::Int(12)), (b"Columns".to_vec(), Object::Int(4))])));
let mut data = data.to_vec();
data.resize(data.len().next_multiple_of(4), 0);
let rows: Vec<u8> = data.chunks(4).flat_map(|row| std::iter::once(0).chain(row.iter().copied())).collect();
let mut stream = Stream::flate(Dict::new(), &rows);
stream.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms));
Object::Ref(doc.add(Object::Stream(stream)))
}
#[test]
fn embedded_editor_fonts_are_validated_without_installing_or_exporting() {
use pdfcraft_cos::Stream;
@@ -124,34 +136,46 @@ fn embedded_editor_fonts_are_validated_without_installing_or_exporting() {
}
/// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name
/// ("Mono" would mean Courier, 600 units for every glyph).
/// ("Mono" would mean Courier, 600 units for every glyph), the program's `/DecodeParms` given
/// directly or by reference.
#[test]
fn a_font_without_widths_measures_with_its_embedded_program() {
use pdfcraft_cos::Stream;
use skrifa::MetadataProvider as _;
let mut doc = Document::new_empty();
let bytes = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
let face = skrifa::FontRef::new(bytes).unwrap();
let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default());
let upem = f64::from(face.metrics(size, location).units_per_em);
let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem;
let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes)));
let mut descriptor = Dict::new();
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
let f = font(
&mut doc,
vec![
("Subtype", Object::name("TrueType")),
("BaseFont", Object::name("HouseMono")),
("Encoding", Object::name("WinAnsiEncoding")),
("FontDescriptor", Object::Dict(descriptor)),
],
);
let m = Metrics::from_dict(&doc, &f);
for c in ['i', 'l', 'W', ' '] {
assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c));
for by_reference in [false, true] {
let mut doc = Document::new_empty();
let program = if by_reference {
behind_parms_by_reference(&mut doc, bytes)
} else {
Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))))
};
let mut descriptor = Dict::new();
descriptor.set(b"FontFile2".to_vec(), program);
let f = font(
&mut doc,
vec![
("Subtype", Object::name("TrueType")),
("BaseFont", Object::name("HouseMono")),
("Encoding", Object::name("WinAnsiEncoding")),
("FontDescriptor", Object::Dict(descriptor)),
],
);
let m = Metrics::from_dict(&doc, &f);
for c in ['i', 'l', 'W', ' '] {
assert!(
(m.width(u32::from(c)) - advance(c)).abs() < 1e-9,
"{c}, by reference {by_reference}: {} vs {}",
m.width(u32::from(c)),
advance(c)
);
}
assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6, by reference {by_reference}");
}
assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6");
}
#[test]
@@ -342,6 +366,79 @@ fn cmap_streams_decode_within_a_size_cap() {
}
}
#[test]
fn cmap_streams_read_their_parameters_given_by_reference() {
// Both maps behind a predictor whose `/DecodeParms` is a reference: read without it, every
// row keeps its tag byte inside the map's tokens.
let to_unicode = b"1 begincodespacerange <0000> <FFFF> endcodespacerange 1 beginbfrange <0024> <0026> <0041> endbfrange";
let encoding = b"1 begincodespacerange <0000> <FFFF> endcodespacerange 1 begincidrange <0000> <FFFF> 0 endcidrange";
let mut doc = Document::new_empty();
let (tu, enc) = (behind_parms_by_reference(&mut doc, to_unicode), behind_parms_by_reference(&mut doc, encoding));
let mut desc = Dict::new();
desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
let f = font(
&mut doc,
vec![("Subtype", Object::name("Type0")), ("Encoding", enc), ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), ("ToUnicode", tu)],
);
let m = Metrics::from_dict(&doc, &f);
assert_eq!(m.decode(&[0, 0x24]), "A", "ToUnicode");
assert!(matches!(m.codes, Codes::Ranges(_)), "Encoding");
}
/// A Type 0 subset over Inter with `/DW` alone, whose CIDs 1 to 7 draw "A B C D" (ToUnicode
/// gives CIDs 1 and 2), its program and its `/CIDToGIDMap` each behind a predictor whose
/// `/DecodeParms` is a reference, or behind Flate alone.
fn inter_subset(program_by_reference: bool, map_by_reference: bool) -> Metrics {
use skrifa::MetadataProvider as _;
let bytes = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
let face = skrifa::FontRef::new(bytes).unwrap();
let gid = |c: char| u16::try_from(face.charmap().map(c).unwrap().to_u32()).unwrap();
let gids: Vec<u8> = std::iter::once(0).chain("A B C D".chars().map(gid)).flat_map(u16::to_be_bytes).collect();
let stream = |doc: &mut Document, data: &[u8], by_reference: bool| {
if by_reference { behind_parms_by_reference(doc, data) } else { Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), data)))) }
};
let mut doc = Document::new_empty();
let program = stream(&mut doc, bytes, program_by_reference);
let map = stream(&mut doc, &gids, map_by_reference);
let to_unicode = b"1 begincodespacerange <0000> <FFFF> endcodespacerange 2 beginbfchar <0001> <0041> <0002> <0020> endbfchar";
let to_unicode = stream(&mut doc, to_unicode, false);
let mut descriptor = Dict::new();
descriptor.set(b"FontFile2".to_vec(), program);
let mut desc = Dict::new();
desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
desc.set(b"DW".to_vec(), Object::Int(600));
desc.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
desc.set(b"CIDToGIDMap".to_vec(), map);
let f = font(
&mut doc,
vec![
("Subtype", Object::name("Type0")),
("BaseFont", Object::name("ABCDEF+Inter")),
("Encoding", Object::name("Identity-H")),
("DescendantFonts", Object::Array(vec![Object::Dict(desc)])),
("ToUnicode", to_unicode),
],
);
Metrics::from_dict(&doc, &f)
}
/// A subset with `/DW` alone has a glyph for a CID when the program's glyph it maps to has an
/// outline, so the space, Inter's empty glyph, is refused. Its program, or its `/CIDToGIDMap`,
/// behind parameters given by reference says the same: read without them, no program says
/// which glyphs are empty, or the map points the CIDs at other glyphs.
#[test]
fn a_subsets_program_and_cid_map_read_their_parameters_given_by_reference() {
let direct = inter_subset(false, false);
assert!(!direct.outlined_cids.is_empty());
assert_eq!(direct.encode("A").as_deref(), Some(&[0, 1][..]));
assert_eq!(direct.encode(" "), None, "the space's glyph is empty");
for (program, map) in [(true, false), (false, true)] {
let m = inter_subset(program, map);
assert_eq!(m.outlined_cids, direct.outlined_cids, "program by reference: {program}, map: {map}");
assert_eq!(m.encode(" "), None, "program by reference: {program}, map: {map}");
}
}
#[test]
fn composite_fonts_use_to_unicode() {
let mut doc = Document::new_empty();
+4 -1
View File
@@ -22,11 +22,14 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
printed this way went from 4.3 MB to 231 KB (Standard) and 161 KB (Mobile). Not merged yet:
side-by-side tiles, rotated strips, masks. Content arrays use the shared space boundary
policy, so trailing comments, split operands and graphics state keep their meaning after merging.
A page whose content doesn't decode, or is damaged partway, is left as it is: written again from
what decoded, it would lose the rest.
- **Images:** the effective resolution of each image is measured where pages draw it (the CTM
through form XObjects, up to 12 deep; the smallest over all uses).
Page-local `/UserUnit` scaling is included, so large-format pages are measured in physical inches.
If a use can't be
measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a
measured (forms nested deeper or drawn too many times, or content that doesn't decode or is
damaged partway, which may draw an image larger past the damage), no image is resampled. Colour and grayscale images above a
threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and
stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. A
stencil `/Mask` has its own resolution, so the picture changes under it and it stays as it is.
+7 -6
View File
@@ -32,9 +32,10 @@ fn held_page_resources(doc: &Document, page: ObjRef) -> Option<(Dict, ObjRef)> {
}
/// The page content `contents` names, decoded against `budget` and joined. `None` when a stream
/// doesn't decode or the budget runs out: the content is not all known.
/// doesn't decode, is damaged partway or the budget runs out: the content is not all known.
pub(crate) fn content_bytes(doc: &Document, contents: &Object, budget: &mut DecodeBudget) -> Option<Vec<u8>> {
let pieces = budget.decode_contents(doc, contents).into_iter().collect::<Result<Vec<_>, _>>().ok()?;
let reads = budget.decode_contents_with_damage(doc, contents);
let pieces = reads.into_iter().map(|read| read.ok().filter(|(_, damaged)| !damaged).map(|(data, _)| data)).collect::<Option<Vec<_>>>()?;
Some(Pieces::join(&pieces).data().to_vec())
}
@@ -214,7 +215,7 @@ impl Walk<'_> {
self.complete = false;
return;
}
let Ok(data) = self.budget.decode(s) else {
let Ok((data, false)) = self.budget.decode_with_damage(doc, s, usize::MAX) else {
self.complete = false;
return;
};
@@ -245,9 +246,9 @@ fn remaps_device_spaces(doc: &Document, resources: &Dict) -> bool {
/// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses.
///
/// If forms nest too deeply, are drawn too many times, or a page's content doesn't decode within
/// its budget, every image is given `INFINITY` (never resampled), since its largest use may be
/// among those missed.
/// If forms nest too deeply, are drawn too many times, or a page's or form's content doesn't
/// decode within its budget or is damaged partway, every image is given `INFINITY` (never
/// resampled), since its largest use may be among those missed.
pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap<ObjRef, f64> {
walk_pages(doc, pages).0
}
+81
View File
@@ -3157,6 +3157,87 @@ fn images_that_do_not_tile_a_picture_are_left_alone() {
unmerged(&mut off, &Settings { merge_fragments: false, ..Settings::default() });
}
/// zlib data that reads as `readable` and is then damaged, with more of the stream after the
/// damage: a stored block, then a block of the reserved type (RFC 1951 §3.2.3), then `rest`.
fn damaged_flate(readable: &[u8], rest: &[u8]) -> Stream {
let len = u16::try_from(readable.len()).unwrap();
let mut raw = vec![0x78, 0x01, 0x00];
raw.extend_from_slice(&len.to_le_bytes());
raw.extend_from_slice(&(!len).to_le_bytes());
raw.extend_from_slice(readable);
raw.push(0x07);
raw.extend_from_slice(rest);
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
Stream::from_raw(dict, raw)
}
/// A page whose content is damaged partway isn't all known: its strips aren't merged, which would
/// write its content again without what follows the damage.
#[test]
fn strips_on_a_page_damaged_partway_are_left_as_they_are() {
let (mut doc, _) = printed_page(40, |_| false, |_, s| s);
let p = pdfcraft_annot::page_refs(&doc).unwrap()[0];
let bad = damaged_flate(b"0 g\n", b"q 1000 0 0 1 0 0 cm /R0 Do Q");
let damaged = doc.add(Object::Stream(bad.clone()));
let first = doc.get(p).as_dict().unwrap().get(b"Contents").cloned().unwrap();
let contents = Object::Array(vec![first, Object::Ref(damaged)]);
doc.update_dict(p, |d| d.set(b"Contents".to_vec(), contents.clone())).unwrap();
let r = optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!((r.merged_images, r.fragments_merged), (0, 0), "{r:?}");
assert_eq!(doc.get(p).as_dict().unwrap().get(b"Contents"), Some(&contents));
assert_eq!(stream(&doc, damaged), bad);
}
/// An image may be drawn larger past the damage in a page's or a form's content, so no image is
/// resampled for the uses before it.
#[test]
fn content_damaged_partway_leaves_every_image_at_its_resolution() {
for in_form in [false, true] {
let mut doc = Document::new_empty();
let img = image(&mut doc, 720, 720, 1, false, None);
let p = page(&mut doc, &[("Im", img)], "q 72 0 0 72 0 0 cm /Im Do Q");
assert_eq!(effective_resolutions(&doc, &[p])[&img], 720.0);
let mut bad = damaged_flate(b"q 72 0 0 72 0 0 cm /Im Do Q\n", b"q 720 0 0 720 0 0 cm /Im Do Q");
let added = if in_form {
bad.dict.set(b"Subtype".to_vec(), Object::name("Form"));
bad.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
bad.dict.set(b"Resources".to_vec(), Object::Dict(resources(xobjects(&[("Im", img)]))));
let f = doc.add(Object::Stream(bad));
let res = Object::Dict(resources(xobjects(&[("Im", img), ("F", f)])));
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), res)).unwrap();
content(&mut doc, "/F Do")
} else {
doc.add(Object::Stream(bad))
};
let first = doc.get(p).as_dict().unwrap().get(b"Contents").cloned().unwrap();
doc.update_dict(p, |d| d.set(b"Contents".to_vec(), Object::Array(vec![first, Object::Ref(added)]))).unwrap();
// Not measured, or measured as never resampled: the image pass reads only what was measured.
let ppi = effective_resolutions(&doc, &[p]).get(&img).copied().unwrap_or(f64::INFINITY);
assert_eq!(ppi, f64::INFINITY, "in a form: {in_form}");
let r = optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!(r.images_resampled, 0, "{r:?}");
}
}
/// One page damaged partway stops resampling for the whole document: another page drawing the same
/// image at its full resolution, whole, doesn't get it resampled either.
#[test]
fn content_damaged_partway_on_one_page_leaves_the_image_another_page_draws() {
let mut doc = Document::new_empty();
let img = image(&mut doc, 720, 720, 1, false, None);
let damaged = page(&mut doc, &[("Im", img)], "");
let bad = doc.add(Object::Stream(damaged_flate(b"0 g\n", b"q 720 0 0 720 0 0 cm /Im Do Q")));
doc.update_dict(damaged, |d| d.set(b"Contents".to_vec(), Object::Ref(bad))).unwrap();
let whole = page(&mut doc, &[("Im", img)], "q 72 0 0 72 0 0 cm /Im Do Q");
assert_eq!(effective_resolutions(&doc, &[whole])[&img], 720.0);
let ppi = effective_resolutions(&doc, &[damaged, whole]).get(&img).copied().unwrap_or(f64::INFINITY);
assert_eq!(ppi, f64::INFINITY);
let r = optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!(r.images_resampled, 0, "{r:?}");
assert_eq!(doc.get(img).as_dict().and_then(|d| d.int(b"Width")), Some(720));
}
/// `levels` nested forms with their own resources, the innermost drawing `img` over the unit square.
fn nested_forms(doc: &mut Document, img: ObjRef, levels: usize) -> ObjRef {
let mut inner = form(doc, Some(Object::Dict(resources(xobjects(&[("Im", img)])))), "/Im Do");
+3 -2
View File
@@ -499,9 +499,10 @@ pub fn page_as_form(dst: &mut Document, src: &Document, page: usize) -> Result<(
let bbox = rect(b"CropBox").or_else(|| rect(b"MediaBox")).unwrap_or([0.0, 0.0, 612.0, 792.0]);
let (w, h) = (bbox[2] - bbox[0], bbox[3] - bbox[1]);
let rotate = d.get(b"Rotate").and_then(|r| src.resolve(r).as_int()).unwrap_or(0).rem_euclid(360);
// Content: the page's streams, decoded and joined.
// Content: the page's streams, decoded and joined. Of a stream damaged partway, the copy draws
// what decoded before the damage.
let content = match own.get(b"Contents") {
Some(c) => crate::prune::content_bytes(src, c).map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?,
Some(c) => crate::prune::content_bytes(src, c).map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?.0,
None => Vec::new(),
};
let mut copier = Copier::new(src, HashMap::new(), HashMap::new());
+83 -49
View File
@@ -40,12 +40,15 @@ enum Owner {
struct Pruner<'a> {
doc: &'a Document,
used: HashMap<XKey, HashSet<Vec<u8>>>,
/// Dictionaries whose drawn names can't be known for certain (undecodable content, nesting
/// past the depth limit, Type 3 glyphs drawing through them). They are left untouched:
/// pruning must never drop an XObject something still draws.
/// Dictionaries whose drawn names can't be known for certain (undecodable content, Type 3
/// glyphs drawing through them). They are left untouched: pruning must never drop an XObject
/// something still draws.
unsure: HashSet<XKey>,
/// An overlarge glyph-resource graph is not safe to prune at all.
glyph_budget_exhausted: bool,
/// Nor is a file with forms nested past [`MAX_FORM_DEPTH`]: what the forms there draw isn't
/// read, and they may draw it through resources shared with content that was.
too_deep: bool,
glyph_work_left: usize,
glyph_seen: HashSet<ObjRef>,
/// An indirect font's explicit resources have one owner regardless of which page uses it.
@@ -53,6 +56,9 @@ struct Pruner<'a> {
/// Forms already analysed, with the owner of the resources they drew against: drawing one
/// again records nothing new, and re-reading it on every `Do` is exponential in the nesting.
forms_seen: HashSet<(ObjRef, Owner)>,
/// Owners of resources that content not read whole drew against, whose every form was read
/// as drawn ([`Pruner::unread`]): once is enough.
partly_read: HashSet<Owner>,
}
impl Pruner<'_> {
@@ -185,7 +191,7 @@ impl Pruner<'_> {
return;
};
if depth > MAX_FORM_DEPTH {
self.unsure.insert(key);
self.too_deep = true;
return;
}
for op in pdfcraft_content::parse(data).ops {
@@ -194,54 +200,83 @@ impl Pruner<'_> {
}
let Some(name) = op.name(0) else { continue };
self.used.entry(key).or_default().insert(name.to_vec());
let Some(r) = xobjects.get(name).and_then(Object::as_ref) else { continue };
let Object::Stream(s) = &*self.doc.get(r) else { continue };
if s.dict.name(b"Subtype") != Some(b"Form") {
continue;
}
let (inner, inner_owner) = match s.dict.get(b"Resources") {
Some(res) => match res.as_ref() {
Some(rr) => match self.doc.get(rr).as_dict().cloned() {
Some(d) => (d, Owner::Resources(rr)),
None => continue,
},
None => match self.doc.resolve(res).as_dict().cloned() {
// The form carries its resources inline: filter them on the form itself.
Some(d) => (d, Owner::Resources(r)),
None => continue,
},
self.form(&xobjects, name, resources, owner, depth);
}
}
/// For content that can't be read whole (damaged partway, or not decoding at all): what it
/// draws is unknown, so its `/XObject` dictionary is kept whole, and every form there is read
/// as drawn, which also reads every form the part before any damage draws. A form whose
/// resources are shared keeps what it draws from them too.
fn unread(&mut self, resources: &Dict, owner: Owner, depth: u8) {
self.protect_type3_resources(resources, owner);
let Some((xobjects, key)) = self.xobjects(resources, owner) else { return };
self.unsure.insert(key);
if depth > MAX_FORM_DEPTH {
self.too_deep = true;
return;
}
if !self.partly_read.insert(owner) {
return;
}
for (name, _) in xobjects.iter() {
self.form(&xobjects, name, resources, owner, depth);
}
}
/// Record what the XObject `name` of `xobjects` draws when it is a form, drawn from content
/// with `resources`.
fn form(&mut self, xobjects: &Dict, name: &[u8], resources: &Dict, owner: Owner, depth: u8) {
let Some(r) = xobjects.get(name).and_then(Object::as_ref) else { return };
let Object::Stream(s) = &*self.doc.get(r) else { return };
// A `/Subtype` given by reference reads as no form here, as wherever the workspace tells
// forms from images (redaction, edit and Optimize among them): resolving it is a change
// across all of them.
if s.dict.name(b"Subtype") != Some(b"Form") {
return;
}
let (inner, inner_owner) = match s.dict.get(b"Resources") {
Some(res) => match res.as_ref() {
Some(rr) => match self.doc.get(rr).as_dict().cloned() {
Some(d) => (d, Owner::Resources(rr)),
None => return,
},
// A form without its own resources draws against the ones it was invoked with.
None => (resources.clone(), owner),
};
if !self.forms_seen.insert((r, inner_owner)) {
continue;
}
#[cfg(test)]
crate::work(1);
match s.decoded() {
Ok(data) => self.content(&data, &inner, inner_owner, depth + 1),
// What the form draws is unknown: keep everything it could reach.
Err(_) => {
self.protect_type3_resources(&inner, inner_owner);
self.distrust(&inner, inner_owner);
}
}
None => match self.doc.resolve(res).as_dict().cloned() {
// The form carries its resources inline: filter them on the form itself.
Some(d) => (d, Owner::Resources(r)),
None => return,
},
},
// A form without its own resources draws against the ones it was invoked with.
None => (resources.clone(), owner),
};
if !self.forms_seen.insert((r, inner_owner)) {
return;
}
#[cfg(test)]
crate::work(1);
match DecodeBudget::new(usize::MAX, 0).decode_with_damage(self.doc, s, usize::MAX) {
Ok((data, false)) => self.content(&data, &inner, inner_owner, depth + 1),
Ok((_, true)) | Err(_) => self.unread(&inner, inner_owner, depth + 1),
}
}
}
/// The page's content, concatenated (`/Contents` may be one stream or an array) with a space
/// between pieces as the renderer joins them, decoded against one page's [`DecodeBudget`]. An
/// between pieces as the renderer joins them, decoded against one page's [`DecodeBudget`], and
/// whether a stream is damaged partway (its part is then what decoded before the damage). An
/// error when a stream can't be decoded or the budget runs out: the page may draw names that
/// can't be seen.
pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result<Vec<u8>, CosError> {
pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result<(Vec<u8>, bool), CosError> {
let mut out = Vec::new();
for data in DecodeBudget::page().decode_contents(doc, contents) {
out.extend(data?);
let mut damaged = false;
for read in DecodeBudget::page().decode_contents_with_damage(doc, contents) {
let (data, partial) = read?;
damaged |= partial;
out.extend(data);
out.push(b' ');
}
Ok(out)
Ok((out, damaged))
}
/// The resources of a page (as `walk` reports them) with their owner.
@@ -313,10 +348,12 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result<usize, Organiz
used: HashMap::new(),
unsure: HashSet::new(),
glyph_budget_exhausted: false,
too_deep: false,
glyph_work_left: MAX_GLYPH_RESOURCE_WORK,
glyph_seen: HashSet::new(),
glyph_fonts: HashSet::new(),
forms_seen: HashSet::new(),
partly_read: HashSet::new(),
};
for (page, attrs) in &pages {
let Some(res) = attrs.get(b"Resources") else { continue };
@@ -324,16 +361,13 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result<usize, Organiz
let Some(pd) = pruner.doc.get(*page).as_dict().cloned() else { continue };
let Some(contents) = pd.get(b"Contents").cloned() else { continue };
match content_bytes(pruner.doc, &contents) {
Ok(data) if data.is_empty() => {}
Ok(data) => pruner.content(&data, &res_dict, owner, 0),
Err(_) => {
pruner.protect_type3_resources(&res_dict, owner);
pruner.distrust(&res_dict, owner);
}
Ok((data, false)) if data.is_empty() => {}
Ok((data, false)) => pruner.content(&data, &res_dict, owner, 0),
Ok((_, true)) | Err(_) => pruner.unread(&res_dict, owner, 0),
}
}
let Pruner { used, unsure, glyph_budget_exhausted, .. } = pruner;
if glyph_budget_exhausted {
let Pruner { used, unsure, glyph_budget_exhausted, too_deep, .. } = pruner;
if glyph_budget_exhausted || too_deep {
return Ok(0);
}
let mut removed = 0;
+256
View File
@@ -2628,6 +2628,262 @@ fn extract_keeps_images_an_undecodable_page_might_draw() {
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "an image the unreadable page may draw must stay");
}
/// zlib data that reads as `readable` and is then damaged, with more of the stream after the
/// damage: a stored block, then a block of the reserved type (RFC 1951 §3.2.3), then `rest`.
fn damaged_flate(readable: &[u8], rest: &[u8]) -> pdfcraft_cos::Stream {
let len = u16::try_from(readable.len()).unwrap();
let mut raw = vec![0x78, 0x01, 0x00];
raw.extend_from_slice(&len.to_le_bytes());
raw.extend_from_slice(&(!len).to_le_bytes());
raw.extend_from_slice(readable);
raw.push(0x07);
raw.extend_from_slice(rest);
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
pdfcraft_cos::Stream::from_raw(dict, raw)
}
/// A page or form damaged partway may draw images past the damage, which other readers may still
/// read: pruning keeps them, as it does for content that doesn't decode.
#[test]
fn extract_keeps_images_content_damaged_partway_might_draw() {
use pdfcraft_cos::ObjRef;
let mut page = doc_images_with(&raw("/ImA Do"), "");
page.set(ObjRef::new(6, 0), damaged_flate(b"/ImA Do\n", b"/ImB Do"));
let mut form = doc_images_with(&raw("/Fm Do"), "");
let mut fm = damaged_flate(b"/ImA Do\n", b"/ImB Do");
fm.dict.set(b"Subtype".to_vec(), Object::name("Form"));
fm.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
let fm = form.add(fm);
let xobjects: Dict =
[(b"ImA".to_vec(), Object::Ref(ObjRef::new(8, 0))), (b"ImB".to_vec(), Object::Ref(ObjRef::new(9, 0))), (b"Fm".to_vec(), Object::Ref(fm))]
.into_iter()
.collect();
form.update_dict(ObjRef::new(7, 0), |d| d.set(b"XObject".to_vec(), Object::Dict(xobjects))).unwrap();
for (src, what) in [(page, "page"), (form, "form")] {
let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap());
let names = xobject_names(&part, 0);
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "{what}: an image drawn past the damage must stay: {names:?}");
}
}
/// Two pages: the first draws `/Fm` through resources of its own, `/Fm` draws `/Nested` through
/// resources of its own too, and `/Nested` draws `/ImA` through resources it shares with the second
/// page, which draws `/ImB` through them. The three streams are the first page's content, `/Fm`
/// and `/Nested`.
fn doc_shared_form_resources(page: pdfcraft_cos::Stream, fm: pdfcraft_cos::Stream, nested: pdfcraft_cos::Stream) -> Document {
use pdfcraft_cos::ObjRef;
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /XObject << /Fm 7 0 R >> >> >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources 8 0 R >>".into(),
raw(""),
raw("/ImB Do"),
raw(""),
"<< /XObject << /ImA 9 0 R /ImB 10 0 R >> >>".into(),
image(1, 1, 1),
image(1, 1, 2000),
raw(""),
];
let mut doc = open(build(&b, "/Root 1 0 R"));
doc.set(ObjRef::new(5, 0), page);
let nested_res: Dict =
[(b"XObject".to_vec(), Object::Dict([(b"Nested".to_vec(), Object::Ref(ObjRef::new(11, 0)))].into_iter().collect()))].into_iter().collect();
for (num, mut form, resources) in [(7, fm, Object::Dict(nested_res)), (11, nested, Object::Ref(ObjRef::new(8, 0)))] {
form.dict.set(b"Subtype".to_vec(), Object::name("Form"));
form.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
form.dict.set(b"Resources".to_vec(), resources);
doc.set(ObjRef::new(num, 0), form);
}
doc
}
/// Every form the resources of a page or form damaged partway name may be drawn, before the
/// damage or past it, so each is read: one whose resources another page shares keeps what it draws
/// from them. So is each one content that doesn't decode may draw.
#[test]
fn extract_keeps_images_forms_drawn_around_damage_draw_through_shared_resources() {
let whole = |content: &[u8]| pdfcraft_cos::Stream::from_raw(Dict::new(), content.to_vec());
let undecodable = |content: &[u8]| {
let mut s = whole(content);
s.dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
s
};
let cases = [
("page, before the damage", damaged_flate(b"/Fm Do\n", b"0 g"), whole(b"/Nested Do")),
("page, past the damage", damaged_flate(b"0 g\n", b"/Fm Do"), whole(b"/Nested Do")),
("page that doesn't decode", undecodable(b"/Fm Do"), whole(b"/Nested Do")),
("form, before the damage", whole(b"/Fm Do"), damaged_flate(b"/Nested Do\n", b"0 g")),
("form, past the damage", whole(b"/Fm Do"), damaged_flate(b"0 g\n", b"/Nested Do")),
("form that doesn't decode", whole(b"/Fm Do"), undecodable(b"/Nested Do")),
];
for (what, page, fm) in cases {
let part = full_roundtrip(&extract_pages(&doc_shared_form_resources(page, fm, whole(b"/ImA Do")), &[0, 1]).unwrap());
let names = xobject_names(&part, 1);
assert!(names.iter().any(|n| n.as_slice() == b"ImA"), "{what}: {names:?}");
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "{what}: {names:?}");
}
// What nothing draws still goes.
let src = doc_shared_form_resources(whole(b"0 g"), whole(b"/Nested Do"), whole(b"/ImA Do"));
let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap());
assert_eq!(xobject_names(&part, 1), [b"ImB".to_vec()]);
}
/// Forms are read 8 deep (`prune.rs`'s `MAX_FORM_DEPTH`). A damaged form that deep still has
/// every form it names read as drawn: here `/Nested`, which draws through resources another page
/// shares. Read 9 deep, it leaves the file unpruned, so what it draws from them stays.
#[test]
fn a_damaged_form_at_the_depth_limit_has_its_forms_read() {
use pdfcraft_cos::ObjRef;
let whole = |content: &[u8]| pdfcraft_cos::Stream::from_raw(Dict::new(), content.to_vec());
let mut src = doc_shared_form_resources(whole(b"/C Do"), damaged_flate(b"0 g\n", b"/Nested Do"), whole(b"/ImA Do"));
// Seven forms between the page and `/Fm`, each drawing the next as `/C`: `/Fm` is 8 deep.
let mut next = ObjRef::new(7, 0);
for _ in 1..8 {
let mut form = whole(b"/C Do");
form.dict.set(b"Subtype".to_vec(), Object::name("Form"));
form.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
form.dict.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect()));
next = src.add(Object::Stream(form));
}
let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
src.update_dict(ObjRef::new(3, 0), |p| {
p.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect()))
})
.unwrap();
let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap());
let names = xobject_names(&part, 1);
assert!(names.iter().any(|n| n.as_slice() == b"ImA"), "{names:?}");
}
/// Past the depth limit a form isn't read, nor what the forms it draws draw: one of those may draw
/// through resources another page shares, and keep images there that page doesn't draw, so nothing
/// is pruned. Here `/Fm` is 9 deep, whole or damaged partway, and `/Nested`, which it draws, draws
/// `/ImA` through the resources the second page draws `/ImB` through.
#[test]
fn nothing_is_pruned_past_the_depth_limit() {
use pdfcraft_cos::ObjRef;
let whole = |content: &[u8]| pdfcraft_cos::Stream::from_raw(Dict::new(), content.to_vec());
for (what, fm) in [("whole", whole(b"/Nested Do")), ("damaged", damaged_flate(b"0 g\n", b"/Nested Do"))] {
let mut src = doc_shared_form_resources(whole(b"/C Do"), fm, whole(b"/ImA Do"));
// Eight forms between the page and `/Fm`, each drawing the next as `/C`: `/Fm` is 9 deep.
let mut next = ObjRef::new(7, 0);
for _ in 0..8 {
let mut form = whole(b"/C Do");
form.dict.set(b"Subtype".to_vec(), Object::name("Form"));
form.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
form.dict.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect()));
next = src.add(Object::Stream(form));
}
let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
src.update_dict(ObjRef::new(3, 0), |p| {
p.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect()))
})
.unwrap();
let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap());
let names = xobject_names(&part, 1);
assert!(names.iter().any(|n| n.as_slice() == b"ImA"), "{what}: {names:?}");
}
}
/// Down to the depth limit pruning goes on: under a page whole or damaged partway, a form 8 deep,
/// whole or damaged, is read, and the image nothing draws, `/ImA`, still goes from the resources
/// the second page draws `/ImB` through.
#[test]
fn pruning_goes_on_down_to_the_depth_limit() {
use pdfcraft_cos::ObjRef;
let whole = |content: &[u8]| pdfcraft_cos::Stream::from_raw(Dict::new(), content.to_vec());
let cases = [
("whole", whole(b"/C Do"), whole(b"0 g")),
("damaged form", whole(b"/C Do"), damaged_flate(b"0 g\n", b"0 g")),
("damaged page", damaged_flate(b"/C Do\n", b"0 g"), whole(b"0 g")),
];
for (what, page, fm) in cases {
let mut src = doc_shared_form_resources(page, fm, whole(b""));
// `/Fm` names an image, not `/Nested`: nothing is read 9 deep.
let image: Dict = [(b"Img".to_vec(), Object::Ref(ObjRef::new(9, 0)))].into_iter().collect();
let resources = Object::Dict([(b"XObject".to_vec(), Object::Dict(image))].into_iter().collect());
src.update_dict(ObjRef::new(7, 0), |f| f.set(b"Resources".to_vec(), resources)).unwrap();
// Seven forms between the page and `/Fm`, each drawing the next as `/C`: `/Fm` is 8 deep.
let mut next = ObjRef::new(7, 0);
for _ in 1..8 {
let mut form = whole(b"/C Do");
form.dict.set(b"Subtype".to_vec(), Object::name("Form"));
form.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
form.dict.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect()));
next = src.add(Object::Stream(form));
}
let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
src.update_dict(ObjRef::new(3, 0), |p| {
p.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect()))
})
.unwrap();
let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap());
assert_eq!(xobject_names(&part, 1), [b"ImB".to_vec()], "{what}");
}
}
/// A chain of forms nested far past the depth limit, whole or each damaged partway, is read only
/// down to it: the walk's depth stays bounded however long the chain is, and the run ends without
/// an error, having pruned nothing.
#[test]
fn a_long_chain_of_forms_is_read_only_to_the_depth_limit() {
use pdfcraft_cos::ObjRef;
let whole = |content: &[u8]| pdfcraft_cos::Stream::from_raw(Dict::new(), content.to_vec());
let xobjects = |names: Dict| Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect());
for damaged in [false, true] {
let mut doc = doc_shared_form_resources(whole(b"/C Do"), whole(b""), whole(b""));
let mut names = Dict::new();
for _ in 0..100_000 {
let mut form = if damaged { damaged_flate(b"", b"/C Do") } else { whole(b"/C Do") };
form.dict.set(b"Subtype".to_vec(), Object::name("Form"));
form.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
form.dict.set(b"Resources".to_vec(), xobjects(names));
let next = doc.add(Object::Stream(form));
names = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
}
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Resources".to_vec(), xobjects(names))).unwrap();
assert!(matches!(crate::prune::prune_unused_xobjects(&mut doc), Ok(0)), "damaged: {damaged}");
}
}
/// A form whose `/DecodeParms` is given by reference is read through it: the image it draws is
/// kept, not lost to content read with the predictor's tag bytes left in it.
#[test]
fn extract_keeps_images_a_form_with_parameters_by_reference_draws() {
use pdfcraft_cos::ObjRef;
let content = b"q 10 0 0 10 0 0 cm /ImB Do Q";
// PNG Up, one column: each byte a row of its own, tagged 2.
let mut rows = Vec::new();
let mut above = 0u8;
for &b in content {
rows.extend([2, b.wrapping_sub(above)]);
above = b;
}
let mut src = doc_images_with(&raw("/Fm Do"), "");
let parms = src.add(Object::Dict([(b"Predictor".to_vec(), Object::Int(12))].into_iter().collect::<Dict>()));
let mut fm = pdfcraft_cos::Stream::flate(Dict::new(), &rows);
fm.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms));
fm.dict.set(b"Subtype".to_vec(), Object::name("Form"));
fm.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
let fm = src.add(fm);
let xobjects: Dict =
[(b"ImA".to_vec(), Object::Ref(ObjRef::new(8, 0))), (b"ImB".to_vec(), Object::Ref(ObjRef::new(9, 0))), (b"Fm".to_vec(), Object::Ref(fm))]
.into_iter()
.collect();
src.update_dict(ObjRef::new(7, 0), |d| d.set(b"XObject".to_vec(), Object::Dict(xobjects))).unwrap();
let part = full_roundtrip(&extract_pages(&src, &[1]).unwrap());
let names = xobject_names(&part, 0);
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "{names:?}");
assert!(!names.iter().any(|n| n.as_slice() == b"ImA"), "what the part doesn't draw still goes: {names:?}");
}
#[test]
fn extract_keeps_images_type3_glyphs_may_draw() {
// A Type 3 font without its own /Resources draws through the page's: its glyphs could `Do`.
+1 -1
View File
@@ -1000,7 +1000,7 @@ fn xobject(
}
// Strict, like page content: a Form read only in part counts as undecodable.
let data = if scope.budget.enter_form() {
scope.budget.decode_strict(s)
scope.budget.decode_strict(doc, s)
} else {
Err(CosError::Filter(format!("the page draws more than {MAX_PAGE_FORMS} form XObjects")))
};
+7 -2
View File
@@ -290,7 +290,7 @@ pub(crate) fn page_streams_within(
match &*doc.resolve(o) {
// Strict: a tolerant decode keeps what precedes corruption and drops the rest, which
// still draws in viewers that read further; anything not read can't be cleaned.
Object::Stream(s) => data.push(budget.decode_strict(s).map_err(|_| RedactError::Unreadable(index + 1))?),
Object::Stream(s) => data.push(budget.decode_strict(doc, s).map_err(|_| RedactError::Unreadable(index + 1))?),
Object::Null => data.push(Vec::new()),
_ => return Err(RedactError::Unreadable(index + 1)),
}
@@ -820,7 +820,12 @@ fn redact_page_content(
// longer draws so the removed image or form bytes cannot survive in the saved file.
let page_content: Vec<Vec<u8>> = new_list
.iter()
.map(|o| match &*doc.resolve(o) {
.zip(&list)
.zip(&data)
.map(|((now, was), read)| match &*doc.resolve(now) {
// Left as it was: what was read above, with its parameters by reference.
_ if now == was => read.clone(),
// Written above by `Stream::flate`, which leaves no `/DecodeParms`.
Object::Stream(s) => s.decoded().unwrap_or_default(),
_ => Vec::new(),
})
+85
View File
@@ -143,6 +143,91 @@ fn glyphs_under_a_mark_go_and_the_rest_stays_put() {
assert!(!content(&doc, 0).contains("1234"));
}
/// `content` behind a PNG Up predictor whose `/DecodeParms` is an indirect object added to `doc`,
/// in a stream with `dict`'s entries too.
fn predicted(doc: &mut Document, dict: pdfcraft_cos::Dict, content: &[u8]) -> pdfcraft_cos::Stream {
use pdfcraft_cos::{Dict, Object, Stream};
// One column: each byte a row of its own, tagged 2.
let mut rows = Vec::new();
let mut above = 0u8;
for &b in content {
rows.extend([2, b.wrapping_sub(above)]);
above = b;
}
let parms = doc.add(Object::Dict([(b"Predictor".to_vec(), Object::Int(12))].into_iter().collect::<Dict>()));
let mut s = Stream::flate(Dict::new(), &rows);
for (k, v) in dict.iter() {
s.dict.set(k.to_vec(), v.clone());
}
s.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms));
s
}
/// Content whose `/DecodeParms` is given by reference is read through the reference, on the page
/// and in a form: the text under a mark goes. Read with the predictor's tag bytes left in, no glyph
/// was found under the mark, and the redaction left the text in the file and said it was done.
#[test]
fn content_with_parameters_given_by_reference_is_redacted() {
use pdfcraft_cos::{Dict, ObjRef, Object};
let secret = b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET";
let marked = [[5.0, 90.0, 120.0, 115.0]];
let mut page = one_page(b"", "", vec![]);
let s = predicted(&mut page, Dict::new(), secret);
page.set(ObjRef::new(4, 0), s);
// The parameters take object 7 and the form 8.
let mut form = one_page(b"/Fm Do", "/XObject << /Fm 8 0 R >>", vec![]);
let mut dict = Dict::new();
dict.set(b"Subtype".to_vec(), Object::name("Form"));
dict.set(b"BBox".to_vec(), Object::Array([0, 0, 300, 300].into_iter().map(Object::Int).collect()));
dict.set(
b"Resources".to_vec(),
Object::Dict(
[(b"Font".to_vec(), Object::Dict([(b"F1".to_vec(), Object::Ref(ObjRef::new(5, 0)))].into_iter().collect()))].into_iter().collect(),
),
);
let s = predicted(&mut form, dict, secret);
form.set(ObjRef::new(8, 0), s);
for (mut doc, what) in [(page, "page"), (form, "form")] {
assert_eq!(under(&mut doc, 0, &marked), 6, "{what}");
mark(&mut doc, 0, &marked, "");
let r = apply(&mut doc, None).unwrap();
assert_eq!((r.marks, r.glyphs), (1, 6), "{what}");
let mut saved = reopen(&doc);
assert_eq!(under(&mut saved, 0, &marked), 0, "{what}");
// Nor is the text left in a stream the page no longer draws, read with its parameters.
for (r, o) in saved.scan_objects() {
let Object::Stream(s) = &*o else { continue };
let data = pdfcraft_cos::DecodeBudget::new(usize::MAX, 0).decode_with_damage(&saved, s, usize::MAX).map(|(d, _)| d).unwrap_or_default();
assert!(!data.windows(6).any(|w| w == b"SECRET"), "{what}: {r:?}");
}
}
}
/// An image removed under a mark stays in the page's resources while another of its content
/// streams, with `/DecodeParms` given by reference, still draws it. Read without the parameters,
/// that stream seemed not to draw it, and the page lost the image there.
#[test]
fn an_image_drawn_again_in_content_with_parameters_by_reference_stays() {
use pdfcraft_cos::{Dict, ObjRef, Object};
let img = stream("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", [255, 255, 255, 255]);
let mut doc = one_page(b"q 10 0 0 10 10 10 cm /Im1 Do Q", "/XObject << /Im1 7 0 R >>", vec![img]);
let again = predicted(&mut doc, Dict::new(), b"q 10 0 0 10 200 200 cm /Im1 Do Q");
let again = doc.add(Object::Stream(again));
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(again)])))
.unwrap();
mark(&mut doc, 0, &[[5.0, 5.0, 25.0, 25.0]], "");
let r = apply(&mut doc, None).unwrap();
assert_eq!(r.images_removed, 1, "{r:?}");
let doc = reopen(&doc);
assert!(content(&doc, 0).contains("200 200 cm /Im1 Do"), "{}", content(&doc, 0));
let p = pdfcraft_model::pages(&doc).swap_remove(0);
let res = doc.resolve(p.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
// The save numbers objects afresh: the image is told by what it is.
let Some(Object::Stream(image)) = xo.reference(b"Im1").map(|r| (*doc.get(r)).clone()) else { panic!("no /Im1: {xo:?}") };
assert_eq!((image.dict.name(b"Subtype"), image.decoded().ok()), (Some(&b"Image"[..]), Some(vec![255; 4])));
}
/// #2130: a font with no /Widths is drawn with its embedded program's advances. Measured with a
/// guess from its name instead (Courier's 600 units for "Mono"), the end of the line seemed to
/// lie past a mark that covers all of it on screen, and was kept.
+39
View File
@@ -332,6 +332,45 @@ fn a_survivor_in_an_xfa_packet_is_found() {
assert!(!proof.passed(), "{}", proof.to_text());
}
#[test]
fn a_survivor_behind_parameters_given_by_reference_is_found() {
// Object 7 holds the text behind Flate and a PNG predictor whose `/DecodeParms` is object 8,
// a reference. Read without them, its rows keep their tag bytes, which split the text; the
// sweep reads them as redaction does, and reports the copy.
let text = format!("note: {SECRET} xx");
let rows: Vec<u8> = text.as_bytes().chunks(4).flat_map(|row| std::iter::once(0).chain(row.iter().copied())).collect();
let packed = stream("/Filter /FlateDecode /DecodeParms 8 0 R", Stream::flate(Dict::new(), &rows).raw.as_ref());
let parms = b"<< /Predictor 12 /Columns 4 >>".to_vec();
let doc = marked(Spec { extra: vec![packed, parms], ..Spec::default() });
let snap = Snapshot::capture(&doc, None).unwrap();
let proof = snap.prove_saved(doc.bytes(), None, &ProofOptions::default());
let s = surface(&proof, Surface::DecodedStreams);
assert_eq!(s.verdict, Verdict::Survivor, "{}", proof.to_text());
assert!(s.locations.iter().any(|l| l == "object 7 (decoded)"), "{:?}", s.locations);
assert!(!proof.passed(), "{}", proof.to_text());
}
#[test]
fn a_unicode_map_behind_parameters_given_by_reference_is_read_alike() {
// The font's /ToUnicode (object 7) reads codes A..H as "SECRETVA", behind Flate and a PNG
// predictor whose `/DecodeParms` is object 8, a reference. The page draws the codes twice,
// once under the mark. Redaction takes the removed text from that map, and the proof the page
// text it allows for: read by one and not the other, the copy that stays counted as a
// survivor of a sound redaction.
let cmap = b"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapName /Test def\n1 begincodespacerange\n<00> <FF>\nendcodespacerange\n8 beginbfchar\n<41> <0053>\n<42> <0045>\n<43> <0043>\n<44> <0052>\n<45> <0045>\n<46> <0054>\n<47> <0056>\n<48> <0041>\nendbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n";
let mut data = cmap.to_vec();
data.resize(data.len().next_multiple_of(4), b' ');
let rows: Vec<u8> = data.chunks(4).flat_map(|row| std::iter::once(0).chain(row.iter().copied())).collect();
let to_unicode = stream("/Filter /FlateDecode /DecodeParms 8 0 R", Stream::flate(Dict::new(), &rows).raw.as_ref());
let parms = b"<< /Predictor 12 /Columns 4 >>".to_vec();
let content = b"BT /F1 10 Tf 10 200 Td (ABCDEFGH) Tj ET BT /F1 10 Tf 10 100 Td (ABCDEFGH) Tj ET";
let mut doc = marked_content(content, Spec { extra: vec![to_unicode, parms], ..Spec::default() });
doc.update_dict(ObjRef::new(5, 0), |font| font.set(b"ToUnicode".to_vec(), Object::Ref(ObjRef::new(7, 0)))).unwrap();
let (result, proof) = run(&mut doc);
assert!(result.is_ok(), "{result:?}");
assert!(proof.passed(), "{}", proof.to_text());
}
#[test]
fn a_survivor_in_an_unused_object_is_found() {
// Object 7 is reachable from nothing: no catalog entry, no page resource. A reader that
+3 -3
View File
@@ -58,7 +58,7 @@ impl Decoder {
let mut d = Decoder { map: HashMap::new(), width: if composite { 2 } else { 1 }, opaque: false };
let tu = font.get(b"ToUnicode").map(|t| doc.resolve(t));
if let Some(Object::Stream(s)) = tu.as_deref() {
let data = ctx.decode(s)?;
let data = ctx.decode(doc, s)?;
if data.len() > MAX_CMAP {
return Err(());
}
@@ -435,7 +435,7 @@ pub(super) fn page_content(doc: &Document, page: &Dict, ctx: &Ctx, skip_overlay:
for o in &list {
match &*resolve_strict(doc, o)? {
Object::Stream(s) => {
let d = ctx.decode(s)?;
let d = ctx.decode(doc, s)?;
if skip_overlay && ctx.is_overlay(&d) {
continue;
}
@@ -536,7 +536,7 @@ impl Extractor<'_> {
self.stack.push(r);
}
let result = (|| {
let data = self.ctx.decode(s)?;
let data = self.ctx.decode(self.doc, s)?;
let own = s.dict.get(b"Resources").and_then(|r| self.doc.resolve(r).as_dict().cloned());
self.scope(&data, own.as_ref().unwrap_or(parent), ctm, depth, placed)
})();
+4 -4
View File
@@ -98,7 +98,7 @@ impl Geo<'_> {
// own placement: where it lands comes from each `/Rect`, so the first
// placement tells nothing about the others. Repeated decodes spend the
// same bounded budget as everything else.
let Ok(data) = self.ctx.decode(&st) else {
let Ok(data) = self.ctx.decode(self.doc, &st) else {
self.unverifiable("an annotation appearance can't be read in full");
continue;
};
@@ -278,7 +278,7 @@ impl Geo<'_> {
{
return;
}
let Ok(data) = self.ctx.decode(s) else {
let Ok(data) = self.ctx.decode(self.doc, s) else {
self.unverifiable("a form under the region can't be decoded in full");
return;
};
@@ -336,7 +336,7 @@ impl Geo<'_> {
if depth > MAX_DEPTH {
return Err(());
}
let data = self.ctx.decode(s)?;
let data = self.ctx.decode(self.doc, s)?;
let own = s.dict.get(b"Resources").and_then(|r| self.doc.resolve(r).as_dict().cloned());
let res = own.as_ref().unwrap_or(parent);
let xobjects = res_dict(self.doc, res, b"XObject");
@@ -464,7 +464,7 @@ pub(super) fn image_cleared(doc: &Document, ctx: &Ctx, s: &Stream, ctm: &Matrix,
if !matches!(bpc, 1 | 2 | 4 | 8 | 16) || ncomp == 0 || ncomp > 32 {
return Err(());
}
let data = ctx.decode(s)?;
let data = ctx.decode(doc, s)?;
let bits = ncomp.checked_mul(bpc).ok_or(())?;
let row = w.checked_mul(bits).ok_or(())?.div_ceil(8);
if data.len() < row.checked_mul(h).ok_or(())? {
+5 -5
View File
@@ -252,7 +252,7 @@ pub struct ProofOptions {
#[cfg(test)]
pub(crate) mod tests_support {
use pdfcraft_content::parse;
use pdfcraft_cos::{Dict, Object, Stream};
use pdfcraft_cos::{Dict, Document, Object, Stream};
use super::extract::{carries_glyphs, parse_cmap};
use super::limits::{MAX_STREAM, MAX_TOTAL_DECODED, MAX_WORK, MIN_STRUCTURED_NEEDLE};
@@ -308,9 +308,9 @@ pub(crate) mod tests_support {
pub fn decode_budget_runs_out() -> (bool, bool) {
let ctx = Ctx::new(Default::default());
let s = Stream::from_raw(Dict::new(), vec![0u8; 100]);
let before = ctx.decode(&s).is_ok();
let before = ctx.decode(&Document::new_empty(), &s).is_ok();
let spent = Ctx::with_decode_left(50);
(before, spent.decode(&s).is_err())
(before, spent.decode(&Document::new_empty(), &s).is_err())
}
/// Does a decode that fails past the one-stream cap pay for the attempt, so the same bomb
@@ -322,9 +322,9 @@ pub(crate) mod tests_support {
drop(big);
let small = Stream::from_raw(Dict::new(), vec![0u8; 100]);
let ctx = Ctx::with_decode_left(u64::try_from(MAX_STREAM + 50).unwrap_or(u64::MAX));
let refused = ctx.decode(&bomb).is_err();
let refused = ctx.decode(&Document::new_empty(), &bomb).is_err();
let charged = ctx.decoded.get() == MAX_TOTAL_DECODED - 50;
let gone = ctx.decode(&small).is_err();
let gone = ctx.decode(&Document::new_empty(), &small).is_err();
(refused, charged, gone)
}
+1 -1
View File
@@ -200,7 +200,7 @@ impl Snapshot {
for r in created {
let obj = doc.try_get(r.num).map_err(|_| RedactError::OverlayUnreadable)?;
let Object::Stream(s) = &*obj else { continue };
let data = ctx.decode(s).map_err(|_| RedactError::OverlayUnreadable)?;
let data = ctx.decode(doc, s).map_err(|_| RedactError::OverlayUnreadable)?;
digests.insert(sha256(&[data]));
}
self.overlay = digests;
+4 -4
View File
@@ -495,7 +495,7 @@ fn sweep_objects(needles: &[Needle], doc: &Document, depth: usize, tag: &str, ac
for n in acc.bytes(Surface::DecodedStreams, needles, &s.raw, MIN_STRUCTURED_NEEDLE) {
acc.survivor(Surface::DecodedStreams, format!("{loc} (raw)"), Some(n));
}
match acc.ctx.decode(s) {
match acc.ctx.decode(doc, s) {
Ok(d) => {
let loc = format!("{loc} (decoded)");
for (n, c) in acc.counts(Surface::DecodedStreams, needles, &d, MIN_STRUCTURED_NEEDLE) {
@@ -508,7 +508,7 @@ fn sweep_objects(needles: &[Needle], doc: &Document, depth: usize, tag: &str, ac
Err(()) => acc.raw_only(Surface::DecodedStreams),
}
} else {
match acc.ctx.decode(s) {
match acc.ctx.decode(doc, s) {
// The boxes and labels this run drew are not text to look for.
Ok(d) if acc.ctx.is_overlay(&d) => overlay_raw.push(s.raw.to_vec()),
Ok(d) => {
@@ -578,7 +578,7 @@ fn sweep_attachments(needles: &[Needle], doc: &Document, embedded: &BTreeSet<u32
acc.problem(Surface::EmbeddedFiles, format!("{loc} is not a stream"));
continue;
};
let payload = match acc.ctx.decode(s) {
let payload = match acc.ctx.decode(doc, s) {
Ok(p) => p,
Err(()) => {
acc.problem(Surface::EmbeddedFiles, format!("{loc} can't be decoded in full"));
@@ -742,7 +742,7 @@ pub(super) fn sweep_xmp(needles: &[Needle], doc: &Document, tag: &str, acc: &mut
acc.problem(Surface::Xmp, format!("{loc} is not a stream"));
return;
};
let Ok(data) = acc.ctx.decode(s) else {
let Ok(data) = acc.ctx.decode(doc, s) else {
acc.problem(Surface::Xmp, format!("{loc} can't be decoded in full"));
return;
};
+3 -2
View File
@@ -33,11 +33,12 @@ impl Ctx {
/// attempt is charged to the cumulative allowance, failures included: a decode that fails
/// past the one-stream cap pays the cap, and one that fails mid-way pays what it decoded
/// before the error — the work of finding out is not work a repeated reference gets for free.
pub(super) fn decode(&self, s: &Stream) -> Result<Vec<u8>, ()> {
/// `/DecodeParms` given by reference are read in `doc`, as redaction itself reads them.
pub(super) fn decode(&self, doc: &Document, s: &Stream) -> Result<Vec<u8>, ()> {
let left = MAX_TOTAL_DECODED.saturating_sub(self.decoded.get());
let cap = usize::try_from(left).unwrap_or(usize::MAX).min(MAX_STREAM);
let mut budget = DecodeBudget::new(cap, usize::MAX);
let result = budget.decode_strict(s);
let result = budget.decode_strict(doc, s);
// The attempt's own accounting covers a success (what it decoded), a stream cut short
// by corruption (what it decoded first) and one refused past the cap (the cap): charge
// it through, whichever way the attempt went.