Skip to main content

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}