use super::*;
use crate::NeverCancel;
use crate::hnc8::{OutlineDefect, OutlineRepair};
use flate2::{Compression, write::ZlibEncoder};
use std::{
cell::Cell,
io::{self, Write},
rc::Rc,
};
const PREFIX: [u8; 20] = *b"\x03\x80\x64\x00\x03\x80\xc8\x00COMPRESSTEXT";
struct Flag(Rc<Cell<bool>>);
impl Cancellation for Flag {
fn is_cancelled(&self) -> bool {
self.0.get()
}
}
#[derive(Clone)]
struct Record {
kind: i32,
bytes: Vec<u8>,
coordinate: RawTextCoordinate,
}
impl Record {
fn jpeg(width: u16, height: u16, sample: u8, x: u16, y: u16) -> Self {
Self {
kind: 2,
bytes: jpeg(width, height, sample),
coordinate: RawTextCoordinate {
x,
y,
width: 80,
height: 40,
},
}
}
fn type0(rows: &[Vec<bool>], x: u16, y: u16) -> Self {
Self {
kind: 0,
bytes: type0(rows),
coordinate: RawTextCoordinate {
x,
y,
width: 80,
height: 40,
},
}
}
}
struct Fixture {
bytes: Vec<u8>,
index: usize,
text_offsets: Vec<usize>,
descriptors: Vec<Vec<u64>>,
payloads: Vec<Vec<u64>>,
}
fn text(records: &[Record]) -> Vec<u8> {
let mut plain = vec![0x19; 28 + records.len() * 28];
for (slot, marker) in [(0, 0x8070_u16), (4, 0x8071), (8, 0x8001)] {
plain[8 + slot..10 + slot].copy_from_slice(&marker.to_le_bytes());
}
for (number, record) in records.iter().enumerate() {
let at = 28 + number * 28;
plain[at..at + 2].copy_from_slice(&record.coordinate.x.to_le_bytes());
plain[at + 2..at + 4].copy_from_slice(&record.coordinate.y.to_le_bytes());
plain[at + 4..at + 6].copy_from_slice(&record.coordinate.width.to_le_bytes());
plain[at + 6..at + 8].copy_from_slice(&record.coordinate.height.to_le_bytes());
}
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&plain).unwrap();
let mut result = PREFIX.to_vec();
result.extend((plain.len() as u32).to_le_bytes());
result.extend(encoder.finish().unwrap());
result
}
fn image_records(records: &[Record]) -> Vec<u8> {
let mut plain = Vec::new();
for record in records {
let mut image = [0_u8; 28];
image[..2].copy_from_slice(&0x800a_u16.to_le_bytes());
image[4..6].copy_from_slice(&record.coordinate.x.to_le_bytes());
image[6..8].copy_from_slice(&record.coordinate.y.to_le_bytes());
image[8..10].copy_from_slice(&record.coordinate.width.to_le_bytes());
image[10..12].copy_from_slice(&record.coordinate.height.to_le_bytes());
plain.extend(image);
}
plain.extend([4, 0x80, 0, 0]);
plain
}
fn direct_text(records: &[Record]) -> Vec<u8> {
let plain = image_records(records);
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&plain).unwrap();
let mut result = b"COMPRESSTEXT".to_vec();
result.extend((plain.len() as u32).to_le_bytes());
result.extend(encoder.finish().unwrap());
result
}
fn fixture(variant: Variant, pages: &[Vec<Record>]) -> Fixture {
fixture_with_text(
variant,
pages,
if variant == Variant::HnB {
|_| b"original opaque HN-B text".to_vec()
} else {
text
},
)
}
fn fixture_with_text(
variant: Variant,
pages: &[Vec<Record>],
text: fn(&[Record]) -> Vec<u8>,
) -> Fixture {
let (count_at, index) = match variant {
Variant::C8 => (8, 0x50),
Variant::HnA => (0x90, 0x15c),
Variant::HnB => (0x90, 0xd8),
};
let mut bytes = vec![0; index + pages.len() * 20];
match variant {
Variant::C8 => bytes[..4].copy_from_slice(&[0xc8, 0, 0, 0]),
Variant::HnA | Variant::HnB => {
bytes[..4].copy_from_slice(b"HN\0\0");
bytes[4..8].copy_from_slice(match variant {
Variant::HnA => &[0x90, 1, 0, 0],
_ => &[0xc8, 0, 0, 0],
});
}
}
if variant == Variant::HnB {
bytes[0x88..0x8c].copy_from_slice(&0xc8_u32.to_le_bytes());
}
bytes[count_at + 4] = 2; bytes[count_at..count_at + 4].copy_from_slice(&(pages.len() as i32).to_le_bytes());
bytes[count_at + 24..count_at + 26].copy_from_slice(&100_u16.to_le_bytes());
bytes[count_at + 26..count_at + 28].copy_from_slice(&200_u16.to_le_bytes());
let mut result = Fixture {
bytes,
index,
text_offsets: Vec::new(),
descriptors: Vec::new(),
payloads: Vec::new(),
};
for (number, records) in pages.iter().enumerate() {
let offset = result.bytes.len();
let page_text = text(records);
result.bytes.extend(&page_text);
let row = index + number * 20;
result.bytes[row..row + 4].copy_from_slice(&(offset as i32).to_le_bytes());
result.bytes[row + 4..row + 8].copy_from_slice(&(page_text.len() as i32).to_le_bytes());
result.bytes[row + 8..row + 10].copy_from_slice(&(records.len() as i16).to_le_bytes());
result.text_offsets.push(offset);
let mut descriptors = Vec::new();
let mut payloads = Vec::new();
for record in records {
let descriptor = result.bytes.len();
let payload = descriptor + 12;
result.bytes.extend(record.kind.to_le_bytes());
result.bytes.extend((payload as i32).to_le_bytes());
result
.bytes
.extend((record.bytes.len() as i32).to_le_bytes());
result.bytes.extend(&record.bytes);
descriptors.push(descriptor as u64);
payloads.push(payload as u64);
}
result.descriptors.push(descriptors);
result.payloads.push(payloads);
}
result
}
fn segment(marker: u8, body: &[u8]) -> Vec<u8> {
let mut bytes = vec![0xff, marker];
bytes.extend(u16::try_from(body.len() + 2).unwrap().to_be_bytes());
bytes.extend(body);
bytes
}
#[derive(Default)]
struct Bits {
bytes: Vec<u8>,
byte: u8,
used: u8,
}
impl Bits {
fn put(&mut self, value: u16, bits: u8) {
for shift in (0..bits).rev() {
self.byte = (self.byte << 1) | ((value >> shift) as u8 & 1);
self.used += 1;
if self.used == 8 {
self.bytes.push(self.byte);
if self.byte == 0xff {
self.bytes.push(0);
}
self.byte = 0;
self.used = 0;
}
}
}
fn finish(mut self) -> Vec<u8> {
if self.used > 0 {
self.put((1 << (8 - self.used)) - 1, 8 - self.used);
}
self.bytes
}
}
fn jpeg(width: u16, height: u16, sample: u8) -> Vec<u8> {
let mut bytes = vec![0xff, 0xd8];
bytes.extend(segment(0xe0, b"JFIF\0\x01\x01\0\0\x01\0\x01\0\0"));
bytes.extend(segment(0xfe, b"original composition fixture"));
bytes.extend(segment(0xdb, &[&[0_u8][..], &[1_u8; 64][..]].concat()));
let [h0, h1] = height.to_be_bytes();
let [w0, w1] = width.to_be_bytes();
bytes.extend(segment(0xc0, &[8, h0, h1, w0, w1, 1, 1, 0x11, 0]));
let mut dc = vec![0, 0, 0, 0, 12];
dc.extend([0; 12]);
dc.extend(0..12);
bytes.extend(segment(0xc4, &dc));
let mut ac = vec![0x10, 1];
ac.extend([0; 15]);
ac.push(0);
bytes.extend(segment(0xc4, &ac));
bytes.extend(segment(0xda, &[1, 1, 0, 0, 63, 0]));
let mut bits = Bits::default();
let mut previous = 0;
for _ in 0..u32::from(width.div_ceil(8)) * u32::from(height.div_ceil(8)) {
let value = 8 * (i32::from(sample) - 128);
let difference = value - previous;
previous = value;
let category = (32 - difference.unsigned_abs().leading_zeros()) as u8;
bits.put(u16::from(category), 4);
if category > 0 {
let amplitude = if difference < 0 {
difference + (1 << category) - 1
} else {
difference
};
bits.put(amplitude as u16, category);
}
bits.put(0, 1);
}
bytes.extend(bits.finish());
bytes.extend([0xff, 0xd9]);
bytes
}
fn table() -> QmTable {
QmTable::standard()
}
fn type0(rows: &[Vec<bool>]) -> Vec<u8> {
let mut bytes = vec![0; 48];
bytes[..4].copy_from_slice(&40_u32.to_le_bytes());
bytes[4..8].copy_from_slice(&(rows[0].len() as i32).to_le_bytes());
bytes[8..12].copy_from_slice(&(rows.len() as i32).to_le_bytes());
bytes[12..14].copy_from_slice(&1_u16.to_le_bytes());
bytes[14..16].copy_from_slice(&1_u16.to_le_bytes());
bytes[32..36].copy_from_slice(&2_u32.to_le_bytes());
bytes[40..43].fill(0xff);
let mut coded = qm_encoder();
coded.type0_rows(rows, false);
bytes.extend(coded.finish());
bytes
}
fn rows(width: usize) -> Vec<Vec<bool>> {
(0..3)
.map(|y| (0..width).map(|x| x == y || x + y + 1 == width).collect())
.collect()
}
fn packed(rows: &[Vec<bool>]) -> Vec<u8> {
let stride = rows[0].len().div_ceil(8);
let mut packed = vec![0; stride * rows.len()];
for (number, row) in rows.iter().enumerate() {
for (x, bit) in row.iter().enumerate() {
if *bit {
packed[number * stride + x / 8] |= 0x80 >> (x % 8);
}
}
}
packed
}
#[derive(Clone, Copy, Debug)]
enum Fault {
Zero,
Overreport,
Io,
}
fn fault(fault: Fault, requested: usize) -> crate::Result<usize> {
match fault {
Fault::Zero => Ok(0),
Fault::Overreport => Ok(requested + 1),
Fault::Io => Err(Error::from(ErrorKind::Io(io::Error::other(
"original synthetic fault",
)))),
}
}
struct Source {
bytes: Vec<u8>,
short: usize,
max_request: usize,
fault_at: Option<(u64, Fault)>,
payload: Option<(u64, u64)>,
payload_bytes_read: u64,
}
impl Source {
fn new(bytes: Vec<u8>) -> Self {
Self {
bytes,
short: usize::MAX,
max_request: 0,
fault_at: None,
payload: None,
payload_bytes_read: 0,
}
}
}
impl RangedSource for Source {
fn size(&self) -> u64 {
self.bytes.len() as u64
}
fn read_at(&mut self, offset: u64, destination: &mut [u8]) -> crate::Result<usize> {
self.max_request = self.max_request.max(destination.len());
if self.fault_at.is_some_and(|(at, _)| offset >= at) {
return fault(self.fault_at.unwrap().1, destination.len());
}
let start = usize::try_from(offset).unwrap();
let count = destination
.len()
.min(self.short)
.min(self.bytes.len().saturating_sub(start));
if count > 0 {
destination[..count].copy_from_slice(&self.bytes[start..start + count]);
}
if let Some((first, end)) = self.payload {
let overlap = (offset + count as u64)
.min(end)
.saturating_sub(offset.max(first));
self.payload_bytes_read += overlap;
}
Ok(count)
}
}
#[derive(Default)]
struct Sink {
bytes: Vec<u8>,
short: Option<usize>,
max_request: usize,
fail_after: Option<usize>,
cancel: Option<Rc<Cell<bool>>>,
fail_flush: bool,
}
impl Write for Sink {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.max_request = self.max_request.max(bytes.len());
if self.fail_after.is_some_and(|at| self.bytes.len() >= at) {
return Err(io::Error::other("synthetic PDF sink failure"));
}
let count = bytes.len().min(self.short.unwrap_or(usize::MAX));
self.bytes.extend(&bytes[..count]);
if let Some(flag) = &self.cancel {
flag.set(true);
}
Ok(count)
}
fn flush(&mut self) -> std::io::Result<()> {
if self.fail_flush {
Err(io::Error::other("synthetic PDF flush failure"))
} else {
Ok(())
}
}
}
#[derive(Default)]
struct Visitor {
mappings: Vec<(u32, Option<u32>)>,
images: Vec<(ImageRecord, u32, u32, u32, [f64; 6])>,
sizes: Vec<Option<PageSpec>>,
fail: bool,
cancel: Option<Rc<Cell<bool>>>,
}
impl ComposeVisitor for Visitor {
fn page(&mut self, page: ComposePage<'_>) -> crate::Result<()> {
self.mappings
.push((page.source.page_number, page.output_page));
self.sizes.push(page.size);
for image in page.images {
self.images.push((
image.record,
image.visible_width,
image.display_width,
image.height,
image.transform,
));
}
if let Some(flag) = &self.cancel {
flag.set(true);
}
if self.fail {
Err(Error::from(ErrorKind::Io(io::Error::other(
"synthetic visitor failure",
))))
} else {
Ok(())
}
}
}
fn convert(
source: &mut Source,
sink: &mut Sink,
table: Option<&QmTable>,
visitor: &mut Visitor,
options: ComposeOptions,
limits: &Limits,
) -> Result<ComposeReport> {
convert_source_pages_pdf(source, sink, table, visitor, options, limits, &NeverCancel)
}
fn contains(bytes: &[u8], needle: &[u8]) -> bool {
bytes.windows(needle.len()).any(|part| part == needle)
}
#[test]
fn hna_region_metadata_keeps_the_single_jpeg_and_pdf_unchanged() {
fn region_text(records: &[Record]) -> Vec<u8> {
let mut plain = vec![0x1c, 0x80, 0, 0, 0xce, 0x80, 0, 0];
let placement = image_records(records);
plain.extend_from_slice(&placement[..placement.len() - 4]);
let mut region = [0; 28];
region[..2].copy_from_slice(&0x800a_u16.to_le_bytes());
region[4..6].copy_from_slice(&10_u16.to_le_bytes());
region[6..8].copy_from_slice(&30_u16.to_le_bytes());
region[8..10].copy_from_slice(&40_u16.to_le_bytes());
region[10..12].copy_from_slice(&50_u16.to_le_bytes());
plain.extend_from_slice(®ion);
plain.extend_from_slice(&[4, 0x80, 0, 0]);
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&plain).unwrap();
let mut text = PREFIX.to_vec();
text.extend_from_slice(&(plain.len() as u32).to_le_bytes());
text.extend(encoder.finish().unwrap());
text
}
let mut image = Record::jpeg(16, 32, 73, 0, 0);
image.coordinate.width = 100;
image.coordinate.height = 200;
let pages = [vec![image]];
let mut outputs = Vec::new();
for text in [text as fn(&[Record]) -> Vec<u8>, region_text] {
let built = fixture_with_text(Variant::HnA, &pages, text);
let mut source = Source::new(built.bytes);
let mut sink = Sink::default();
let mut visitor = Visitor::default();
let report = convert(
&mut source,
&mut sink,
None,
&mut visitor,
ComposeOptions::default(),
&Limits::default(),
)
.unwrap();
assert_eq!(report.conversion.pages_converted, 1);
assert_eq!(visitor.images.len(), 1);
outputs.push(sink.bytes);
}
assert_eq!(outputs[0], outputs[1]);
}
struct Harness {
fixture: Fixture,
source: Source,
sink: Sink,
visitor: Visitor,
}
impl Harness {
fn new(variant: Variant, pages: &[Vec<Record>]) -> Self {
let mut fixture = fixture(variant, pages);
let source = Source::new(std::mem::take(&mut fixture.bytes));
Self {
fixture,
source,
sink: Sink::default(),
visitor: Visitor::default(),
}
}
fn type0() -> Self {
Self::new(Variant::C8, &[vec![Record::type0(&rows(9), 0, 0)]])
}
fn run(
&mut self,
table: Option<&QmTable>,
options: ComposeOptions,
limits: &Limits,
) -> Result<ComposeReport> {
convert(
&mut self.source,
&mut self.sink,
table,
&mut self.visitor,
options,
limits,
)
}
fn cancelled(&mut self, flag: Rc<Cell<bool>>) -> Result<ComposeReport> {
convert_source_pages_pdf(
&mut self.source,
&mut self.sink,
Some(&table()),
&mut self.visitor,
ComposeOptions::default(),
&Limits::default(),
&Flag(flag),
)
}
}
fn located(error: &Error, variant: Variant, page: Option<u32>, image: Option<u32>) {
let Context::Hnc8 {
variant: found,
page: found_page,
image: found_image,
..
} = error.context
else {
panic!("not an HN/C8 error: {error:?}");
};
assert_eq!(
(found, found_page, found_image),
(Some(variant), page, image)
);
assert!(error.offset.is_some());
}
#[test]
fn hnb_maps_all_six_rows_without_a_table() {
let first = Record::jpeg(16, 8, 55, 99, 99);
let last = Record::jpeg(8, 16, 205, 99, 99);
let built = fixture(
Variant::HnB,
&[
vec![first.clone()],
vec![],
vec![],
vec![],
vec![],
vec![last.clone()],
],
);
let mut source = Source::new(built.bytes);
let mut sink = Sink::default();
let mut visitor = Visitor::default();
let report = convert_source_pages_pdf(
&mut source,
&mut sink,
None,
&mut visitor,
ComposeOptions::default(),
&Limits::default(),
&NeverCancel,
)
.unwrap();
assert_eq!(
visitor.mappings,
[
(1, Some(1)),
(2, None),
(3, None),
(4, None),
(5, None),
(6, Some(2))
]
);
assert_eq!(report.source_variant, Variant::HnB);
assert_eq!(report.source_pages, 6);
assert_eq!(report.output_pages, 2);
assert_eq!(report.no_image_pages, 4);
assert_eq!(report.type0_images, 0);
assert_eq!(report.jpeg_images, 2);
assert_eq!(report.conversion.pages_converted, 2);
assert_eq!(
report.conversion.output_bytes_written,
sink.bytes.len() as u64
);
assert_eq!(visitor.images[0].4, [3.84, 0.0, 0.0, -1.92, 0.0, 1.92]);
assert_eq!(visitor.images[1].4, [1.92, 0.0, 0.0, -3.84, 0.0, 3.84]);
assert_eq!(visitor.sizes[1..5], [None; 4]);
assert!(contains(&sink.bytes, &first.bytes));
assert!(contains(&sink.bytes, &last.bytes));
}
#[test]
fn invented_hna_c8_text_preserves_order_overlap_repeats_and_off_page_positions() {
for variant in [Variant::HnA, Variant::C8] {
let first = Record::jpeg(16, 16, 50, 0, 0);
let mut second = Record::jpeg(8, 8, 210, 17, 3);
second.kind = 1;
let repeated = second.clone();
let outside = Record::jpeg(8, 8, 125, u16::MAX, u16::MAX);
let built = fixture(variant, &[vec![first, second, repeated, outside]]);
let expected_descriptors = built.descriptors[0].clone();
let mut source = Source::new(built.bytes);
let mut sink = Sink::default();
let mut visitor = Visitor::default();
let report = convert(
&mut source,
&mut sink,
None,
&mut visitor,
ComposeOptions::default(),
&Limits::default(),
)
.unwrap();
assert_eq!(report.output_pages, 1);
assert_eq!(report.jpeg_images, 4);
assert_eq!(
visitor
.images
.iter()
.map(|image| image.0.descriptor_offset)
.collect::<Vec<_>>(),
expected_descriptors
);
assert_eq!(visitor.images[1].4, visitor.images[2].4);
let scale = 240.0 / 2473.0;
assert_eq!(
visitor.images[1].4,
[
80.0 * scale,
0.0,
0.0,
-40.0 * scale,
17.0 * scale,
200.0 * scale - 3.0 * scale
]
);
assert!(visitor.images[3].4[4] > 3.84);
assert!(visitor.images[3].4[5] < 0.0);
let content = crate::test_support::pdf_text(&sink.bytes);
let locations = (0..4)
.map(|number| content.find(&format!("/Im{number} Do")).unwrap())
.collect::<Vec<_>>();
assert!(locations.windows(2).all(|pair| pair[0] < pair[1]));
}
}
#[test]
fn mixed_images_preserve_exact_padding_and_asymmetric_top_first_rows() {
for width in [1, 7, 8, 9, 24, 25, 28, 31, 32, 33] {
let pixels = rows(width);
let bilevel = Record::type0(&pixels, 0, 0);
let jpeg = Record::jpeg(8, 8, 155, 9, 0);
let built = fixture(
Variant::C8,
&[vec![bilevel, jpeg, Record::type0(&pixels, 13, 1)]],
);
let mut source = Source::new(built.bytes);
let mut sink = Sink::default();
let mut visitor = Visitor::default();
let report = convert(
&mut source,
&mut sink,
Some(&table()),
&mut visitor,
ComposeOptions::default(),
&Limits::default(),
)
.unwrap();
let display_width = width;
assert_eq!(visitor.images[0].1, width as u32);
assert_eq!(visitor.images[0].2, display_width as u32);
assert_eq!(
visitor.sizes[0].unwrap().width_points,
100.0 * (240.0 / 2473.0)
);
assert_eq!(visitor.images[0].4[3], 40.0 * (240.0 / 2473.0));
let expected = packed(&pixels);
assert!(
crate::test_support::bilevel_pixels(&sink.bytes).contains(&expected),
"width {width}"
);
assert!(contains(
&sink.bytes,
format!("/Width {display_width}\n/Height 3\n").as_bytes()
));
assert_eq!(report.type0_images, 2);
assert_eq!(report.jpeg_images, 1);
}
}
#[test]
fn short_source_and_sink_calls_stay_bounded() {
let built = fixture(
Variant::C8,
&[vec![
Record::type0(&rows(33), 0, 0),
Record::jpeg(8, 8, 128, 1, 1),
]],
);
let mut source = Source::new(built.bytes);
source.short = 1;
let mut sink = Sink {
short: Some(2),
..Sink::default()
};
let mut visitor = Visitor::default();
let limits = Limits {
io_chunk_bytes: 7,
..Limits::default()
};
let report = convert(
&mut source,
&mut sink,
Some(&table()),
&mut visitor,
ComposeOptions::default(),
&limits,
)
.unwrap();
assert_eq!(report.output_pages, 1);
assert!(source.max_request <= 7);
assert!(sink.max_request <= 7);
}
#[test]
fn invalid_text_marker_is_rejected_without_emitting_images() {
for variant in [Variant::C8, Variant::HnA] {
let mut case = Harness::new(variant, &[vec![Record::jpeg(8, 8, 128, 0, 0)]]);
case.source.bytes[case.fixture.text_offsets[0] + 8] ^= 1;
let error = convert_source_pages_pdf(
&mut case.source,
&mut case.sink,
None,
&mut case.visitor,
ComposeOptions::default(),
&Limits::default(),
&NeverCancel,
)
.unwrap_err();
located(&error, variant, Some(1), None);
assert_eq!(stage_of(&error), Some(Hnc8Stage::Text));
assert!(matches!(error.context, Context::Hnc8 { .. }));
assert!(case.visitor.mappings.is_empty());
assert!(!contains(&case.sink.bytes, b"/Subtype /Image"));
}
}
#[test]
fn pure_text_refusals_are_explicit_and_hnb_no_image_rows_are_observed() {
for variant in [Variant::C8, Variant::HnA, Variant::HnB] {
let mut case = Harness::new(variant, &[vec![], vec![]]);
let error = case
.run(None, ComposeOptions::default(), &Limits::default())
.unwrap_err();
assert_eq!(stage_of(&error), Some(Hnc8Stage::Preflight));
assert_eq!(error.reason, "image-only output has no image to draw");
if variant == Variant::HnB {
located(&error, variant, None, None);
assert_eq!(case.visitor.mappings, [(1, None), (2, None)]);
} else {
located(&error, variant, Some(1), None);
assert!(case.visitor.mappings.is_empty());
}
assert!(!contains(&case.sink.bytes, b"/Subtype /Image"));
}
}
#[test]
fn hnb_image_only_rows_validate_counts_codecs_and_type3_headers() {
let mut many = Harness::new(Variant::HnB, &[vec![Record::jpeg(8, 8, 100, 0, 0); 2]]);
let error = many
.run(None, ComposeOptions::default(), &Limits::default())
.unwrap_err();
located(&error, Variant::HnB, Some(1), None);
assert!(matches!(
error,
Error {
kind: ErrorKind::UnsupportedFormat,
..
}
));
assert!(!contains(&many.sink.bytes, b"/Subtype /Image"));
for kind in [0, 1, 3] {
let mut record = Record::jpeg(8, 8, 100, 0, 0);
record.kind = kind;
let mut case = Harness::new(Variant::HnB, &[vec![record]]);
let error = case
.run(
Some(&table()),
ComposeOptions::default(),
&Limits::default(),
)
.unwrap_err();
located(&error, Variant::HnB, Some(1), Some(1));
assert_eq!(stage_of(&error), Some(Hnc8Stage::Headers));
if kind == 3 {
assert!(error.reason.starts_with("type-3 DIB:"));
} else {
assert!(matches!(error.kind, ErrorKind::UnsupportedFormat));
assert_eq!(error.reason, "image record type");
assert_eq!(error.offset, Some(case.fixture.descriptors[0][0]));
}
assert!(!contains(&case.sink.bytes, b"/Subtype /Image"));
}
}
#[test]
fn late_invalid_type1_or_type3_header_refuses_page_before_output() {
for kind in [1, 3] {
let first = Record::jpeg(8, 8, 70, 0, 0);
let mut second = Record::jpeg(8, 8, 190, 0, 0);
second.kind = kind;
if kind == 1 {
second.bytes[0] = 0; }
let mut case = Harness::new(Variant::C8, &[vec![first, second]]);
let error = case
.run(None, ComposeOptions::default(), &Limits::default())
.unwrap_err();
located(&error, Variant::C8, Some(1), Some(2));
assert_eq!(stage_of(&error), Some(Hnc8Stage::Headers));
if kind == 3 {
assert!(error.reason.starts_with("type-3 DIB: "));
} else {
assert!(matches!(error.context, Context::Hnc8 { .. }));
assert_eq!(error.offset, Some(case.fixture.payloads[0][1]));
}
assert!(!contains(&case.sink.bytes, b"/Subtype /Image"));
}
}
#[test]
fn missing_table_and_text_working_allocation_failures_remain_located() {
let mut missing = Harness::type0();
let error = missing
.run(None, ComposeOptions::default(), &Limits::default())
.unwrap_err();
located(&error, Variant::C8, Some(1), Some(1));
assert_eq!(stage_of(&error), Some(Hnc8Stage::Headers));
assert_eq!(error.offset, Some(missing.fixture.descriptors[0][0]));
assert_eq!(error.reason, "type-0 image requires a caller QM table");
let mut bounded = Harness::type0();
let limits = Limits {
io_chunk_bytes: 7,
max_allocation_bytes: 512,
..Limits::default()
};
let error = bounded
.run(Some(&table()), ComposeOptions::default(), &limits)
.unwrap_err();
located(&error, Variant::C8, Some(1), None);
assert_eq!(stage_of(&error), Some(Hnc8Stage::Text));
assert!(matches!(
error.kind,
ErrorKind::LimitExceeded {
resource: "text decoder allocation reservation",
limit: 512,
attempted: crate::hnc8::TEXT_DECODER_RESERVATION_BYTES,
}
));
}
#[test]
fn allocation_and_count_ceilings_precede_text_or_payload_output() {
let images = vec![Record::jpeg(8, 8, 128, 0, 0); 2];
let mut case = Harness::new(Variant::C8, &[images]);
let limits = Limits {
io_chunk_bytes: 7,
max_allocation_bytes: 64,
..Limits::default()
};
let error = case
.run(None, ComposeOptions::default(), &limits)
.unwrap_err();
assert_eq!(stage_of(&error), Some(Hnc8Stage::Preflight));
assert!(matches!(
error,
Error {
kind: ErrorKind::LimitExceeded {
resource: "allocation bytes",
..
},
..
}
));
let mut case = Harness::new(Variant::C8, &[vec![Record::jpeg(8, 8, 128, 0, 0)]]);
let row = case.fixture.index;
case.source.bytes[row + 8..row + 10].copy_from_slice(&8193_i16.to_le_bytes());
let error = case
.run(None, ComposeOptions::default(), &Limits::default())
.unwrap_err();
assert_eq!(stage_of(&error), Some(Hnc8Stage::Preflight));
assert!(matches!(
error.kind,
ErrorKind::LimitExceeded {
resource: "PDF image placements per page",
limit: 8192,
attempted: 8193
}
));
}
#[test]
fn malformed_counts_spans_and_text_declared_length_are_not_partial_passes() {
for corruption in 0..4 {
let mut case = Harness::new(Variant::C8, &[vec![Record::jpeg(8, 8, 128, 0, 0)]]);
let row = case.fixture.index;
let descriptor = case.fixture.descriptors[0][0] as usize;
match corruption {
0 => case.source.bytes[row + 8..row + 10].copy_from_slice(&(-1_i16).to_le_bytes()),
1 => case.source.bytes[descriptor + 4..descriptor + 8]
.copy_from_slice(&i32::MAX.to_le_bytes()),
2 => case.source.bytes[case.fixture.text_offsets[0] + 20] ^= 1,
_ => case.source.bytes[row + 8..row + 10].copy_from_slice(&2_i16.to_le_bytes()),
}
let error = case
.run(None, ComposeOptions::default(), &Limits::default())
.unwrap_err();
located(
&error,
Variant::C8,
Some(1),
if corruption == 1 { Some(1) } else { None },
);
assert!(matches!(error.context, Context::Hnc8 { .. }));
assert_eq!(
stage_of(&error),
Some(if corruption >= 2 {
Hnc8Stage::Text
} else {
Hnc8Stage::Container
})
);
assert!(!contains(&case.sink.bytes, b"/Subtype /Image"));
assert!(case.visitor.mappings.is_empty());
}
}
#[test]
fn source_and_pdf_sink_failures_are_located_and_never_emit_success_events() {
for mode in [Fault::Zero, Fault::Overreport, Fault::Io] {
let mut case = Harness::type0();
case.source.fault_at = Some((case.fixture.payloads[0][0], mode));
let error = case
.run(
Some(&table()),
ComposeOptions::default(),
&Limits::default(),
)
.unwrap_err();
located(&error, Variant::C8, Some(1), Some(1));
assert_eq!(stage_of(&error), Some(Hnc8Stage::Headers));
assert!(case.visitor.mappings.is_empty());
}
let mut case = Harness::type0();
case.sink.fail_after = Some(60);
let error = case
.run(
Some(&table()),
ComposeOptions::default(),
&Limits::default(),
)
.unwrap_err();
located(&error, Variant::C8, Some(1), Some(1));
assert_eq!(stage_of(&error), Some(Hnc8Stage::Pdf));
assert!(case.visitor.mappings.is_empty());
}
#[test]
fn visitor_failure_and_inter_page_cancellation_invalidate_the_whole_conversion() {
let mut case = Harness::type0();
case.visitor.fail = true;
let error = case
.run(
Some(&table()),
ComposeOptions::default(),
&Limits::default(),
)
.unwrap_err();
located(&error, Variant::C8, Some(1), None);
assert_eq!(stage_of(&error), Some(Hnc8Stage::Visitor));
assert_eq!(case.visitor.mappings, [(1, Some(1))]);
let mut case = Harness::new(Variant::C8, &vec![vec![Record::type0(&rows(9), 0, 0)]; 2]);
let flag = Rc::new(Cell::new(false));
case.visitor.cancel = Some(flag.clone());
let error = case.cancelled(flag).unwrap_err();
located(&error, Variant::C8, Some(2), None);
assert_eq!(stage_of(&error), Some(Hnc8Stage::Container));
assert_eq!(case.visitor.mappings, [(1, Some(1))]);
}
#[test]
fn invalid_configuration_is_refused_before_source_or_sink_access() {
let mut case = Harness::type0();
let limits = Limits {
io_chunk_bytes: 0,
..Limits::default()
};
let error = case
.run(None, ComposeOptions::default(), &limits)
.unwrap_err();
assert!(matches!(
error,
Error {
kind: ErrorKind::Malformed,
..
}
));
assert!(case.sink.bytes.is_empty());
}
#[test]
fn resource_arithmetic_and_noop_visitor_are_checked_at_boundaries() {
assert_eq!(metadata_bytes(3, 8).unwrap(), 24);
assert!(metadata_bytes(u64::MAX, 2).is_err());
let unlimited = Limits {
max_allocation_bytes: u64::MAX,
..Limits::default()
};
assert!(
page_vector::<u8>(0, &unlimited, "test empty metadata")
.unwrap()
.is_empty()
);
let small = page_vector::<u8>(3, &unlimited, "test small metadata").unwrap();
assert!(small.is_empty());
assert!(small.capacity() >= 3);
assert!(matches!(
page_vector::<u8>(usize::MAX, &unlimited, "test current-page reserve"),
Err(Error { kind: ErrorKind::LimitExceeded { resource: "test current-page reserve", attempted, .. }, .. }) if attempted == usize::MAX as u64
));
let restricted = Limits {
max_allocation_bytes: 2,
..Limits::default()
};
assert!(matches!(
page_vector::<u8>(3, &restricted, "test metadata allocation"),
Err(Error {
kind: ErrorKind::LimitExceeded {
resource: "allocation bytes",
limit: 2,
attempted: 3,
..
},
..
})
));
let page = PageRecord {
page_number: 1,
row_offset: 80,
text: super::super::Span {
offset: 100,
length: 0,
},
image_count: 0,
unknown: [0; 10],
};
().page(ComposePage {
source: page,
output_page: None,
size: None,
images: &[],
})
.unwrap();
}
#[test]
fn larger_padded_rows_are_streamed_in_chunks_without_growing_page_metadata() {
let mut case = Harness::new(Variant::C8, &[vec![Record::type0(&rows(4097), 0, 0)]]);
let limits = Limits {
io_chunk_bytes: 17,
..Limits::default()
};
case.run(Some(&table()), ComposeOptions::default(), &limits)
.unwrap();
assert!(case.source.max_request <= 17);
assert!(case.sink.max_request <= 17);
assert!(crate::test_support::bilevel_pixels(&case.sink.bytes).contains(&packed(&rows(4097))));
}
#[test]
fn malformed_jpeg_headers_are_located() {
let mut case = Harness::new(Variant::HnB, &[vec![Record::jpeg(8, 8, 128, 0, 0)]]);
case.source.bytes[case.fixture.payloads[0][0] as usize + 1] = 0;
let error = case
.run(None, ComposeOptions::default(), &Limits::default())
.unwrap_err();
located(&error, Variant::HnB, Some(1), Some(1));
assert_eq!(stage_of(&error), Some(Hnc8Stage::Headers));
assert!(matches!(error.context, Context::Hnc8 { .. }));
}
#[test]
fn initial_pdf_and_final_flush_failures_keep_known_variant_and_source_anchor() {
for initial in [true, false] {
let mut case = Harness::new(Variant::HnB, &[vec![Record::jpeg(8, 8, 128, 0, 0)]]);
if initial {
case.sink.fail_after = Some(0);
} else {
case.sink.fail_flush = true;
}
let error = case
.run(None, ComposeOptions::default(), &Limits::default())
.unwrap_err();
located(&error, Variant::HnB, None, None);
assert_eq!(stage_of(&error), Some(Hnc8Stage::Pdf));
assert_eq!(error.offset, Some(0));
assert!(matches!(error.kind, ErrorKind::Io(_)));
assert_eq!(case.visitor.mappings.len(), usize::from(!initial));
}
}
fn example_container_error() -> Error {
Error::malformed(99, "invented field: original error example").within(Context::Hnc8 {
variant: Some(Variant::C8),
page: Some(2),
image: Some(3),
segment: None,
stage: None,
})
}
fn assert_error_description(error: &Error) {
use std::fmt;
struct Refuse;
impl fmt::Write for Refuse {
fn write_str(&mut self, _: &str) -> fmt::Result {
Err(fmt::Error)
}
}
assert!(error.to_string().contains("HN/C8"), "{error}");
assert!(fmt::write(&mut Refuse, format_args!("{error}")).is_err());
}
#[test]
fn nested_errors_keep_their_location_and_gain_the_stage() {
let at = At {
variant: Some(Variant::C8),
page: Some(2),
image: Some(3),
offset: Some(41),
};
let pdf_sink = Context::Hnc8 {
variant: None,
page: None,
image: None,
segment: None,
stage: Some(Hnc8Stage::Pdf),
};
let cases = [
(
Error::malformed(99, "original error").within(Context::HNC8),
Hnc8Stage::Decode,
),
(Error::cancelled().at(99).within(pdf_sink), Hnc8Stage::Pdf),
];
for (error, expected) in cases {
let error = type0_decode(at)(error);
located(&error, Variant::C8, Some(2), Some(3));
assert_eq!(error.offset, Some(99));
assert_eq!(stage_of(&error), Some(expected));
assert_error_description(&error);
}
let error = at.locator(Hnc8Stage::Headers)(example_container_error());
located(&error, Variant::C8, Some(2), Some(3));
assert_eq!(
(error.offset, stage_of(&error)),
(Some(99), Some(Hnc8Stage::Headers))
);
assert_error_description(&error);
let error = at.locator(Hnc8Stage::Decode)(Error::invalid(crate::arith::INVALID_CONTEXT));
located(&error, Variant::C8, Some(2), Some(3));
assert_eq!(
(error.offset, stage_of(&error)),
(Some(41), Some(Hnc8Stage::Decode))
);
assert_eq!(error.reason, crate::arith::INVALID_CONTEXT);
assert_error_description(&error);
}
#[test]
fn plain_errors_have_descriptions_without_a_source() {
for error in [
Error::invalid("type-0 image requires a caller QM table"),
unsupported("image record type"),
Error::invalid("example"),
unsupported("example"),
unsupported("image-only output has no image to draw"),
Error::invalid("type-3 DIB: example"),
] {
let error = At::NONE.error(Hnc8Stage::Preflight, error);
assert!(std::error::Error::source(&error).is_none());
assert_error_description(&error);
}
}
fn render_original_pdf(bytes: &[u8]) -> Vec<u8> {
render_original_pdf_at(bytes, "0.7419")
}
fn render_original_pdf_at(bytes: &[u8], dpi: &str) -> Vec<u8> {
use std::{
fs,
path::PathBuf,
process::Command,
sync::atomic::{AtomicUsize, Ordering},
};
static NEXT: AtomicUsize = AtomicUsize::new(0);
struct Artifacts(PathBuf);
impl Drop for Artifacts {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
let directory = std::env::temp_dir().join(format!(
"caj2pdf-compose-original-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed),
));
fs::create_dir(&directory).unwrap();
let artifacts = Artifacts(directory);
let pdf = artifacts.0.join("original.pdf");
let raster = artifacts.0.join("original.pgm");
fs::write(&pdf, bytes).unwrap();
let checked = Command::new("qpdf")
.arg("--check")
.arg(&pdf)
.output()
.expect("qpdf is a required PDF test tool");
assert!(
checked.status.success(),
"{}",
String::from_utf8_lossy(&checked.stderr)
);
let rendered = Command::new("mutool")
.args([
"draw", "-q", "-A", "0", "-r", dpi, "-c", "gray", "-F", "pgm", "-o",
])
.arg(&raster)
.arg(&pdf)
.arg("1")
.output()
.expect("mutool is a required PDF test tool");
assert!(
rendered.status.success(),
"{}",
String::from_utf8_lossy(&rendered.stderr)
);
fs::read(raster).unwrap()
}
#[test]
fn independent_render_proves_padded_orientation_and_later_overlap_pixels() {
let pixels = rows(9);
let mut records = vec![
Record::jpeg(32, 16, 50, 0, 0),
Record::type0(&pixels, 0, 0),
Record::jpeg(8, 8, 90, 16000, 4000),
Record::jpeg(8, 8, 210, 16000, 4000),
];
for (record, [width, height]) in
records
.iter_mut()
.zip([[32000, 16000], [9000, 3000], [8000, 8000], [8000, 8000]])
{
record.coordinate.width = width;
record.coordinate.height = height;
}
let mut case = Harness::new(Variant::C8, &[records]);
case.source.bytes[32..34].copy_from_slice(&32000_u16.to_le_bytes());
case.source.bytes[34..36].copy_from_slice(&16000_u16.to_le_bytes());
case.run(
Some(&table()),
ComposeOptions::default(),
&Limits::default(),
)
.unwrap();
let raster = render_original_pdf(&case.sink.bytes);
let header = b"P5\n32 16\n255\n";
assert!(
raster.starts_with(header),
"unexpected original raster geometry"
);
let samples = &raster[header.len()..];
assert_eq!(samples.len(), 32 * 16);
for (y, row) in pixels.iter().enumerate() {
for x in 0..9 {
assert_eq!(
samples[y * 32 + x],
if row.get(x) == Some(&true) { 0 } else { 255 },
"x={x}, y={y}"
);
}
}
assert!(
samples[30].abs_diff(50) <= 1,
"storage padding must not paint"
);
assert!(
samples[15 * 32 + 30].abs_diff(50) <= 1,
"background draw is present"
);
assert!(
samples[6 * 32 + 18].abs_diff(210) <= 1,
"the later overlapping JPEG paints over the earlier 90-gray draw"
);
assert_eq!(case.visitor.images.len(), 4);
}
#[test]
fn optional_hna_outlines_preserve_image_bytes_and_use_nullable_xyz() {
let image = Record::jpeg(8, 8, 90, 0, 0);
let mut fixture = fixture(Variant::HnA, &[vec![image.clone()]]);
let at = fixture.index;
fixture.bytes.splice(at..at, [0; 308]);
fixture.bytes[344..348].copy_from_slice(&1_i32.to_le_bytes());
fixture.bytes[at..at + 8].copy_from_slice(b"Original");
fixture.bytes[at + 280] = b'1';
fixture.bytes[at + 304..at + 308].copy_from_slice(&1_u32.to_le_bytes());
let row = at + 308;
fixture.bytes[row..row + 4]
.copy_from_slice(&((fixture.text_offsets[0] + 308) as i32).to_le_bytes());
let descriptor = fixture.descriptors[0][0] as usize + 308;
fixture.bytes[descriptor + 4..descriptor + 8]
.copy_from_slice(&((fixture.payloads[0][0] + 308) as i32).to_le_bytes());
let limits = Limits::default();
let mut sink = Sink::default();
let report = convert(
&mut Source::new(fixture.bytes.clone()),
&mut sink,
None,
&mut Visitor::default(),
ComposeOptions {
include_bookmarks: true,
..ComposeOptions::default()
},
&limits,
)
.unwrap();
assert_eq!(report.conversion.bookmarks_written, 1);
assert_eq!(report.output_pages, 1);
assert!(contains(&sink.bytes, b"/XYZ null null null"));
assert!(contains(&sink.bytes, &image.bytes));
assert_eq!((report.outline.declared, report.outline.written), (1, 1));
assert_eq!(report.outline.defects, 0);
let pdf = |bytes: &[u8], include_bookmarks: bool, limits: &Limits| {
let mut sink = Sink::default();
convert(
&mut Source::new(bytes.to_vec()),
&mut sink,
None,
&mut Visitor::default(),
ComposeOptions {
include_bookmarks,
..ComposeOptions::default()
},
limits,
)
.map(|report| (report, sink.bytes))
};
let error = pdf(
&fixture.bytes,
true,
&Limits {
max_bookmarks: 0,
..Limits::default()
},
)
.unwrap_err();
assert!(matches!(error.context, Context::Hnc8 { .. }));
fixture.bytes[at + 280] = b'2';
let (report, skipped) = pdf(&fixture.bytes, true, &limits).unwrap();
assert_eq!(report.conversion.bookmarks_written, 0);
assert_eq!(report.output_pages, 1);
assert_eq!(
report.outline.recorded_defects(),
[OutlineDefect {
offset: at as u64 + 280,
reason: "destination is outside source pages",
repair: OutlineRepair::Skipped,
}]
);
let (_, without) = pdf(&fixture.bytes, false, &limits).unwrap();
assert_eq!(skipped, without);
}
#[test]
fn unproven_outline_variants_are_reported_not_written_or_failed() {
let mut outputs = Vec::new();
for include_bookmarks in [false, true] {
let fixture = fixture(Variant::C8, &[vec![Record::jpeg(8, 8, 90, 0, 0)]]);
let mut sink = Sink::default();
let report = convert(
&mut Source::new(fixture.bytes),
&mut sink,
None,
&mut Visitor::default(),
ComposeOptions {
include_bookmarks,
..ComposeOptions::default()
},
&Limits::default(),
)
.unwrap();
assert_eq!(report.outline.unverified, include_bookmarks);
assert_eq!(report.conversion.bookmarks_written, 0);
outputs.push(sink.bytes);
}
assert_eq!(outputs[0], outputs[1]);
}
#[test]
fn uncompressed_text_composes_the_same_ordered_jpeg_page_as_compressed_text() {
fn raw(records: &[Record]) -> Vec<u8> {
let mut bytes = Vec::new();
for record in records {
bytes.extend(0x800a_u16.to_le_bytes());
bytes.extend([0; 2]);
bytes.extend(record.coordinate.x.to_le_bytes());
bytes.extend(record.coordinate.y.to_le_bytes());
bytes.extend(record.coordinate.width.to_le_bytes());
bytes.extend(record.coordinate.height.to_le_bytes());
bytes.extend([0; 16]);
}
bytes.extend(0x8004_u16.to_le_bytes());
bytes.extend([0; 2]);
bytes
}
for variant in [Variant::HnA, Variant::C8] {
let mut records = vec![Record::jpeg(16, 16, 50, 7, 11)];
if variant == Variant::HnA {
records.push(Record::jpeg(8, 8, 210, 17, 3));
}
let pages = [records];
let mut compressed = Harness::new(variant, &pages);
compressed
.run(None, ComposeOptions::default(), &Limits::default())
.unwrap();
let fixture = fixture_with_text(variant, &pages, raw);
let mut source = Source::new(fixture.bytes);
let mut sink = Sink::default();
let report = convert(
&mut source,
&mut sink,
None,
&mut Visitor::default(),
ComposeOptions::default(),
&Limits::default(),
)
.unwrap();
assert_eq!(report.output_pages, 1);
assert_eq!(report.jpeg_images, pages[0].len() as u64);
assert_eq!(sink.bytes, compressed.sink.bytes);
}
}
use crate::Context;
use crate::test_support::{mq_encoder, qm_encoder};
use crate::test_support::{page_image, stage_of};
mod type3_fixture {
include!("../../../tests/common/type3_fixture.rs");
}
fn type3_record(width: u32, height: u32, x: u16, y: u16) -> Record {
Record {
kind: 3,
bytes: type3_fixture::payload(width, height, 0x10),
coordinate: RawTextCoordinate {
x,
y,
width: 80,
height: 40,
},
}
}
#[test]
fn type3_complete_mixed_pages_keep_top_first_pixels() {
let limits = Limits::default();
let mut first_image = type3_record(3, 5, 0, 0);
first_image.coordinate.width = 3000;
first_image.coordinate.height = 5000;
let mut f = fixture_with_text(
Variant::C8,
&[
vec![first_image],
vec![
Record::jpeg(40, 30, 128, 0, 0),
type3_record(9, 3, 12, 7),
type3_record(31, 2, 24, 14),
Record::type0(&rows(9), 0, 20),
],
],
direct_text,
);
f.bytes[32..34].copy_from_slice(&32000_u16.to_le_bytes());
f.bytes[34..36].copy_from_slice(&5000_u16.to_le_bytes());
let mut source = Source::new(f.bytes);
let mut sink = Sink::default();
let mut visitor = Visitor::default();
let report = convert_source_pages_pdf(
&mut source,
&mut sink,
Some(&table()),
&mut visitor,
ComposeOptions::default(),
&limits,
&NeverCancel,
)
.unwrap();
assert_eq!(
(report.output_pages, report.type3_images, report.jpeg_images),
(2, 3, 1)
);
assert_eq!(report.type0_images, 1);
let raster = render_original_pdf(&sink.bytes);
let header = b"P5\n32 5\n255\n";
assert!(
raster.starts_with(header),
"{:?}",
&raster[..raster.len().min(80)]
);
let pixels = &raster[header.len()..];
assert_eq!(pixels.len(), 160);
assert_eq!(pixels[0], 0);
assert!(pixels[1..].iter().all(|&pixel| pixel == 255));
let pdf = String::from_utf8_lossy(&sink.bytes);
assert_eq!(
visitor.sizes[0].unwrap().width_points,
32000.0 * (240.0 / 2473.0)
);
assert_eq!(visitor.images[0].4[5], 0.0);
assert_eq!(pdf.matches("/Width 3\n").count(), 1);
assert_eq!(pdf.matches("/Width 9\n").count(), 2);
assert_eq!(pdf.matches("/Width 31\n").count(), 1);
assert!(crate::test_support::bilevel_pixels(&sink.bytes).contains(&vec![0x80, 0, 0, 0, 0]));
}
#[test]
fn type3_dib_sink_and_cancellation_failures_keep_location() {
for mode in 0..3 {
let limits = Limits::default();
let mut record = type3_record(9, 3, 0, 0);
if mode == 0 {
record.bytes[4..8].copy_from_slice(&0_i32.to_le_bytes());
}
let f = fixture(Variant::C8, &[vec![record]]);
let mut source = Source::new(f.bytes);
let mut sink = Sink::default();
if mode == 1 {
sink.fail_after = Some(20);
}
let flag = Rc::new(Cell::new(mode == 2));
let error = convert_source_pages_pdf(
&mut source,
&mut sink,
Some(&table()),
&mut (),
ComposeOptions::default(),
&limits,
&Flag(flag),
)
.unwrap_err();
if mode == 0 {
located(&error, Variant::C8, Some(1), Some(1));
assert!(
(error.reason.starts_with("type-3 DIB: "))
&& stage_of(&error) == Some(Hnc8Stage::Headers),
"{error}"
);
} else if mode == 1 {
assert!(
std::error::Error::source(&error).is_some(),
"mode {mode}: {error}"
);
} else {
assert!(
matches!(error.kind, ErrorKind::Cancelled),
"mode {mode}: {error}"
);
}
assert!(!contains(&sink.bytes, b"%%EOF"));
}
}
#[test]
fn each_payload_is_read_at_most_twice_per_conversion() {
for record in [
type3_record(9, 3, 0, 0),
Record::jpeg(8, 8, 128, 0, 0),
Record::type0(&rows(9), 0, 0),
] {
let kind = record.kind;
let length = record.bytes.len() as u64;
let limits = Limits::default();
let mut f = fixture(Variant::C8, &[vec![record]]);
f.bytes.extend_from_slice(&[0; 64]);
let mut source = Source::new(f.bytes);
source.short = 7;
source.payload = Some((f.payloads[0][0], f.payloads[0][0] + length));
let mut sink = Sink::default();
convert_source_pages_pdf(
&mut source,
&mut sink,
Some(&table()),
&mut (),
ComposeOptions::default(),
&limits,
&NeverCancel,
)
.unwrap();
assert!(
source.payload_bytes_read <= 2 * length,
"type {kind}: {} payload bytes read for a {length}-byte span",
source.payload_bytes_read
);
}
}
#[test]
fn type3_anomaly_is_explicitly_opted_in_and_reported_per_image() {
use crate::jbig2::text::TextHeaderPolicy;
struct Anomalies(Vec<Option<TextHeaderAnomaly>>);
impl ComposeVisitor for Anomalies {
fn page(&mut self, page: ComposePage<'_>) -> crate::Result<()> {
self.0
.extend(page.images.iter().map(|i| i.type3_text_header_anomaly));
Ok(())
}
}
for policy in [
TextHeaderPolicy::Strict,
TextHeaderPolicy::HnC8UnusedRefinementTemplate,
] {
let limits = Limits::default();
let mut record = type3_record(3, 2, 0, 0);
record.bytes = type3_fixture::payload(3, 2, 0xa40c);
let f = fixture(Variant::C8, &[vec![record]]);
let mut source = Source::new(f.bytes);
let mut sink = Sink::default();
let mut visitor = Anomalies(Vec::new());
let result = convert_source_pages_pdf(
&mut source,
&mut sink,
None,
&mut visitor,
ComposeOptions {
text_header_policy: policy,
..Default::default()
},
&limits,
&NeverCancel,
);
if policy == TextHeaderPolicy::Strict {
assert!(result.is_err());
assert!(visitor.0.is_empty());
} else {
assert_eq!(result.unwrap().type3_images, 1);
assert_eq!(
visitor.0,
[Some(TextHeaderAnomaly::UnusedRefinementTemplate)]
);
}
}
}
fn repeated_raw(records: &[Record]) -> Vec<u8> {
image_records(&records[..2])
}
fn repeated_direct(records: &[Record]) -> Vec<u8> {
direct_text(&records[..2])
}
#[test]
fn repeated_payload_groups_collapse_to_identical_pdf_with_explicit_aliases() {
struct Aliases(Vec<Option<u32>>);
impl ComposeVisitor for Aliases {
fn page(&mut self, page: ComposePage<'_>) -> crate::Result<()> {
assert_eq!(page.output_page, Some(1));
for image in page.images {
self.0.push(image.duplicate_of);
if let Some(original) = image.duplicate_of {
assert_eq!(
image.transform,
page.images[original as usize - 1].transform
);
}
}
Ok(())
}
}
for (variant, framing) in [
(Variant::HnA, repeated_raw as fn(&[Record]) -> Vec<u8>),
(Variant::C8, repeated_direct),
] {
let limits = Limits::default();
let pair = [type3_record(3, 5, 0, 0), Record::jpeg(7, 4, 130, 2, 3)];
let mut reference = None;
for repetitions in [1, 3] {
let records = pair
.iter()
.cloned()
.cycle()
.take(pair.len() * repetitions)
.collect::<Vec<_>>();
let f = fixture_with_text(variant, &[records], framing);
let mut source = Source::new(f.bytes);
source.short = 11;
let mut sink = Sink::default();
let mut aliases = Aliases(Vec::new());
let report = convert_source_pages_pdf(
&mut source,
&mut sink,
None,
&mut aliases,
ComposeOptions::default(),
&limits,
&NeverCancel,
)
.unwrap();
assert_eq!(
(
report.source_pages,
report.output_pages,
report.type3_images,
report.jpeg_images
),
(1, 1, 1, 1)
);
assert_eq!(
report.duplicate_image_records,
((repetitions - 1) * 2) as u64
);
if repetitions == 1 {
reference = Some(sink.bytes);
assert_eq!(aliases.0, [None, None]);
} else {
assert_eq!(
sink.bytes,
reference.take().unwrap(),
"duplicates must not paint a second time"
);
assert_eq!(aliases.0, [None, None, Some(1), Some(2), Some(1), Some(2)]);
}
}
}
}
#[test]
fn repeated_groups_reject_conflicts_partial_groups_and_read_failures_before_draws() {
for mode in 0..8 {
let pair = vec![
Record::jpeg(512, 512, 90, 0, 0),
Record::jpeg(7, 4, 190, 2, 3),
];
let mut records = pair
.iter()
.cloned()
.cycle()
.take(pair.len() * 2)
.collect::<Vec<_>>();
match mode {
0 => records[2].kind = 0,
1 => records[2].bytes.push(0),
2 => {
let last = records[2].bytes.len() - 1;
records[2].bytes[last] ^= 1;
}
3 => {
records.pop();
}
_ => (),
}
let f = fixture_with_text(Variant::C8, &[records], repeated_direct);
let mut source = Source::new(f.bytes);
if (4..=6).contains(&mode) {
source.fault_at = Some((
f.payloads[0][2],
[Fault::Zero, Fault::Overreport, Fault::Io][mode - 4],
));
}
if mode == 7 {
let f = fixture_with_text(Variant::C8, &[pair], |_| direct_text(&[]));
source = Source::new(f.bytes);
}
let mut sink = Sink::default();
let error = convert(
&mut source,
&mut sink,
None,
&mut Visitor::default(),
ComposeOptions::default(),
&Limits::default(),
)
.unwrap_err();
assert_eq!(page_image(&error).0, Some(1));
if mode != 3 && mode != 7 {
assert_eq!(page_image(&error).1, Some(3));
}
if mode == 2 {
assert!(error.to_string().contains("payload differs"));
}
if mode < 2 {
assert!(error.to_string().contains("type or length differs"));
}
assert!(!contains(&sink.bytes, b"/Subtype /Image"));
assert!(!contains(&sink.bytes, b"%%EOF"));
}
}
#[test]
fn repeated_payload_comparison_cancels_and_propagates_first_read_failures() {
struct Stop(Cell<usize>);
impl Cancellation for Stop {
fn is_cancelled(&self) -> bool {
let remaining = self.0.get();
self.0.set(remaining.saturating_sub(1));
remaining == 0
}
}
let payload = vec![0x55; 3100];
let make_record = |number, offset| ImageRecord {
page_number: 1,
image_number: number,
descriptor_offset: offset - 12,
record_type: 2,
payload: super::super::Span {
offset,
length: payload.len() as u64,
},
};
let first = make_record(1, 12);
let second = make_record(2, 3124);
let at = At {
variant: Some(Variant::C8),
page: Some(1),
image: Some(2),
offset: Some(3112),
};
let mut bytes = vec![0; 12];
bytes.extend(&payload);
bytes.extend([0; 12]);
bytes.extend(&payload);
let limits = Limits::default();
let mut completed = false;
for checkpoints in 0..50 {
let mut source = Source::new(bytes.clone());
let result = verify_repeated_image(
&mut source,
first,
second,
at,
&limits,
&Stop(Cell::new(checkpoints)),
);
match result {
Ok(()) => {
completed = true;
break;
}
Err(error) => assert!(matches!(error.kind, ErrorKind::Cancelled)),
}
}
assert!(completed);
let mut source = Source::new(bytes);
source.fault_at = Some((0, Fault::Io));
let error =
verify_repeated_image(&mut source, first, second, at, &limits, &NeverCancel).unwrap_err();
assert!(matches!(error.kind, ErrorKind::Io(_)));
}
#[test]
fn source_page_and_image_extents_are_independent_of_decoded_pixels() {
for variant in [Variant::HnA, Variant::C8] {
let encoders = [text as fn(&[Record]) -> Vec<u8>, direct_text, image_records];
let count = if variant == Variant::HnA { 3 } else { 2 };
for &encode in &encoders[..count] {
for pixels in [(8, 8), (32, 16)] {
let mut record = Record::jpeg(pixels.0, pixels.1, 90, 7, 11);
record.coordinate.width = 12365;
record.coordinate.height = 2473;
let mut built = fixture_with_text(variant, &[vec![record]], encode);
let offset = if variant == Variant::C8 { 32 } else { 168 };
built.bytes[offset..offset + 2].copy_from_slice(&2473_u16.to_le_bytes());
built.bytes[offset + 2..offset + 4].copy_from_slice(&4946_u16.to_le_bytes());
let text_offset = built.text_offsets[0];
if variant == Variant::HnA && built.bytes[text_offset..text_offset + 2] == [3, 0x80]
{
built.bytes[text_offset + 2..text_offset + 4]
.copy_from_slice(&2473_u16.to_le_bytes());
built.bytes[text_offset + 6..text_offset + 8]
.copy_from_slice(&4946_u16.to_le_bytes());
}
let mut visitor = Visitor::default();
convert(
&mut Source::new(built.bytes),
&mut Sink::default(),
None,
&mut visitor,
ComposeOptions::default(),
&Limits::default(),
)
.unwrap();
let size = visitor.sizes[0].unwrap();
assert_eq!((size.width_points, size.height_points), (240.0, 480.0));
let transform = visitor.images[0].4;
assert_eq!(
transform,
[
1200.0,
0.0,
0.0,
-240.0,
7.0 * (240.0 / 2473.0),
480.0 - 11.0 * (240.0 / 2473.0)
]
);
assert_eq!(
(visitor.images[0].1, visitor.images[0].3),
(u32::from(pixels.0), u32::from(pixels.1))
);
}
}
}
}
#[test]
fn missing_page_or_image_extent_fails_before_emitting_image_data() {
for field in 0..4 {
let mut record = Record::jpeg(8, 8, 90, 0, 0);
if field == 2 {
record.coordinate.width = 0;
}
if field == 3 {
record.coordinate.height = 0;
}
let mut built = fixture(Variant::C8, &[vec![record]]);
if field < 2 {
built.bytes[32 + field * 2..34 + field * 2].fill(0);
}
let mut sink = Sink::default();
let mut visitor = Visitor::default();
let error = convert(
&mut Source::new(built.bytes),
&mut sink,
None,
&mut visitor,
ComposeOptions::default(),
&Limits::default(),
)
.unwrap_err();
assert_eq!(stage_of(&error), Some(Hnc8Stage::Geometry));
assert!(visitor.images.is_empty());
assert!(!contains(&sink.bytes, b"/Subtype /Image"));
}
}
fn mixed_codec_content_page() -> Vec<u8> {
#[derive(Default)]
struct Plan(Vec<ComposedImage>);
impl ComposeVisitor for Plan {
fn page(&mut self, page: ComposePage<'_>) -> crate::Result<()> {
self.0.extend_from_slice(page.images);
Ok(())
}
}
let pixels = rows(9);
let fixture = fixture(
Variant::C8,
&[vec![
Record::type0(&pixels, 0, 0),
Record::jpeg(8, 8, 155, 9, 0),
type3_record(3, 5, 18, 0),
]],
);
let mut source = Source::new(fixture.bytes);
source.short = 3;
let limits = Limits {
io_chunk_bytes: 64,
..Default::default()
};
let options = ComposeOptions::default();
let qm = table();
let mut buffers = ImageBuffers::default();
let mut plan = Plan::default();
let mut baseline = Sink::default();
let mut report = convert_source_pages_pdf(
&mut source,
&mut baseline,
Some(&qm),
&mut plan,
options,
&limits,
&NeverCancel,
)
.unwrap();
assert_eq!(plan.0.len(), 3);
let mut sink = Sink {
short: Some(7),
..Default::default()
};
{
let mut font_source = Source::new(crate::pdf::drawing_font());
let mut font =
crate::pdf::OpenTypeFont::read(&mut font_source, 0, &limits, &NeverCancel).unwrap();
let mut document = PdfDocument::new(&mut sink, &limits, &NeverCancel).unwrap();
let handle = document.add_font(&font).unwrap();
let mut handles = Vec::new();
for image in &mut plan.0 {
let type3 = match image.checked {
CheckedImage::Type3 => Some(
preflight_type3(
&mut source,
image.record,
At::NONE,
options.text_header_policy,
&limits,
&NeverCancel,
)
.unwrap(),
),
_ => None,
};
handles.push(
emit_image(
&mut source,
&mut document,
image,
type3,
At::NONE.image(image.record),
&mut buffers,
Some(&qm),
&limits,
&NeverCancel,
&mut report,
)
.unwrap(),
);
}
let fonts = [&handle];
let mut page = document
.begin_content_page(
PageSpec {
width_points: 120.0,
height_points: 100.0,
},
&fonts,
&handles,
)
.unwrap();
page.glyph(0, 'A', [20.0, 0.0, 0.0, 20.0, 10.0, 50.0])
.unwrap();
page.image(0, [18.0, 0.0, 0.0, 6.0, 12.0, 54.0]).unwrap();
page.segment([10.0, 55.0], [90.0, 55.0], 2.0).unwrap();
page.image(1, [16.0, 0.0, 0.0, 16.0, 20.0, 48.0]).unwrap();
page.glyph(0, 'ä¸', [20.0, 0.0, 0.0, 20.0, 30.0, 50.0])
.unwrap();
page.image(2, [6.0, 0.0, 0.0, 10.0, 34.0, 54.0]).unwrap();
page.fill_polygon(&[[32.0, 52.0], [42.0, 52.0], [37.0, 62.0]])
.unwrap();
page.finish().unwrap();
document.embed_font(&handle, &mut font).unwrap();
assert_eq!(document.finish().unwrap().pages_converted, 1);
};
assert_eq!(
(report.type0_images, report.jpeg_images, report.type3_images),
(2, 2, 2)
);
assert_eq!(
crate::test_support::bilevel_pixels(&baseline.bytes),
crate::test_support::bilevel_pixels(&sink.bytes)
);
let content = crate::test_support::pdf_text(&sink.bytes);
let operators = [
"<0041> Tj",
"/Im0 Do",
" l S Q",
"/Im1 Do",
"<4E2D> Tj",
"/Im2 Do",
"h f Q",
];
let positions: Vec<_> = operators
.iter()
.map(|operator| content.find(operator).unwrap())
.collect();
assert!(positions.windows(2).all(|pair| pair[0] < pair[1]));
let decoded = crate::test_support::bilevel_pixels(&sink.bytes);
assert_eq!(decoded[0], packed(&pixels));
assert_eq!(decoded[1], vec![0x80, 0, 0, 0, 0]);
sink.bytes
}
#[test]
fn decoded_images_share_a_content_page_with_glyphs_and_vectors() {
mixed_codec_content_page();
}
#[test]
fn independent_render_checks_decoded_images_in_native_content_order() {
let raster = render_original_pdf_at(&mixed_codec_content_page(), "72");
let header = b"P5\n120 100\n255\n";
assert!(raster.starts_with(header));
let pixels = &raster[header.len()..];
assert_eq!(pixels.len(), 120 * 100);
let pixel = |x: usize, y: usize| pixels[(99 - y) * 120 + x];
assert_eq!(pixel(11, 55), 0); assert_eq!(pixel(80, 55), 0); assert_eq!(pixel(25, 55), 155); assert_eq!(pixel(37, 55), 0); assert_eq!(pixel(110, 90), 255);
let raster = render_original_pdf_at(&super::super::native_page::mixed_page(), "741.9");
let header = b"P5\n600 600\n255\n";
assert!(raster.starts_with(header));
let pixels = &raster[header.len()..];
assert_eq!(pixels.len(), 600 * 600);
let pixel = |x: usize, y: usize| pixels[y * 600 + x];
assert_eq!(pixel(40, 50), 0);
assert_eq!(pixel(100, 50), 255);
assert_eq!(pixel(40, 80), 255);
assert_eq!(pixel(100, 80), 0);
assert_eq!(pixel(85, 120), 68); assert_eq!(pixel(160, 120), 68); assert_eq!(pixel(550, 550), 255);
}
#[test]
fn raw_hna_marked_images_keep_full_page_and_offset_geometry() {
fn marked(records: &[Record], prefix: bool) -> Vec<u8> {
let mut bytes = Vec::new();
if prefix {
for word in [0x8003_u16, 100, 0x8003, 200] {
bytes.extend(word.to_le_bytes());
}
}
for record in records {
for word in [
0x800a,
0xd300,
0xc000 | record.coordinate.x,
record.coordinate.y,
0xc000 | record.coordinate.width,
record.coordinate.height,
] {
bytes.extend(word.to_le_bytes());
}
bytes.extend([0; 16]);
}
bytes.extend(0x8004_u16.to_le_bytes());
bytes.extend([0; 2]);
bytes
}
let pages = [vec![
Record::jpeg(32, 24, 50, 0, 0),
Record::jpeg(28, 18, 210, 20, 30),
]];
let mut reference = Harness::new(Variant::HnA, &pages);
reference
.run(None, ComposeOptions::default(), &Limits::default())
.unwrap();
for make_text in [
(|records: &[Record]| marked(records, true)) as fn(&[Record]) -> Vec<u8>,
|records: &[Record]| marked(records, false),
] {
let fixture = fixture_with_text(Variant::HnA, &pages, make_text);
let mut source = Source::new(fixture.bytes);
let mut sink = Sink::default();
let report = convert(
&mut source,
&mut sink,
None,
&mut Visitor::default(),
ComposeOptions::default(),
&Limits::default(),
)
.unwrap();
assert_eq!(report.output_pages, 1);
assert_eq!(report.jpeg_images, 2);
assert_eq!(sink.bytes, reference.sink.bytes);
}
}
#[test]
fn hna_paired_page_sizes_override_header_per_page_in_raw_and_compressed_text() {
fn raw(records: &[Record]) -> Vec<u8> {
let mut bytes = vec![3, 0x80, 100, 0, 3, 0x80, 200, 0];
bytes.extend(image_records(records));
bytes
}
for make_text in [raw as fn(&[Record]) -> Vec<u8>, text] {
let pages = vec![vec![Record::jpeg(8, 4, 70, 10, 30)]; 2];
let mut fixture = fixture_with_text(Variant::HnA, &pages, make_text);
for (offset, [width, height]) in fixture
.text_offsets
.iter()
.zip([[400u16, 240u16], [320, 200]])
{
fixture.bytes[offset + 2..offset + 4].copy_from_slice(&width.to_le_bytes());
fixture.bytes[offset + 6..offset + 8].copy_from_slice(&height.to_le_bytes());
}
let mut source = Source::new(fixture.bytes);
source.short = 3;
let mut sink = Sink::default();
let mut visitor = Visitor::default();
convert(
&mut source,
&mut sink,
None,
&mut visitor,
ComposeOptions::default(),
&Limits::default(),
)
.unwrap();
let unit = 240.0 / 2473.0;
for (index, [width, height]) in [[400.0, 240.0], [320.0, 200.0]].into_iter().enumerate() {
let size = visitor.sizes[index].unwrap();
assert_eq!(size.width_points, width * unit);
assert_eq!(size.height_points, height * unit);
assert_eq!(
visitor.images[index].4,
[
80.0 * unit,
0.0,
0.0,
-40.0 * unit,
10.0 * unit,
(height - 30.0) * unit
]
);
}
}
}
#[test]
fn hna_zero_page_prefix_dimensions_fail_before_emitting_images() {
for field in [2, 6] {
let mut fixture = fixture(Variant::HnA, &[vec![Record::jpeg(8, 4, 70, 10, 30)]]);
let offset = fixture.text_offsets[0] + field;
fixture.bytes[offset..offset + 2].fill(0);
let mut sink = Sink::default();
let error = convert(
&mut Source::new(fixture.bytes),
&mut sink,
None,
&mut Visitor::default(),
ComposeOptions::default(),
&Limits::default(),
)
.unwrap_err();
assert_eq!(page_image(&error).0, Some(1));
assert_eq!(stage_of(&error), Some(Hnc8Stage::Geometry));
assert!(!contains(&sink.bytes, b"/Subtype /Image"));
}
}
mod native_document;
mod malformed;
mod application_info;
mod route;