mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 22:41:47 -06:00
Merge the fixes to #5461 into this PR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Generated
+1
@@ -4395,6 +4395,7 @@ dependencies = [
|
||||
"pdfcraft-create",
|
||||
"pdfcraft-edit",
|
||||
"pdfcraft-export",
|
||||
"pdfcraft-filters",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-forms",
|
||||
"pdfcraft-geom",
|
||||
|
||||
@@ -91,5 +91,7 @@ recognisable in a diff and byte-stable when re-serialized.
|
||||
- Structural validation against the Arlington model (`core.arlington-validation`), which will live
|
||||
in the reserved `arlington` crate.
|
||||
- Image codecs. `pdfcraft-filters` carries the non-image filters and CCITT fax and JBIG2 decoding
|
||||
(`Document::decoded_image`, a JBIG2 image's `/JBIG2Globals` within `MAX_JBIG2_GLOBALS`, 32 MiB);
|
||||
(`Document::decoded_image`, a JBIG2 image's `/JBIG2Globals` within `MAX_JBIG2_GLOBALS`, 32 MiB,
|
||||
their filters undone once a document for the images that share them, up to 64 MiB kept, while
|
||||
their segments are decoded for every image);
|
||||
DCT and JPX are still decoded through the bootstrap renderer (`core.image-filters`, M2.6).
|
||||
|
||||
@@ -49,6 +49,9 @@ pub struct Document {
|
||||
repair_log: Arc<Vec<String>>,
|
||||
cache: Arc<Mutex<HashMap<u32, Arc<Object>>>>,
|
||||
objstms: Arc<Mutex<HashMap<u32, Arc<ObjStm>>>>,
|
||||
/// What `/JBIG2Globals` streams of the file decode to, by object number: the images of a scan
|
||||
/// share them, and each would otherwise decode them again (see `Document::jbig2_globals`).
|
||||
jbig2_globals: Arc<Mutex<HashMap<u32, Arc<Vec<u8>>>>>,
|
||||
stream_limit: usize,
|
||||
overlay: BTreeMap<u32, Slot>,
|
||||
next_num: u32,
|
||||
|
||||
@@ -5,6 +5,10 @@ use super::{
|
||||
Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared,
|
||||
};
|
||||
|
||||
/// The most decoded `/JBIG2Globals` a document keeps for its images to share (see
|
||||
/// `Document::jbig2_globals`): two streams at [`MAX_JBIG2_GLOBALS`].
|
||||
const JBIG2_GLOBALS_HELD: usize = 2 * MAX_JBIG2_GLOBALS;
|
||||
|
||||
impl Document {
|
||||
pub fn bytes(&self) -> &Arc<Vec<u8>> {
|
||||
&self.data
|
||||
@@ -301,13 +305,13 @@ impl Document {
|
||||
};
|
||||
// `/Width` and `/Height` are read as given directly, as every caller reads them first: one
|
||||
// given by reference has the image refused there, before it gets here.
|
||||
let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok()).unwrap_or(0);
|
||||
let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok());
|
||||
if let Some(((pdfcraft_filters::Filter::Jbig2, _), before)) = chain.split_last() {
|
||||
let Some(columns) = s.dict.int(b"Width").and_then(|w| u32::try_from(w).ok()) else {
|
||||
return Err(CosError::Filter("a JBIG2 image's /Width isn't a size".into()));
|
||||
let (Some(columns), Some(height)) = (s.dict.int(b"Width").and_then(|w| u32::try_from(w).ok()), height) else {
|
||||
return Err(CosError::Filter("a JBIG2 image's /Width or /Height isn't a size".into()));
|
||||
};
|
||||
let globals = self.jbig2_globals(s)?;
|
||||
return pdfcraft_filters::decode_jbig2(&undo(before)?, globals.as_deref(), columns, height, max).map_err(filter);
|
||||
return pdfcraft_filters::decode_jbig2(&undo(before)?, globals.as_deref().map(Vec::as_slice), columns, height, max).map_err(filter);
|
||||
}
|
||||
let Some(p) = self.fax_params(s) else { return s.decoded_within(max) };
|
||||
// As many rows as the image has: a /Rows below /Height would cut the picture short, and
|
||||
@@ -317,24 +321,53 @@ impl Document {
|
||||
return Err(CosError::Filter("a fax image's /Columns aren't its /Width".into()));
|
||||
}
|
||||
let stride = usize::try_from(p.columns).unwrap_or(usize::MAX).div_ceil(8).max(1);
|
||||
let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height);
|
||||
let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height.unwrap_or(0));
|
||||
let data = undo(chain.split_last().map_or(&[][..], |(_, before)| before))?;
|
||||
pdfcraft_filters::decode_ccitt(&data, &p, rows).map_err(filter)
|
||||
}
|
||||
|
||||
/// What the `/JBIG2Globals` stream of JBIG2 image `s` (in its last filter's `/DecodeParms`)
|
||||
/// decodes to, within [`MAX_JBIG2_GLOBALS`]; `None` without one. Globals that can't be read
|
||||
/// are an error, rather than the image decoded without them.
|
||||
fn jbig2_globals(&self, s: &Stream) -> Result<Option<Vec<u8>>, CosError> {
|
||||
let globals = self.last_filter_parms(s).get(b"JBIG2Globals").map(|g| self.resolve(g));
|
||||
let Some(Object::Stream(g)) = globals.as_deref() else { return Ok(None) };
|
||||
self.with_direct_parms(g).decoded_within(MAX_JBIG2_GLOBALS).map(Some).map_err(|e| {
|
||||
/// decodes to, within [`MAX_JBIG2_GLOBALS`]; `None` without one (a reference to no object is
|
||||
/// one to null, ISO 32000-2 §7.3.10, which is an absent entry, §7.3.7). The image is then read
|
||||
/// without them, as the renderer draws it, so an image that needed them mostly fails to decode
|
||||
/// and can decode to other pixels. A stream that is there and can't be read is an error, rather
|
||||
/// than the image decoded without it.
|
||||
///
|
||||
/// The images of a scan share one globals stream, so a stream of the file, not edited since
|
||||
/// it was read (the same in every clone) and with its `/DecodeParms` direct (by reference they
|
||||
/// are another object, which can change while this one doesn't), has its filters undone once a
|
||||
/// document and is kept, up to [`JBIG2_GLOBALS_HELD`] in all; others are read each time. That
|
||||
/// saves the filters only: the segments it holds are decoded again for every image.
|
||||
fn jbig2_globals(&self, s: &Stream) -> Result<Option<Arc<Vec<u8>>>, CosError> {
|
||||
let Some(entry) = self.last_filter_parms(s).get(b"JBIG2Globals").cloned() else { return Ok(None) };
|
||||
let held = match entry {
|
||||
Object::Ref(r) if !self.overlay.contains_key(&r.num) => Some(r.num),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(hit) = held.and_then(|num| self.jbig2_globals.lock().ok()?.get(&num).cloned()) {
|
||||
return Ok(Some(hit));
|
||||
}
|
||||
let Object::Stream(g) = &*self.resolve(&entry) else { return Ok(None) };
|
||||
let readable = self.with_direct_parms(g);
|
||||
let held = held.filter(|_| matches!(readable, Cow::Borrowed(_)));
|
||||
let mut data = readable.decoded_within(MAX_JBIG2_GLOBALS).map_err(|e| {
|
||||
let detail = match e {
|
||||
CosError::Filter(detail) => detail,
|
||||
e => e.to_string(),
|
||||
};
|
||||
CosError::Filter(format!("the JBIG2 globals can't be read: {detail}"))
|
||||
})
|
||||
})?;
|
||||
// What is kept counts by its length, so that's all it may hold on to.
|
||||
data.shrink_to_fit();
|
||||
let data = Arc::new(data);
|
||||
// A poisoned lock only means nothing is kept.
|
||||
if let (Some(num), Ok(mut kept)) = (held, self.jbig2_globals.lock()) {
|
||||
let total = kept.values().map(|g| g.len()).fold(data.len(), usize::saturating_add);
|
||||
if total <= JBIG2_GLOBALS_HELD {
|
||||
kept.insert(num, data.clone());
|
||||
}
|
||||
}
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
pub fn try_get(&self, num: u32) -> Result<Arc<Object>, CosError> {
|
||||
|
||||
@@ -19,6 +19,7 @@ impl Document {
|
||||
repair_log: Arc::new(Vec::new()),
|
||||
cache: Arc::default(),
|
||||
objstms: Arc::default(),
|
||||
jbig2_globals: Arc::default(),
|
||||
stream_limit: crate::object::MAX_DECODED,
|
||||
overlay: BTreeMap::new(),
|
||||
next_num: 1,
|
||||
@@ -102,6 +103,7 @@ impl Document {
|
||||
repair_log: Arc::new(Vec::new()),
|
||||
cache: Arc::default(),
|
||||
objstms: Arc::default(),
|
||||
jbig2_globals: Arc::default(),
|
||||
stream_limit: stream_limit.min(crate::object::MAX_DECODED),
|
||||
overlay: BTreeMap::new(),
|
||||
next_num: 1,
|
||||
|
||||
@@ -173,6 +173,159 @@ fn fax_images_decode_with_their_parameters() {
|
||||
assert!(behind(&[Object::Int(5), fax], &[null, own], coded.clone()).is_err(), "a /Filter entry that isn't a name");
|
||||
}
|
||||
|
||||
/// Images that share a `/JBIG2Globals` stream of the file decode it once a document: it is kept
|
||||
/// for the next, in every clone. A globals stream edited since is read afresh (the kept one is the
|
||||
/// file's), and objects added in memory aren't kept.
|
||||
#[test]
|
||||
fn shared_jbig2_globals_are_read_once() {
|
||||
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
|
||||
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap();
|
||||
let mut doc = Document::new_empty();
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1];
|
||||
let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&skipped))));
|
||||
let image = |doc: &mut Document| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Width".to_vec(), Object::Int(10));
|
||||
d.set(b"Height".to_vec(), Object::Int(3));
|
||||
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
|
||||
let mut parms = Dict::new();
|
||||
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
|
||||
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
|
||||
doc.add(Object::Stream(Stream::from_raw(d, coded.clone())))
|
||||
};
|
||||
let (a, b) = (image(&mut doc), image(&mut doc));
|
||||
let stream = |doc: &Document, r: ObjRef| match &*doc.get(r) {
|
||||
Object::Stream(s) => s.clone(),
|
||||
_ => panic!("not a stream"),
|
||||
};
|
||||
assert_eq!(doc.decoded_image(&stream(&doc, a)).unwrap(), bits);
|
||||
assert!(doc.jbig2_globals.lock().unwrap().is_empty(), "an object added in memory isn't kept");
|
||||
|
||||
// The same objects read from a file, the catalog holding on to the images (a full save may
|
||||
// number them anew).
|
||||
let root = doc.root().unwrap();
|
||||
doc.update_dict(root, |c| c.set(b"Images".to_vec(), Object::Array(vec![Object::Ref(a), Object::Ref(b)]))).unwrap();
|
||||
let file = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap();
|
||||
let mut doc = Document::open(Arc::new(file)).unwrap();
|
||||
let images = doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Images").unwrap().as_array().unwrap().clone();
|
||||
let (a, b) = (images[0].as_ref().unwrap(), images[1].as_ref().unwrap());
|
||||
let parms = stream(&doc, a).dict.get(b"DecodeParms").unwrap().as_dict().unwrap().clone();
|
||||
let globals = parms.reference(b"JBIG2Globals").unwrap();
|
||||
let clone = doc.clone();
|
||||
assert_eq!(doc.decoded_image(&stream(&doc, a)).unwrap(), bits);
|
||||
assert_eq!(doc.jbig2_globals.lock().unwrap().len(), 1, "kept");
|
||||
let kept = doc.jbig2_globals.lock().unwrap()[&globals.num].clone();
|
||||
assert_eq!(clone.decoded_image(&stream(&clone, b)).unwrap(), bits, "the clone's other image");
|
||||
let shared = clone.jbig2_globals.lock().unwrap()[&globals.num].clone();
|
||||
assert!(Arc::ptr_eq(&kept, &shared), "the clone uses the one kept, not a second decode");
|
||||
doc.set(globals, Object::Stream(Stream::from_raw(Dict::new(), b"not JBIG2".to_vec())));
|
||||
assert!(doc.decoded_image(&stream(&doc, b)).is_err(), "the edited globals are read afresh");
|
||||
assert_eq!(clone.decoded_image(&stream(&clone, b)).unwrap(), bits, "the clone keeps the file's");
|
||||
}
|
||||
|
||||
/// A JBIG2 image's size is read as given directly, as every caller reads it first, and an image
|
||||
/// with neither is an error, not an image of no rows.
|
||||
#[test]
|
||||
fn a_jbig2_image_needs_its_size_given_directly() {
|
||||
let coded = pdfcraft_filters::encode_jbig2_generic(&[0xFF, 0xFF], 8, 2).unwrap();
|
||||
let mut doc = Document::new_empty();
|
||||
let size = doc.add(Object::Int(2));
|
||||
let image = |width: Object, height: Object| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Width".to_vec(), width);
|
||||
d.set(b"Height".to_vec(), height);
|
||||
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
|
||||
doc.decoded_image(&Stream::from_raw(d, coded.clone()))
|
||||
};
|
||||
assert!(image(Object::Int(8), Object::Int(2)).is_ok());
|
||||
assert!(image(Object::Ref(size), Object::Int(2)).is_err(), "/Width by reference");
|
||||
assert!(image(Object::Int(8), Object::Ref(size)).is_err(), "/Height by reference");
|
||||
}
|
||||
|
||||
/// A document that kept globals of 64 MiB keeps no more, and the images past that still read:
|
||||
/// three streams of 22 MiB, shared by three images, are two kept and one decoded each time.
|
||||
#[test]
|
||||
fn kept_jbig2_globals_are_bounded_in_total() {
|
||||
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
|
||||
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap();
|
||||
let length = 22 * 1024 * 1024;
|
||||
let mut skipped = vec![0, 0, 0, 0, 0x3E, 0, 0];
|
||||
skipped.extend_from_slice(&u32::try_from(4 + length).unwrap().to_be_bytes());
|
||||
skipped.extend_from_slice(&[0, 0, 0, 1]);
|
||||
skipped.resize(skipped.len() + length, 0);
|
||||
let mut doc = Document::new_empty();
|
||||
let mut images = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&skipped))));
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Width".to_vec(), Object::Int(10));
|
||||
d.set(b"Height".to_vec(), Object::Int(3));
|
||||
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
|
||||
let mut parms = Dict::new();
|
||||
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
|
||||
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
|
||||
images.push(Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, coded.clone())))));
|
||||
}
|
||||
let root = doc.root().unwrap();
|
||||
doc.update_dict(root, |c| c.set(b"Images".to_vec(), Object::Array(images))).unwrap();
|
||||
let doc = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap();
|
||||
let images = doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Images").unwrap().as_array().unwrap().clone();
|
||||
for image in &images {
|
||||
let Object::Stream(s) = &*doc.get(image.as_ref().unwrap()) else { panic!("not a stream") };
|
||||
assert_eq!(doc.decoded_image(s).unwrap(), bits, "every image reads");
|
||||
}
|
||||
let kept = doc.jbig2_globals.lock().unwrap();
|
||||
assert_eq!(kept.len(), 2, "two fit in 64 MiB, a third doesn't");
|
||||
assert!(kept.values().all(|g| g.capacity() == g.len()), "what is kept holds no more than it counts");
|
||||
}
|
||||
|
||||
/// Globals whose `/DecodeParms` are given by reference aren't kept: that's another object, so
|
||||
/// editing it changes what they decode to while the stream itself, the key kept by, stays the same.
|
||||
#[test]
|
||||
fn jbig2_globals_with_parameters_by_reference_are_read_afresh() {
|
||||
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
|
||||
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap();
|
||||
let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1];
|
||||
let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns: 15, early_change: 1 };
|
||||
let mut doc = Document::new_empty();
|
||||
let mut parms = Dict::new();
|
||||
parms.set(b"Predictor".to_vec(), Object::Int(12));
|
||||
parms.set(b"Columns".to_vec(), Object::Int(15));
|
||||
let parms = doc.add(Object::Dict(parms));
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
d.set(b"DecodeParms".to_vec(), Object::Ref(parms));
|
||||
let predicted = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &skipped).unwrap();
|
||||
let globals = doc.add(Object::Stream(Stream::from_raw(d, predicted)));
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Width".to_vec(), Object::Int(10));
|
||||
d.set(b"Height".to_vec(), Object::Int(3));
|
||||
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
|
||||
let mut image_parms = Dict::new();
|
||||
image_parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
|
||||
d.set(b"DecodeParms".to_vec(), Object::Dict(image_parms));
|
||||
let image = doc.add(Object::Stream(Stream::from_raw(d, coded)));
|
||||
let root = doc.root().unwrap();
|
||||
doc.update_dict(root, |c| c.set(b"Image".to_vec(), Object::Ref(image))).unwrap();
|
||||
let mut doc = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap();
|
||||
let image = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Image").unwrap();
|
||||
let stream = |doc: &Document| match &*doc.get(image) {
|
||||
Object::Stream(s) => s.clone(),
|
||||
_ => panic!("not a stream"),
|
||||
};
|
||||
assert_eq!(doc.decoded_image(&stream(&doc)).unwrap(), bits);
|
||||
assert!(doc.jbig2_globals.lock().unwrap().is_empty(), "not kept");
|
||||
let globals = stream(&doc).dict.get(b"DecodeParms").unwrap().as_dict().unwrap().reference(b"JBIG2Globals").unwrap();
|
||||
let Object::Stream(g) = &*doc.get(globals) else { panic!("not a stream") };
|
||||
let parms = g.dict.reference(b"DecodeParms").unwrap();
|
||||
doc.set(parms, Object::Dict(Dict::new()));
|
||||
assert!(doc.decoded_image(&stream(&doc)).is_err(), "without the predictor they read otherwise");
|
||||
}
|
||||
|
||||
/// A JBIG2 image reads through its decoder, behind other filters too, with the `/JBIG2Globals` of
|
||||
/// its own `/DecodeParms` (a dictionary or its place in an array, /Crypt counting, by reference
|
||||
/// too): globals that can't be read are an error, and ones at another filter's place aren't its
|
||||
|
||||
@@ -58,3 +58,5 @@ getrandom = { version = "0.4", features = ["wasm_js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-testkit = { workspace = true }
|
||||
# JBIG2 fixtures (`encode_jbig2_generic`).
|
||||
pdfcraft-filters = { workspace = true }
|
||||
|
||||
@@ -8079,6 +8079,105 @@ fn copy_image_finds_a_picture_inside_form_artwork_under_text() {
|
||||
assert!(d.page_image_copy_file(0, 1).is_err());
|
||||
}
|
||||
|
||||
/// Copy Image reads a JBIG2 image's `/JBIG2Globals`: here they hold the page information, and the
|
||||
/// image's own stream only an extension segment decoders skip, so it has a picture only through
|
||||
/// them. Its default pixel is black.
|
||||
#[test]
|
||||
fn copy_image_reads_the_globals_of_a_jbig2_picture() {
|
||||
let mut globals = vec![0, 0, 0, 0, 48, 0, 1, 0, 0, 0, 19];
|
||||
globals.extend_from_slice(&16u32.to_be_bytes());
|
||||
globals.extend_from_slice(&2u32.to_be_bytes());
|
||||
globals.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0]); // resolution, flags, striping
|
||||
let image = [0, 0, 0, 1, 62, 0, 1, 0, 0, 0, 0];
|
||||
let stream = |dict: &str, data: &[u8]| {
|
||||
let mut o = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes();
|
||||
o.extend_from_slice(data);
|
||||
o.extend_from_slice(b"\nendstream");
|
||||
o
|
||||
};
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >>".to_vec(),
|
||||
stream("", b"q 160 0 0 20 20 100 cm /Im1 Do Q"),
|
||||
stream(
|
||||
"/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 6 0 R >>",
|
||||
&image,
|
||||
),
|
||||
stream("", &globals),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
let mut s = Session::new();
|
||||
let id = s.open("scan.pdf", None, Arc::new(out), None).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
let (w, h, rgba) = d.page_image_rgba(0, 0).unwrap();
|
||||
assert_eq!((w, h), (16, 2));
|
||||
assert!(rgba.chunks(4).all(|p| p == [0, 0, 0, 255]), "the page's default pixel, from the globals");
|
||||
}
|
||||
|
||||
/// Copy Image takes a JBIG2 picture as its pixels and as a PNG file, behind another filter
|
||||
/// (ASCIIHexDecode) too: Export's decoding, through `Document::decoded_image`.
|
||||
#[test]
|
||||
fn copy_image_copies_a_jbig2_picture_behind_another_filter() {
|
||||
// 16 × 2: the left half of the top row black, the rest white.
|
||||
let bits = [0x00, 0xFF, 0xFF, 0xFF];
|
||||
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap();
|
||||
let hex: Vec<u8> = coded.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect();
|
||||
let stream = |dict: &str, data: &[u8]| {
|
||||
let mut o = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes();
|
||||
o.extend_from_slice(data);
|
||||
o.extend_from_slice(b"\nendstream");
|
||||
o
|
||||
};
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >>".to_vec(),
|
||||
stream("", b"q 160 0 0 20 20 100 cm /Im1 Do Q"),
|
||||
stream(
|
||||
"/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter [/ASCIIHexDecode /JBIG2Decode]",
|
||||
&hex,
|
||||
),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
let mut s = Session::new();
|
||||
let id = s.open("scan.pdf", None, Arc::new(out), None).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
assert_eq!(d.page_image_at(0, [30.0, 115.0]), Some(0));
|
||||
assert_eq!(d.page_image_copy_file(0, 0).unwrap().0, "png");
|
||||
let (w, h, rgba) = d.page_image_rgba(0, 0).unwrap();
|
||||
assert_eq!((w, h), (16, 2));
|
||||
let grey: Vec<u8> = rgba.chunks(4).map(|px| px[0]).collect();
|
||||
let expected: Vec<u8> = (0..32).map(|i| if i < 8 { 0 } else { 255 }).collect();
|
||||
assert_eq!(grey, expected, "the picture, black as 0");
|
||||
}
|
||||
|
||||
/// `fixture(2)` with the catalog pointing at a page tree object the file does not have (#1656).
|
||||
fn fixture_without_page_tree() -> Vec<u8> {
|
||||
let bytes = fixture(2);
|
||||
|
||||
@@ -130,7 +130,7 @@ milestone = "M1"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/filters/src/ccitt.rs::decoding_gives_the_rows_back", "crates/filters/src/ccitt.rs::group_3_rows_on_bytes_read_by_the_codes_in_the_data", "crates/cos/src/document/tests.rs::fax_images_decode_with_their_parameters", "crates/filters/src/jbig2/decode.rs::both_codings_decode_to_their_samples", "crates/filters/src/jbig2/decode.rs::what_it_cannot_decode_is_an_error", "crates/cos/src/document/tests.rs::jbig2_images_decode_with_their_globals"]
|
||||
evidence = ["crates/filters/src/ccitt.rs::decoding_gives_the_rows_back", "crates/filters/src/ccitt.rs::group_3_rows_on_bytes_read_by_the_codes_in_the_data", "crates/cos/src/document/tests.rs::fax_images_decode_with_their_parameters", "crates/filters/src/jbig2/decode.rs::both_codings_decode_to_their_samples", "crates/filters/src/jbig2/decode.rs::what_it_cannot_decode_is_an_error", "crates/cos/src/document/tests.rs::jbig2_images_decode_with_their_globals", "crates/cos/src/document/tests.rs::shared_jbig2_globals_are_read_once"]
|
||||
notes = "decoded through the hayro bootstrap for display; own codecs are M1.1/M1.2. CCITT fax decoding is in filters (decode_ccitt, through hayro-ccitt), which Document::decoded_image uses for Export ▸ Images, the optimizer and redaction; so is JBIG2 decoding (decode_jbig2, through hayro-jbig2, the page bounded before it is decoded), which it uses with the image's /JBIG2Globals (within 32 MiB) for Export ▸ Images, Copy Image and redaction. Byte-aligned Group 3 is read by its end-of-line codes when the data starts with one and on bytes otherwise; still limited: data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at /K 0 may come out as other rows, which the optimizer then writes back. The bootstrap renderer draws byte-aligned Group 3 whose fill ends each end-of-line code on a byte as other rows (black box, at every /K), which decode_ccitt reads right; redaction removes Group 3 on bytes whole"
|
||||
|
||||
[[feature]]
|
||||
@@ -1598,7 +1598,7 @@ milestone = "M3"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/it/viewer.rs::a_clicked_picture_is_selected_and_copied_in_its_original_format", "crates/ui-egui/tests/it/viewer.rs::right_click_on_a_picture_copies_it_at_full_size", "crates/ui-egui/tests/it/viewer.rs::a_picture_inside_form_artwork_under_text_is_selected_and_copied", "crates/engine/src/tests.rs::copy_image_finds_a_picture_inside_form_artwork_under_text"]
|
||||
evidence = ["crates/ui-egui/tests/it/viewer.rs::a_clicked_picture_is_selected_and_copied_in_its_original_format", "crates/ui-egui/tests/it/viewer.rs::right_click_on_a_picture_copies_it_at_full_size", "crates/ui-egui/tests/it/viewer.rs::a_picture_inside_form_artwork_under_text_is_selected_and_copied", "crates/engine/src/tests.rs::copy_image_finds_a_picture_inside_form_artwork_under_text", "crates/engine/src/tests.rs::copy_image_copies_a_jbig2_picture_behind_another_filter", "crates/engine/src/tests.rs::copy_image_reads_the_globals_of_a_jbig2_picture"]
|
||||
notes = "Select tool: a click selects a raster image (also inside Form artwork or under text) and ⌘C copies it as a file in its original format (JPEG as stored, else PNG); right-click ▸ Copy Image copies its pixels at full resolution. Missing: inline images (BI/ID/EI), JPEG 2000 images, multi-format clipboard (file and bitmap at once), the web build"
|
||||
|
||||
[[feature]]
|
||||
|
||||
Reference in New Issue
Block a user