pdfboss_jpx/lib.rs
1//! Cleanroom JPEG 2000 decoder for the PDF `JPXDecode` filter (ISO 32000
2//! 7.4.9), implemented purely from ITU-T T.800 (08/2002).
3//!
4//! Scope: JP2 box containers and raw codestreams; the full Annex A marker
5//! set; Tier-2 packet decoding with all five progression orders and POC;
6//! Tier-1 EBCOT (Annex D) over the Annex C MQ coder; Annex E
7//! dequantization with RGN maxshift; the Annex F 5-3 and 9-7 inverse
8//! wavelets; Annex G component transforms and Annex I palette/colour
9//! metadata.
10//!
11//! Contract: header-level problems (bad signature, unparsable SIZ/COD,
12//! exceeded [`DecodeLimits`]) are hard errors, and so is corruption that
13//! prevents ANY packet of the image from decoding. Once the first packet
14//! of the image has decoded, the decoder is lenient — a corrupt packet or
15//! code-block zeroes the remainder of its scope (at most its own tile;
16//! sibling tiles keep decoding), appends one warning to
17//! [`DecodedImage::warnings`], and decoding continues. Output samples are
18//! 8-bit: deeper sources are right-shifted to 8 with round-to-nearest,
19//! signed samples are level-shifted per T.800 G.1.2. The decoder never
20//! panics on hostile input and contains no `unsafe`.
21
22mod boxes;
23mod color;
24mod dequant;
25mod dwt;
26mod error;
27mod geometry;
28mod markers;
29mod mq;
30mod packet;
31mod t1;
32mod tagtree;
33
34pub use error::{JpxError, Result};
35
36/// Hard bounds on attacker-controlled allocation and work, checked before
37/// the corresponding allocations happen.
38#[derive(Clone, Copy, Debug)]
39pub struct DecodeLimits {
40 /// Maximum image-region pixels on the reference grid, per component:
41 /// `(Xsiz - XOsiz) * (Ysiz - YOsiz)` (T.800 A.5.1/B-2).
42 pub max_pixels: u64,
43 /// Maximum component count (SIZ Csiz allows up to 16384).
44 pub max_components: u16,
45 /// Maximum tile count `numXtiles * numYtiles` (Equation (B-5)).
46 pub max_tiles: u32,
47 /// Maximum bytes of decoded output, checked before allocation. Also
48 /// charged against: header-driven bookkeeping — Tier-2 codeword
49 /// segment records, and a 64-byte floor per precinct slot and per
50 /// code-block of every tile's Annex B partition (computed
51 /// arithmetically before the partition is built). Spec-legal headers
52 /// (PPx = PPy = 0 at r = 0, Table A.21) can otherwise describe
53 /// gigabytes of per-block metadata from a few dozen bytes; a
54 /// partition whose bookkeeping alone dwarfs any decodable output
55 /// fails fast with `LimitExceeded`.
56 pub max_decoded_bytes: u64,
57}
58
59impl Default for DecodeLimits {
60 fn default() -> Self {
61 DecodeLimits {
62 max_pixels: 1 << 27,
63 max_components: 16,
64 max_tiles: 65_535,
65 max_decoded_bytes: 1 << 30,
66 }
67 }
68}
69
70/// Colour interpretation of the decoded samples, from the JP2 colr box
71/// (T.800 I.5.3.3) or, for raw codestreams, guessed from the component
72/// count.
73#[non_exhaustive]
74#[derive(Clone, Copy, PartialEq, Eq, Debug)]
75pub enum ColorKind {
76 /// Single-channel greyscale (EnumCS 17, or 1 component).
77 Gray,
78 /// Three-channel RGB (EnumCS 16 sRGB, sYCC already converted, or 3
79 /// components).
80 Rgb,
81 /// Four-channel CMYK: the component-count guess for a RAW codestream
82 /// with four channels (no colr box exists to say otherwise).
83 Cmyk,
84 /// A restricted ICC profile (T.800 I.5.3.3 METH 2) this decoder does
85 /// not interpret itself: colour approximated by component count, with
86 /// the profile bytes exported on [`DecodedImage::icc_profile`] for the
87 /// consumer to apply; a warning records the guess.
88 IccGuess {
89 /// Colour channel count the guess is based on.
90 components: u8,
91 },
92 /// An enumerated colourspace this crate does not convert.
93 Other {
94 /// The EnumCS value (I.5.3.3).
95 enumeration: u32,
96 /// Colour channel count.
97 components: u8,
98 },
99}
100
101/// One soft finding attached to a [`DecodedImage`] (leniency doctrine).
102#[derive(Clone, PartialEq, Eq, Debug)]
103pub struct JpxWarning {
104 /// Human-readable description, citing the ITU-T T.800 clause where
105 /// one applies. Free-form: consumers must not parse it.
106 pub message: String,
107 /// The machine-readable contract: `true` means decoded pixels are
108 /// wrong or missing — a corrupt code-block kept partially decoded,
109 /// zeroed packets or tiles, a skipped component transform, stream
110 /// truncation, dropped or out-of-range tile-parts, or a zero-filled
111 /// channel. `false` means a benign note that left every decoded
112 /// sample intact: compatibility tolerances, header disagreements
113 /// resolved by precedence, colour approximations, the Equation (E-5)
114 /// fallback. Consumers judging whether the pixels can be trusted
115 /// must key on this field, never on `message` text.
116 pub data_loss: bool,
117}
118
119impl JpxWarning {
120 /// A benign note: every decoded sample is intact.
121 pub(crate) fn note(message: impl Into<String>) -> JpxWarning {
122 JpxWarning {
123 message: message.into(),
124 data_loss: false,
125 }
126 }
127
128 /// A data-loss finding: decoded pixels are wrong or missing.
129 pub(crate) fn loss(message: impl Into<String>) -> JpxWarning {
130 JpxWarning {
131 message: message.into(),
132 data_loss: true,
133 }
134 }
135}
136
137/// A fully decoded image.
138#[derive(Clone, Debug)]
139pub struct DecodedImage {
140 /// Image-region width: `Xsiz - XOsiz` after the canvas crop (T.800 B-1).
141 pub width: u32,
142 /// Image-region height: `Ysiz - YOsiz`.
143 pub height: u32,
144 /// Channel count after palette and component transforms, including any
145 /// alpha channel.
146 pub components: u8,
147 /// Interleaved samples, 8-bit normalized, row-major,
148 /// `width * height * components` bytes.
149 pub samples: Vec<u8>,
150 /// Per output channel (parallel to the `samples` interleaving, alpha
151 /// included): the bit depth of the channel's source BEFORE the 8-bit
152 /// normalization — the component's Ssiz depth (T.800 Table A.11) for
153 /// direct channels, the palette column's depth (Table I.13) for
154 /// palette-mapped ones. The machine-readable contract for reversing
155 /// the normalization, e.g. recovering palette indices for a PDF
156 /// Indexed colorspace (ISO 32000 7.4.9). Length equals `components`.
157 pub component_depths: Vec<u8>,
158 /// Colour interpretation of the colour channels.
159 pub color: ColorKind,
160 /// The embedded ICC profile, byte for byte, when the colr box carried
161 /// one (T.800 I.5.3.3 METH 2, always paired with
162 /// [`ColorKind::IccGuess`]). This crate ships the bytes without
163 /// interpreting them; a consumer applying the profile supersedes the
164 /// component-count guess.
165 pub icc_profile: Option<Vec<u8>>,
166 /// Channel index of the opacity channel, when the JP2 cdef box defines
167 /// one (T.800 I.5.3.6, association 0 with type 1 or 2).
168 pub alpha_index: Option<u8>,
169 /// Soft failures encountered after headers parsed (leniency
170 /// doctrine); each one is classified by [`JpxWarning::data_loss`].
171 pub warnings: Vec<JpxWarning>,
172}
173
174/// Decodes a JPEG 2000 image (JP2 file or raw codestream) into 8-bit
175/// interleaved samples.
176///
177/// This is the crate's single entry point (sans-I/O: the caller supplies
178/// the bytes). See the crate docs for the error-vs-warning contract.
179pub fn decode(data: &[u8], limits: &DecodeLimits) -> Result<DecodedImage> {
180 // Container sniff + box walk (T.800 Annex I) -> codestream slice.
181 let container = boxes::scan(data, limits)?;
182 // Main header + every tile-part header/body (Annex A).
183 let cs = markers::parse_codestream(container.codestream, limits)?;
184 let siz = &cs.main.siz;
185 validate_limits(siz, limits)?;
186
187 let mut warnings = container.warnings;
188 warnings.extend(cs.warnings.iter().cloned());
189
190 let (tiles_wide, tiles_high) = geometry::tile_grid(siz)?;
191 let tile_total = u64::from(tiles_wide) * u64::from(tiles_high);
192
193 // Group tile-parts by tile, keeping codestream appearance order within
194 // each tile. A.4.2 requires TNsot to be the correct count or zero, but
195 // real-world streams ship MORE tile-parts than declared; the extras
196 // decode anyway (deliberate compatibility choice), recorded in one
197 // summary warning per codestream.
198 let mut tiles: Vec<Vec<(usize, &markers::TilePart<'_>)>> =
199 (0..tile_total).map(|_| Vec::new()).collect();
200 for (pos, part) in cs.tile_parts.iter().enumerate() {
201 let index = u64::from(part.sot.tile_index);
202 if index >= tile_total {
203 // Its packets never paint: whatever tile they belonged to is
204 // damaged, so this is pixel loss.
205 warnings.push(JpxWarning::loss(format!(
206 "tile-part for out-of-range tile {index} skipped"
207 )));
208 continue;
209 }
210 tiles[index as usize].push((pos, part));
211 }
212 if let Some(warning) = tnsot_compatibility_warning(&tiles) {
213 // Compatibility tolerance: the surplus tile-parts DECODE, so no
214 // pixel is lost.
215 warnings.push(JpxWarning::note(warning));
216 }
217 // Tiles the codestream never delivered render as background — pixels
218 // are missing, so the condition must be visible to the caller.
219 let missing_tiles = tiles.iter().filter(|parts| parts.is_empty()).count();
220 if missing_tiles > 0 {
221 warnings.push(JpxWarning::loss(format!(
222 "{missing_tiles} tile(s) have no tile-parts; rendered as background"
223 )));
224 }
225
226 // PPM packed headers split per tile-part appearance order (A.7.4).
227 let ppm_blobs = if cs.main.ppm.is_empty() {
228 None
229 } else {
230 Some(markers::split_packed_headers(
231 &cs.main.ppm,
232 cs.tile_parts.len(),
233 )?)
234 };
235
236 let mut assembler = color::ImageAssembler::new(siz, container.header.as_ref(), limits)?;
237 // The hard/soft leniency boundary is per image: once ANY tile decodes
238 // a packet, later corruption softens (crate docs).
239 let mut image_packets_decoded = false;
240 // Tile-components whose QCD/QCC lists fewer sub-band entries than the
241 // decomposition describes; summarized as one (E-5) note per image.
242 let mut short_quant_components = 0u64;
243 for (tile_index, parts) in tiles.iter().enumerate() {
244 if parts.is_empty() {
245 // A tile without tile-parts renders as background; the
246 // summary warning above already recorded the data loss.
247 continue;
248 }
249 // A.4.2 requires TPsot order within a tile; be lenient and keep the
250 // appearance order, but say so.
251 if parts
252 .windows(2)
253 .any(|pair| pair[0].1.sot.tile_part_index > pair[1].1.sot.tile_part_index)
254 {
255 // Every part still decodes, just in appearance order: benign.
256 warnings.push(JpxWarning::note(format!(
257 "tile {tile_index}: tile-parts out of TPsot order; using appearance order"
258 )));
259 }
260
261 let part_refs: Vec<&markers::TilePart<'_>> = parts.iter().map(|(_, part)| *part).collect();
262 let overrides = markers::merge_tile_overrides(&part_refs)?;
263 let tile_coding = markers::resolve_tile_coding(&cs.main, &overrides)?;
264
265 // Tile index -> grid position (Equation (B-6)) -> tile rect
266 // (Equations (B-7)..(B-10)).
267 let p = tile_index as u32 % tiles_wide;
268 let q = tile_index as u32 / tiles_wide;
269 let tile_rect = geometry::tile_rect(siz, p, q);
270 if tile_rect.is_empty() {
271 continue;
272 }
273
274 // Bound the tile's Tier-2 bookkeeping BEFORE any of it is
275 // allocated: the precinct/code-block counts follow arithmetically
276 // from the Annex B partition equations, and spec-legal headers
277 // (PPx = PPy = 0 at r = 0, Table A.21) can describe gigabytes of
278 // per-block metadata from a few dozen bytes. The cost is charged
279 // against max_decoded_bytes (see partition_metadata_cost).
280 let mut codings = Vec::with_capacity(siz.components.len());
281 let mut metadata_cost = 0u64;
282 for (index, component) in siz.components.iter().enumerate() {
283 let coding = markers::resolve_component_coding(&cs.main, &overrides, index as u16)?;
284 metadata_cost = metadata_cost.saturating_add(geometry::partition_metadata_cost(
285 tile_rect,
286 component,
287 &coding.style,
288 )?);
289 if coding.quant.short_for(coding.style.decomposition_levels) {
290 short_quant_components += 1;
291 }
292 codings.push(coding);
293 }
294 if metadata_cost > limits.max_decoded_bytes {
295 return Err(JpxError::LimitExceeded {
296 what: "max_decoded_bytes",
297 actual: metadata_cost,
298 limit: limits.max_decoded_bytes,
299 });
300 }
301 let mut components = Vec::with_capacity(siz.components.len());
302 for (component, coding) in siz.components.iter().zip(codings) {
303 let geometry = geometry::tile_component_geometry(tile_rect, component, &coding.style)?;
304 components.push(packet::ComponentContext {
305 geometry,
306 coding,
307 xrsiz: component.xrsiz,
308 yrsiz: component.yrsiz,
309 });
310 }
311 // Table A.17 keys the MCT on the FILTER of the components it
312 // spans (0..3): resolve their common wavelet, `None` when they
313 // disagree (an illegal pairing the colour stage warns about).
314 let mct_wavelet = if components.len() >= 3 {
315 let wavelet = components[0].coding.style.wavelet;
316 components[1..3]
317 .iter()
318 .all(|component| component.coding.style.wavelet == wavelet)
319 .then_some(wavelet)
320 } else {
321 None
322 };
323
324 // Packets flow across tile-part boundaries: concatenate bodies in
325 // decoding order (B.11).
326 let bitstream: Vec<u8> = parts
327 .iter()
328 .flat_map(|(_, part)| part.body.iter().copied())
329 .collect();
330 let packed_headers = packed_headers_for_tile(parts, &overrides, ppm_blobs.as_deref());
331
332 let ctx = packet::TileDecodeContext {
333 components,
334 tile_rect,
335 progression: tile_coding.progression,
336 layers: tile_coding.layers,
337 poc: tile_coding.poc.clone(),
338 sop_markers: tile_coding.sop_markers,
339 eph_markers: tile_coding.eph_markers,
340 bitstream: &bitstream,
341 packed_headers: packed_headers.as_deref(),
342 };
343 let mut packets = packet::read_tile_packets(&ctx, limits, image_packets_decoded)?;
344 image_packets_decoded |= packets.packets_decoded > 0;
345 warnings.extend(packets.warnings.drain(..).map(|warning| JpxWarning {
346 message: format!("tile {tile_index}: {}", warning.message),
347 data_loss: warning.data_loss,
348 }));
349
350 let mut canvases = Vec::with_capacity(ctx.components.len());
351 for (index, context) in ctx.components.iter().enumerate() {
352 let component_packets = packets
353 .components
354 .get(index)
355 .ok_or_else(|| JpxError::Malformed("tier-2 produced too few components".into()))?;
356 let mut bands = Vec::with_capacity(component_packets.bands.len());
357 for band in &component_packets.bands {
358 let mut blocks = Vec::with_capacity(band.blocks.len());
359 for block in &band.blocks {
360 let coefficients = t1::decode_code_block(block, &bitstream)?;
361 if coefficients.corrupt {
362 // One warning per damaged block; its partially
363 // decoded coefficients stay (leniency doctrine),
364 // and the missing passes are pixel loss.
365 warnings.push(JpxWarning::loss(format!(
366 "tile {tile_index} component {index}: corrupt code-block \
367 [{}, {}) x [{}, {}) kept partially decoded",
368 block.rect.x0, block.rect.x1, block.rect.y0, block.rect.y1,
369 )));
370 }
371 blocks.push(coefficients);
372 }
373 bands.push(t1::BandCoefficients {
374 kind: band.kind,
375 level: band.level,
376 rect: band.rect,
377 blocks,
378 });
379 }
380 let mut canvas = dequant::dequantize_tile_component(
381 &context.geometry,
382 &context.coding,
383 &siz.components[index],
384 &bands,
385 limits,
386 )?;
387 dwt::inverse(&mut canvas)?;
388 canvases.push(canvas);
389 }
390 assembler.push_tile(tile_rect, tile_coding.mct, mct_wavelet, canvases)?;
391 }
392 if short_quant_components > 0 {
393 // The (E-5) fallback decodes every coefficient: benign.
394 warnings.push(JpxWarning::note(format!(
395 "{short_quant_components} tile-component(s) signal fewer QCD/QCC sub-band \
396 entries than their decomposition describes; missing step sizes derived \
397 from the first entry via Equation (E-5)"
398 )));
399 }
400 assembler.finish(warnings)
401}
402
403/// One summary warning per codestream when tiles ship more tile-parts
404/// than their declared TNsot. T.800 A.4.2 allows exactly two TNsot
405/// values — the CORRECT tile-part count or zero — so these streams
406/// violate the spec; decoding the surplus anyway is this crate's
407/// deliberate compatibility choice (real-world encoders produce them),
408/// and this note records both facts.
409///
410/// A tile counts as affected when it holds more parts than some declared
411/// TNsot, or when a TPsot index reaches the declared count (a surplus
412/// index can appear even in a truncated tile that kept few parts).
413fn tnsot_compatibility_warning(tiles: &[Vec<(usize, &markers::TilePart<'_>)>]) -> Option<String> {
414 let affected = tiles
415 .iter()
416 .filter(|parts| {
417 parts.iter().any(|(_, part)| {
418 let declared = part.sot.tile_part_count;
419 declared != 0
420 && (parts.len() > usize::from(declared) || part.sot.tile_part_index >= declared)
421 })
422 })
423 .count();
424 if affected == 0 {
425 return None;
426 }
427 Some(format!(
428 "{affected} tile(s) ship more tile-parts than their declared TNsot \
429 (violates T.800 A.4.2); tolerated for compatibility with \
430 real-world encoders"
431 ))
432}
433
434/// Enforces [`DecodeLimits`] against the SIZ header before any
435/// size-derived allocation (`max_decoded_bytes` is enforced later, by the
436/// colour stage, right before the output buffer is sized).
437fn validate_limits(siz: &markers::Siz, limits: &DecodeLimits) -> Result<()> {
438 let image = geometry::Rect {
439 x0: siz.xosiz,
440 y0: siz.yosiz,
441 x1: siz.xsiz,
442 y1: siz.ysiz,
443 };
444 let pixels = u64::from(image.width()) * u64::from(image.height());
445 if pixels > limits.max_pixels {
446 return Err(JpxError::LimitExceeded {
447 what: "max_pixels",
448 actual: pixels,
449 limit: limits.max_pixels,
450 });
451 }
452 let components = siz.components.len() as u64;
453 if components > u64::from(limits.max_components) {
454 return Err(JpxError::LimitExceeded {
455 what: "max_components",
456 actual: components,
457 limit: u64::from(limits.max_components),
458 });
459 }
460 let (tiles_wide, tiles_high) = geometry::tile_grid(siz)?;
461 let tiles = u64::from(tiles_wide) * u64::from(tiles_high);
462 if tiles > u64::from(limits.max_tiles) {
463 return Err(JpxError::LimitExceeded {
464 what: "max_tiles",
465 actual: tiles,
466 limit: u64::from(limits.max_tiles),
467 });
468 }
469 Ok(())
470}
471
472/// Selects the packed packet headers for one tile: PPM blobs (already
473/// split per tile-part, A.7.4) win over PPT segments (A.7.5); `None` means
474/// the packet headers sit in the tile bit stream itself.
475fn packed_headers_for_tile(
476 parts: &[(usize, &markers::TilePart<'_>)],
477 overrides: &markers::TileOverrides,
478 ppm_blobs: Option<&[Vec<u8>]>,
479) -> Option<Vec<u8>> {
480 if let Some(blobs) = ppm_blobs {
481 let mut buffer = Vec::new();
482 for (pos, _) in parts {
483 if let Some(blob) = blobs.get(*pos) {
484 buffer.extend_from_slice(blob);
485 }
486 }
487 return Some(buffer);
488 }
489 if overrides.ppt.is_empty() {
490 return None;
491 }
492 // merge_tile_overrides already ordered the PPT segments (decoding
493 // order, Zppt-sorted within each tile-part header).
494 Some(
495 overrides
496 .ppt
497 .iter()
498 .flat_map(|segment| segment.data.iter().copied())
499 .collect(),
500 )
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 /// The B.4 worked example SIZ (see geometry::tests): 1432 x 954 grid,
508 /// image offset (152, 234), 396 x 297 tiles, two components.
509 fn example_siz() -> markers::Siz {
510 markers::Siz {
511 rsiz: 0,
512 xsiz: 1432,
513 ysiz: 954,
514 xosiz: 152,
515 yosiz: 234,
516 xtsiz: 396,
517 ytsiz: 297,
518 xtosiz: 0,
519 ytosiz: 0,
520 components: vec![
521 markers::SizComponent {
522 depth: 8,
523 signed: false,
524 xrsiz: 1,
525 yrsiz: 1,
526 },
527 markers::SizComponent {
528 depth: 8,
529 signed: false,
530 xrsiz: 2,
531 yrsiz: 2,
532 },
533 ],
534 }
535 }
536
537 #[test]
538 fn limits_default_to_the_documented_bounds() {
539 let limits = DecodeLimits::default();
540 assert_eq!(limits.max_pixels, 134_217_728); // 1 << 27
541 assert_eq!(limits.max_components, 16);
542 assert_eq!(limits.max_tiles, 65_535);
543 assert_eq!(limits.max_decoded_bytes, 1_073_741_824); // 1 << 30
544 }
545
546 #[test]
547 fn validate_limits_accepts_the_b4_example_under_defaults() {
548 validate_limits(&example_siz(), &DecodeLimits::default()).unwrap();
549 }
550
551 #[test]
552 fn validate_limits_measures_the_image_region() {
553 // Image region: (1432 - 152) x (954 - 234) = 1280 x 720 = 921 600
554 // reference-grid pixels.
555 let limits = DecodeLimits {
556 max_pixels: 921_599,
557 ..DecodeLimits::default()
558 };
559 match validate_limits(&example_siz(), &limits) {
560 Err(JpxError::LimitExceeded {
561 what,
562 actual,
563 limit,
564 }) => {
565 assert_eq!(what, "max_pixels");
566 assert_eq!(actual, 921_600);
567 assert_eq!(limit, 921_599);
568 }
569 other => panic!("expected max_pixels breach, got {other:?}"),
570 }
571 }
572
573 #[test]
574 fn validate_limits_counts_components_and_tiles() {
575 // Two components; 4 x 4 = 16 tiles (B.4 example).
576 let limits = DecodeLimits {
577 max_components: 1,
578 ..DecodeLimits::default()
579 };
580 assert!(matches!(
581 validate_limits(&example_siz(), &limits),
582 Err(JpxError::LimitExceeded {
583 what: "max_components",
584 actual: 2,
585 ..
586 })
587 ));
588 let limits = DecodeLimits {
589 max_tiles: 15,
590 ..DecodeLimits::default()
591 };
592 assert!(matches!(
593 validate_limits(&example_siz(), &limits),
594 Err(JpxError::LimitExceeded {
595 what: "max_tiles",
596 actual: 16,
597 ..
598 })
599 ));
600 }
601
602 /// A minimal tile-part carrying only the SOT fields the TNsot
603 /// compatibility summary inspects.
604 fn part(tile_part_index: u8, tile_part_count: u8) -> markers::TilePart<'static> {
605 markers::TilePart {
606 sot: markers::Sot {
607 tile_index: 0,
608 tile_part_length: 0,
609 tile_part_index,
610 tile_part_count,
611 },
612 overrides: markers::TileOverrides::default(),
613 body: &[],
614 }
615 }
616
617 fn grouped<'a>(
618 tiles: &'a [Vec<markers::TilePart<'a>>],
619 ) -> Vec<Vec<(usize, &'a markers::TilePart<'a>)>> {
620 tiles
621 .iter()
622 .map(|parts| parts.iter().enumerate().collect())
623 .collect()
624 }
625
626 #[test]
627 fn tnsot_summary_is_silent_when_the_declared_counts_hold() {
628 // Matching counts, an unsignalled TNsot = 0, and an empty tile all
629 // stay silent.
630 let tiles = vec![
631 vec![part(0, 2), part(1, 2)],
632 vec![part(0, 0), part(1, 0)],
633 vec![],
634 ];
635 assert_eq!(tnsot_compatibility_warning(&grouped(&tiles)), None);
636 }
637
638 #[test]
639 fn tnsot_summary_counts_affected_tiles_once_per_codestream() {
640 // Tile 0: three parts against a declared TNsot of 2. Tile 1: only
641 // one part kept, but its TPsot = 5 sits beyond the declared 2.
642 // Tile 2 is honest. One warning, two tiles counted.
643 let tiles = vec![
644 vec![part(0, 2), part(1, 2), part(2, 2)],
645 vec![part(5, 2)],
646 vec![part(0, 1)],
647 ];
648 assert_eq!(
649 tnsot_compatibility_warning(&grouped(&tiles)).as_deref(),
650 Some(
651 "2 tile(s) ship more tile-parts than their declared TNsot \
652 (violates T.800 A.4.2); tolerated for compatibility with \
653 real-world encoders"
654 )
655 );
656 }
657}