mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 20:04:37 +00:00
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:
Generated
-1
@@ -4265,7 +4265,6 @@ version = "0.18.1"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-filters",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"thiserror 2.0.21",
|
||||
|
||||
@@ -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
@@ -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
@@ -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' ');
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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)?;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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")))
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})();
|
||||
|
||||
@@ -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(())? {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user