diff --git a/docs/known-gaps.md b/docs/known-gaps.md index 9e2013b..20201e1 100644 --- a/docs/known-gaps.md +++ b/docs/known-gaps.md @@ -53,7 +53,7 @@ HEIF 解码也已调优(M3),基准项目是 `Sdcb.SimdCodecs.Heif.Benchmar 0..255(线性光数据如增益图 HDR 再加 `srgbEncode: true`),但不做色调映射。 - **多帧读取器的帧数可能少于文件里的帧数。** `ImageInfo.FrameCount` 报告文件里有多少帧或页,但: - APNG:解码还没实现,读取器只给出 PNG 的默认图(1 帧); - - JBIG2:独立 `.jb2` 文件只解码第 1 页,读取器只有 1 帧。 + - JBIG2:门面的读取器只给出独立 `.jb2` 文件的第 1 页(1 帧);其他页用 `Jbig2Decoder.DecodeFile(file, pageNumber)`。 - **接口里没有“时间戳 → 帧号”的辅助方法。** WebP 包的 `WebpAnimationDecoder.FrameIndexAt` 有,接口层没有; 需要时可以按各帧的 `Duration` 自己累加。 - **`IImageFrameReader` 和 `ImageDecoder` 会话都不是线程安全的**(动画需要在调用之间保留画布状态),每个线程各开一个。 diff --git a/docs/known-gaps/jbig2.md b/docs/known-gaps/jbig2.md index 2086601..9c02af3 100644 --- a/docs/known-gaps/jbig2.md +++ b/docs/known-gaps/jbig2.md @@ -2,10 +2,19 @@ 条目约定(“不支持”“门面不提供”“未调优”)和跨格式的门面限制见 [总览](../known-gaps.md)。 -- **独立 `.jb2` 文件只解码第 1 页。** PDF 内嵌的 JBIG2 流没有文件头可供识别,需要直接调用 - `Jbig2Decoder.Decode(embedded, globals, width, height)`,门面不处理。 +- **门面只解码独立 `.jb2` 文件的第 1 页。** 其他页用 `Jbig2Decoder.DecodeFile(file, pageNumber)`,页数由 + `Jbig2Decoder.TryReadFileInfo(file, out w, out h, out pages)` 给出。PDF 内嵌的 JBIG2 流没有文件头可供识别, + 需要直接调用 `Jbig2Decoder.Decode(embedded, globals, width, height)`,门面不处理。 - **不支持**: - EXTTEMPLATE 通用区域; - - 使用 HENABLESKIP 的半色调区域; - - MMR 编码的半色调区域和图案字典。 + - 引用或保留算术上下文的符号字典(符号字典标志的 bit 8、9); + - MMR 的 2D 扩展码(含 T.6 非压缩模式)。 +- **Huffman 编码只有部分覆盖。** 标准表、自定义表段、未压缩和 MMR 的高度类集合位图、Huffman 文本区域,都有 + `Jbig2ExtendedOracleTests` 用手工组装的码流对照 jbig2dec。Huffman 符号字典里的精细化 / 聚合符号(SDHUFF 与 + SDREFAGG 同时为 1)、Huffman 文本区域里的逐实例精细化,没有黑盒编码器能造流,只有 issue #1 语料里的 + `042_14.jb2` 用到,而 jbig2dec 和本解码器都判它为坏流,所以这两条路径没有经过对照验证。标准表 B.7 的码长 + 之和是 15/16(不满),也只在单元测试里检查过。 +- issue #1 附件里的 114 个 `.jb2` 文件(来源和许可不明,不进仓库)由 `Issue1Jbig2CorpusTests` 在本地回归: + 设置 `SIMDCODECS_ISSUE1_DIR` 指向解压目录即可。112 个和 jbig2dec 逐页逐像素一致;`042_13`、`042_14` 两个 + jbig2dec 和本解码器都在同一处判为坏流,本解码器抛 `InvalidDataException`。 - 未调优。 diff --git a/docs/known-gaps/jpeg2000.md b/docs/known-gaps/jpeg2000.md index f477229..13a9245 100644 --- a/docs/known-gaps/jpeg2000.md +++ b/docs/known-gaps/jpeg2000.md @@ -2,14 +2,22 @@ 条目约定(“不支持”“门面不提供”“未调优”)和跨格式的门面限制见 [总览](../known-gaps.md)。 -- **只支持 Part 1(T.800),定位是 PDF 里的 `/JPXDecode`。** +- **只支持 Part 1(T.800),定位是 PDF 里的 `/JPXDecode`。** 多 tile、tile-part、自定义 precinct、 + 五种进度顺序、SOP / EPH 标记、JP2 调色板(`pclr` + `cmap`)都已支持。 - **不支持**: - 进度顺序变更(POC); - 打包的包头(PPM、PPT); - 感兴趣区域(RGN / MAXSHIFT); - - 在 tile-part 头里覆盖主头参数的标记。 -- **分量位深最多 16 位,且所有分量位深必须相同。** + - 在 tile-part 头里覆盖主头参数的标记(COD / COC / QCD / QCC); + - code-block 样式里的选择性旁路(0x01)、每个 pass 单独终止(0x04)、垂直因果上下文(0x08)。 + 上下文重置(0x02)、可预测终止(0x10)、分段符号(0x20)已支持; + - JP2 的通道定义盒(`cdef`); + - 分量子采样、带符号分量。 +- **分量位深最多 16 位,且所有分量位深必须相同**;经调色板映射后的各通道位深也必须相同。 - **门面只呈现 1 或 3 个分量、且不是 sYCC 的图像**;带 alpha、CMYK、sYCC 的图门面返回 `false`, `Jpeg2000Decoder.Decode` 仍会返回全部分量。 -- **没有分辨率缩减(reduce)解码。** -- **已有基准测试,性能待调优**:基准项目 `Sdcb.SimdCodecs.Jpeg2000.Benchmarks` 已建立并在 `results/` 记录初始基线;目前纯托管单核解码耗时约为 libopenjp2 纯 C 核心的 1.9×~2.2×;EBCOT 条带位掩码快速跳过、Code-block 级多线程并发与系数重构缓冲区复用尚未实现。 +- **门面不提供分辨率缩减(reduce)**;`Jpeg2000Decoder.Decode(data, reduce)` 可以直接调用。 +- **真实文件回归**:issue #1 附带的 16 个 JPEG 2000 文件(来源和许可不明,不进仓库)全部与 + `opj_decompress` 一致(5/3 逐样本相同,9/7 最多差 1)。改动解码器后按 `Issue1Jpeg2000CorpusTests` + 的说明下载并跑一遍。 +- **已有基准测试,性能待调优**:基准项目 `Sdcb.SimdCodecs.Jpeg2000.Benchmarks` 已建立并在 `results/` 记录初始基线;目前纯托管单核解码耗时约为 libopenjp2 纯 C 核心的 1.9×~2.2×;EBCOT 条带位掩码快速跳过、Code-block 级多线程并发与系数重构缓冲区复用尚未实现。tile 之间是串行解码的。 diff --git a/src/Sdcb.SimdCodecs.Jbig2/GenericRegionDecoder.cs b/src/Sdcb.SimdCodecs.Jbig2/GenericRegionDecoder.cs index 9250695..3d34bf9 100644 --- a/src/Sdcb.SimdCodecs.Jbig2/GenericRegionDecoder.cs +++ b/src/Sdcb.SimdCodecs.Jbig2/GenericRegionDecoder.cs @@ -97,6 +97,11 @@ internal static class GenericRegionDecoder /// /// The decode's remaining pixel allowance, charged before anything is allocated. /// + /// + /// USESKIP / SKIP of T.88 §6.2.5.7: where this bitmap holds a 1 the pixel is not + /// coded at all and is set to 0. Null when USESKIP is 0. Must be + /// x . + /// public static Jbig2Bitmap Decode( ref MqDecoder mq, scoped Span contexts, @@ -105,8 +110,11 @@ public static Jbig2Bitmap Decode( int template, bool typicalPrediction, scoped ReadOnlySpan at, - Jbig2PixelBudget budget) + Jbig2PixelBudget budget, + Jbig2Bitmap? skip = null) { + if (skip is not null && (skip.Width != width || skip.Height != height)) + throw new ArgumentException("The SKIP bitmap must match the region size.", nameof(skip)); var expectedAt = template == 0 ? 8 : 2; if (at.Length < expectedAt) throw new ArgumentException($"GBTEMPLATE {template} needs {expectedAt / 2} AT pixel(s).", nameof(at)); @@ -138,8 +146,22 @@ public static Jbig2Bitmap Decode( } var row = y * width; - for (var x = 0; x < width; x++) - bitmap.Data[row + x] = (byte)mq.Decode(contexts, Context(bitmap, x, y, template, at)); + if (skip is null) + { + for (var x = 0; x < width; x++) + bitmap.Data[row + x] = (byte)mq.Decode(contexts, Context(bitmap, x, y, template, at)); + } + else + { + // §6.2.5.7 step 3 c): a skipped pixel costs no decision and reads as 0. + // The bitmap is freshly zeroed, so leaving it alone is setting it to 0. + var skipped = skip.Data; + for (var x = 0; x < width; x++) + { + if (skipped[row + x] != 0) continue; + bitmap.Data[row + x] = (byte)mq.Decode(contexts, Context(bitmap, x, y, template, at)); + } + } } return bitmap; diff --git a/src/Sdcb.SimdCodecs.Jbig2/HalftoneDecoder.cs b/src/Sdcb.SimdCodecs.Jbig2/HalftoneDecoder.cs index 1cf007c..a63ffee 100644 --- a/src/Sdcb.SimdCodecs.Jbig2/HalftoneDecoder.cs +++ b/src/Sdcb.SimdCodecs.Jbig2/HalftoneDecoder.cs @@ -56,6 +56,23 @@ public static Jbig2Bitmap[] DecodePatternDictionary( ref mq, contexts, count * patternWidth, patternHeight, template, typicalPrediction: false, CollectiveAt(patternWidth), budget); + return SlicePatterns(collective, count, patternWidth, patternHeight); + } + + /// + /// + /// for HDMMR = 1: the collective bitmap is MMR-coded (T.88 §6.7.5, Table 27). + /// + public static Jbig2Bitmap[] DecodePatternDictionaryMmr( + ReadOnlySpan data, int patternWidth, int patternHeight, int maxIndex, Jbig2PixelBudget budget) + { + var count = maxIndex + 1; + var collective = MmrDecoder.Decode(data, count * patternWidth, patternHeight, budget); + return SlicePatterns(collective, count, patternWidth, patternHeight); + } + + private static Jbig2Bitmap[] SlicePatterns(Jbig2Bitmap collective, int count, int patternWidth, int patternHeight) + { var patterns = new Jbig2Bitmap[count]; for (var i = 0; i < count; i++) patterns[i] = collective.Crop(i * patternWidth, 0, patternWidth, patternHeight); @@ -75,6 +92,8 @@ public static Jbig2Bitmap[] DecodePatternDictionary( /// HTEMPLATE, the generic template each bitplane uses. /// HDEFPIXEL. /// HCOMBOP. + /// HMMR: the grey-scale bitplanes are MMR-coded. + /// HENABLESKIP: grid cells whose pattern falls wholly outside the region are not coded. internal readonly record struct HalftoneParameters( int Width, int Height, @@ -86,22 +105,47 @@ internal readonly record struct HalftoneParameters( int VectorY, int Template, byte DefaultPixel, - CombinationOperator Combination); + CombinationOperator Combination, + bool Mmr = false, + bool EnableSkip = false); /// Decodes a halftone region (T.88 §6.6.5). - /// The arithmetic decoder, positioned at the grey-scale bitplanes. + /// The coded grey-scale bitplanes, after the segment's fixed fields. /// The region parameters, read from the segment header. /// The dither patterns from the pattern dictionary this region names. /// The decode's remaining pixel allowance. public static Jbig2Bitmap DecodeHalftoneRegion( - ref MqDecoder mq, HalftoneParameters p, Jbig2Bitmap[] patterns, Jbig2PixelBudget budget) + ReadOnlySpan data, HalftoneParameters p, Jbig2Bitmap[] patterns, Jbig2PixelBudget budget) { if (patterns.Length == 0) throw new InvalidDataException("JBIG2: halftone region refers to an empty pattern dictionary."); budget.Charge(p.Width, p.Height); var region = new Jbig2Bitmap(p.Width, p.Height, p.DefaultPixel); - var grey = DecodeGrayscale(ref mq, p.GridWidth, p.GridHeight, patterns.Length, p.Template, budget); + + // §6.6.5.1: with HENABLESKIP, a grid cell whose pattern would land wholly + // outside the region is not coded in any bitplane. The test is on the + // pattern's top-left corner, in whole pixels, against the pattern size. + Jbig2Bitmap? skip = null; + if (p.EnableSkip) + { + budget.Charge(p.GridWidth, p.GridHeight); + skip = new Jbig2Bitmap(p.GridWidth, p.GridHeight); + var patternWidth = patterns[0].Width; + var patternHeight = patterns[0].Height; + for (var m = 0; m < p.GridHeight; m++) + { + for (var n = 0; n < p.GridWidth; n++) + { + var x = (p.GridX + (long)m * p.VectorY + (long)n * p.VectorX) >> 8; + var y = (p.GridY + (long)m * p.VectorX - (long)n * p.VectorY) >> 8; + if (x + patternWidth <= 0 || x >= p.Width || y + patternHeight <= 0 || y >= p.Height) + skip.Data[m * p.GridWidth + n] = 1; + } + } + } + + var grey = DecodeGrayscale(data, p, patterns.Length, skip, budget); for (var m = 0; m < p.GridHeight; m++) { @@ -111,15 +155,24 @@ public static Jbig2Bitmap DecodeHalftoneRegion( // a vector, so a dither grid can be rotated or sheared relative to // the page. Coordinates are in 1/256 pixel units throughout, hence // the shift rather than a division. - var x = p.GridX + m * p.VectorY + n * p.VectorX; - var y = p.GridY + m * p.VectorX - n * p.VectorY; + // In long: HGX is a full 32-bit value, and a grid of 2^24 cells times a + // 16-bit vector overflows int long before it is implausible. + var x = p.GridX + (long)m * p.VectorY + (long)n * p.VectorX; + var y = p.GridY + (long)m * p.VectorX - (long)n * p.VectorY; var level = grey[m * p.GridWidth + n]; if ((uint)level >= (uint)patterns.Length) throw new InvalidDataException( $"JBIG2: halftone grey level {level} exceeds the {patterns.Length}-pattern dictionary."); - region.Combine(patterns[level], x >> 8, y >> 8, p.Combination); + var px = x >> 8; + var py = y >> 8; + // A pattern wholly off the region changes nothing; skipping it here + // also keeps the coordinates inside int for Combine. + if (px >= p.Width || py >= p.Height || px + patterns[level].Width <= 0 || py + patterns[level].Height <= 0) + continue; + + region.Combine(patterns[level], (int)px, (int)py, p.Combination); } } @@ -132,14 +185,21 @@ public static Jbig2Bitmap DecodeHalftoneRegion( /// above it to undo the Gray coding. /// private static int[] DecodeGrayscale( - ref MqDecoder mq, int width, int height, int levels, int template, Jbig2PixelBudget budget) + ReadOnlySpan data, HalftoneParameters p, int levels, Jbig2Bitmap? skip, Jbig2PixelBudget budget) { + var width = p.GridWidth; + var height = p.GridHeight; + + // HBPP = ceil(log2(HNUMPATS)), at least one plane. levels is at most 2^32, + // so this stops by 32. var planes = 0; - while (1 << planes < levels) planes++; + while (planes < 32 && 1L << planes < levels) planes++; planes = Math.Max(planes, 1); - var contexts = new byte[1 << GenericRegionDecoder.ContextBits(template)]; - var at = GrayscaleAt(template); + var contexts = new byte[1 << GenericRegionDecoder.ContextBits(p.Template)]; + var at = GrayscaleAt(p.Template); + var mq = new MqDecoder(data); + var mmrOffset = 0; // The caller has already bounded the grid by Jbig2Limits.MaxHalftoneGridCells, // which is what keeps this product inside int and these two arrays (five @@ -149,8 +209,19 @@ private static int[] DecodeGrayscale( for (var bit = planes - 1; bit >= 0; bit--) { - var plane = GenericRegionDecoder.Decode( - ref mq, contexts, width, height, template, typicalPrediction: false, at, budget); + Jbig2Bitmap plane; + if (p.Mmr) + { + // C.5 with GSMMR = 1: each plane is its own MMR bitmap, starting on the + // byte after the previous one ended. SKIP does not apply to MMR coding. + plane = MmrDecoder.Decode(data[Math.Min(mmrOffset, data.Length)..], width, height, budget, out var used); + mmrOffset += used; + } + else + { + plane = GenericRegionDecoder.Decode( + ref mq, contexts, width, height, p.Template, typicalPrediction: false, at, budget, skip); + } // C.5: every plane but the first is coded relative to the one above, // so the running XOR is what turns Gray code back into binary. diff --git a/src/Sdcb.SimdCodecs.Jbig2/HuffmanTable.cs b/src/Sdcb.SimdCodecs.Jbig2/HuffmanTable.cs new file mode 100644 index 0000000..a9829c4 --- /dev/null +++ b/src/Sdcb.SimdCodecs.Jbig2/HuffmanTable.cs @@ -0,0 +1,305 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; + +namespace Sdcb.SimdCodecs.Jbig2; + +/// +/// A bit reader over Huffman-coded JBIG2 data, most significant bit first +/// (T.88 §6.2.2, §7.4.13). Unlike , which T.88 lets read +/// past its data as 0xFF, a Huffman stream has no such convention, so running out +/// is an error rather than an invented value. +/// +internal ref struct Jbig2BitReader +{ + private readonly ReadOnlySpan _data; + private long _bitPosition; + + public Jbig2BitReader(ReadOnlySpan data) + { + _data = data; + _bitPosition = 0; + } + + /// The data this reader reads from. + public readonly ReadOnlySpan Data => _data; + + /// The byte holding the next unread bit, or the next byte once aligned. + public readonly int BytePosition => (int)((_bitPosition + 7) >> 3); + + public int ReadBit() + { + var index = _bitPosition >> 3; + if (index >= _data.Length) + throw new InvalidDataException("JBIG2: Huffman-coded data ends early."); + + var bit = (_data[(int)index] >> (7 - (int)(_bitPosition & 7))) & 1; + _bitPosition++; + return bit; + } + + /// Reads (0 to 32) bits as an unsigned value. + public uint ReadBits(int count) + { + if ((uint)count > 32) throw new ArgumentOutOfRangeException(nameof(count)); + if (_bitPosition + count > (long)_data.Length * 8) + throw new InvalidDataException("JBIG2: Huffman-coded data ends early."); + + uint value = 0; + for (var i = 0; i < count; i++) value = (value << 1) | (uint)ReadBit(); + return value; + } + + /// Skips to the next byte boundary (a no-op when already on one). + public void AlignToByte() => _bitPosition = (_bitPosition + 7) & ~7L; + + /// Moves to byte , which must lie within the data. + public void SeekToByte(long position) + { + if (position < 0 || position > _data.Length) + throw new InvalidDataException("JBIG2: Huffman-coded data ends early."); + _bitPosition = position * 8; + } +} + +/// +/// A Huffman table in the sense of T.88 Annex B: a list of lines, each a prefix +/// code length (PREFLEN), a range length (RANGELEN) and the bottom of its range +/// (RANGELOW), plus optionally a lower-range line (values from RANGELOW down to +/// minus infinity), an upper-range line (RANGELOW up) and an out-of-band line. +/// Prefix codes are assigned from the lengths alone by the canonical procedure of +/// B.3, in line order, so a table is fully described by its lines. +/// +internal sealed class HuffmanTable +{ + /// What a line's value means. + internal enum LineKind : byte + { + Normal, + Lower, + Upper, + OutOfBand, + } + + /// One table line (T.88 B.2). + internal readonly record struct Line(int PrefixLength, int RangeLength, int RangeLow, LineKind Kind = LineKind.Normal); + + /// The value returns for an OOB line; the same sentinel as the arithmetic decoder's. + public const int OutOfBand = ArithIntDecoder.OutOfBand; + + /// + /// Prefix lengths beyond this are refused. B.2 allows PREFLEN up to 255 in a + /// custom table, but a complete code over 2^32 values never needs more than 32. + /// + private const int MaxPrefixLength = 32; + + // Canonical decoding tables: for each prefix length, the first code of that + // length and the lines that have it, in assignment order. + private readonly long[] _firstCode; + private readonly Line[][] _byLength; + + /// Builds a table from its lines (B.3). + /// The lengths do not form a prefix code. + public HuffmanTable(IReadOnlyList lines) + { + var maxLength = 0; + foreach (var line in lines) + { + if (line.PrefixLength is < 0 or > MaxPrefixLength) + throw new InvalidDataException($"JBIG2: Huffman table line has prefix length {line.PrefixLength}."); + if (line.RangeLength is < 0 or > 32) + throw new InvalidDataException($"JBIG2: Huffman table line has range length {line.RangeLength}."); + maxLength = Math.Max(maxLength, line.PrefixLength); + } + + var counts = new int[maxLength + 1]; + foreach (var line in lines) + if (line.PrefixLength > 0) counts[line.PrefixLength]++; + + _firstCode = new long[maxLength + 1]; + _byLength = new Line[maxLength + 1][]; + var lists = new List[maxLength + 1]; + for (var l = 1; l <= maxLength; l++) lists[l] = new List(counts[l]); + foreach (var line in lines) + if (line.PrefixLength > 0) lists[line.PrefixLength].Add(line); + + // B.3: FIRSTCODE[len] = (FIRSTCODE[len - 1] + LENCOUNT[len - 1]) * 2, with + // LENCOUNT[0] taken as 0. A set of lengths that over-subscribes the code + // space (Kraft sum above 1) would hand out a code wider than its length, + // which is not a prefix code; that is malformed data, not a table. + long first = 0; + var previousCount = 0; + for (var l = 1; l <= maxLength; l++) + { + first = (first + previousCount) << 1; + if (first + counts[l] > 1L << l) + throw new InvalidDataException("JBIG2: Huffman table prefix lengths do not form a prefix code."); + _firstCode[l] = first; + _byLength[l] = [.. lists[l]]; + previousCount = counts[l]; + } + + _byLength[0] = []; + } + + /// Decodes one value (B.4), or . + public int Decode(ref Jbig2BitReader reader) + { + long code = 0; + for (var length = 1; length < _firstCode.Length; length++) + { + code = (code << 1) | (uint)reader.ReadBit(); + var index = code - _firstCode[length]; + var group = _byLength[length]; + if (index < 0 || index >= group.Length) continue; + + var line = group[index]; + switch (line.Kind) + { + case LineKind.OutOfBand: + return OutOfBand; + case LineKind.Lower: + return Checked(line.RangeLow - (long)reader.ReadBits(line.RangeLength)); + case LineKind.Upper: + return Checked(line.RangeLow + (long)reader.ReadBits(line.RangeLength)); + default: + return Checked(line.RangeLow + (long)reader.ReadBits(line.RangeLength)); + } + } + + throw new InvalidDataException("JBIG2: Huffman code is not in the table."); + } + + private static int Checked(long value) => value is >= int.MinValue + 1 and <= int.MaxValue + ? (int)value + : throw new InvalidDataException("JBIG2: Huffman-decoded value overflows a 32-bit integer."); + + /// + /// A table whose lines all have range length zero and values 0, 1, 2, ... in + /// order: the shape of the symbol ID tables of §7.4.3.1.7 and of the run code + /// table that precedes them. + /// + public static HuffmanTable FromCodeLengths(ReadOnlySpan lengths) + { + var lines = new Line[lengths.Length]; + for (var i = 0; i < lengths.Length; i++) lines[i] = new Line(lengths[i], 0, i); + return new HuffmanTable(lines); + } + + /// + /// Reads a table segment's data part (T.88 §7.4.13, B.2): flags, HTLOW, + /// HTHIGH, then the lines packed at HTPS / HTRS bits each. + /// + public static HuffmanTable ReadSegment(ReadOnlySpan data) + { + if (data.Length < 9) throw new InvalidDataException("JBIG2: truncated Huffman table segment."); + + var flags = data[0]; + var hasOob = (flags & 0x01) != 0; + var prefixBits = ((flags >> 1) & 0x07) + 1; + var rangeBits = ((flags >> 4) & 0x07) + 1; + var low = BinaryPrimitives.ReadInt32BigEndian(data[1..]); + var high = BinaryPrimitives.ReadInt32BigEndian(data[5..]); + if (low >= high) + throw new InvalidDataException($"JBIG2: Huffman table segment has HTLOW {low} not below HTHIGH {high}."); + + var reader = new Jbig2BitReader(data[9..]); + var lines = new List(); + + // Each line costs at least prefixBits + rangeBits >= 2 bits of the segment, + // and ReadBits refuses to run past it, so the loop is bounded by the data. + long current = low; + while (current < high) + { + var prefix = (int)reader.ReadBits(prefixBits); + var range = (int)reader.ReadBits(rangeBits); + if (range > 32) + throw new InvalidDataException($"JBIG2: Huffman table segment line has range length {range}."); + lines.Add(new Line(prefix, range, (int)current)); + current += 1L << range; + } + + lines.Add(new Line((int)reader.ReadBits(prefixBits), 32, low - 1, LineKind.Lower)); + lines.Add(new Line((int)reader.ReadBits(prefixBits), 32, high, LineKind.Upper)); + if (hasOob) lines.Add(new Line((int)reader.ReadBits(prefixBits), 0, 0, LineKind.OutOfBand)); + + return new HuffmanTable(lines); + } + + // ---- the standard tables of Annex B.5 (Tables B.1 to B.15) -------------------- + + private static readonly HuffmanTable?[] Standard = new HuffmanTable?[16]; + + /// Standard table B., 1 to 15. + public static HuffmanTable Get(int number) + { + lock (Standard) + return Standard[number] ??= new HuffmanTable(StandardLines(number)); + } + + /// The lines of standard table B., in the order the table lists them. + internal static Line[] StandardLines(int number) => number switch + { + 1 => [N(1, 4, 0), N(2, 8, 16), N(3, 16, 272), U(3, 65808)], + 2 => [N(1, 0, 0), N(2, 0, 1), N(3, 0, 2), N(4, 3, 3), N(5, 6, 11), U(6, 75), O(6)], + 3 => [N(8, 8, -256), N(1, 0, 0), N(2, 0, 1), N(3, 0, 2), N(4, 3, 3), N(5, 6, 11), L(8, -257), U(7, 75), O(6)], + 4 => [N(1, 0, 1), N(2, 0, 2), N(3, 0, 3), N(4, 3, 4), N(5, 6, 12), U(5, 76)], + 5 => [N(7, 8, -255), N(1, 0, 1), N(2, 0, 2), N(3, 0, 3), N(4, 3, 4), N(5, 6, 12), L(7, -256), U(6, 76)], + 6 => + [ + N(5, 10, -2048), N(4, 9, -1024), N(4, 8, -512), N(4, 7, -256), N(5, 6, -128), N(5, 5, -64), N(4, 5, -32), + N(2, 7, 0), N(3, 7, 128), N(3, 8, 256), N(4, 9, 512), N(4, 10, 1024), L(6, -2049), U(6, 2048), + ], + 7 => + [ + N(4, 9, -1024), N(3, 8, -512), N(4, 7, -256), N(5, 6, -128), N(5, 5, -64), N(4, 5, -32), N(4, 5, 0), + N(5, 5, 32), N(5, 6, 64), N(4, 7, 128), N(3, 8, 256), N(3, 9, 512), N(4, 10, 1024), L(5, -1025), U(5, 2048), + ], + 8 => + [ + N(8, 3, -15), N(9, 1, -7), N(8, 1, -5), N(9, 0, -3), N(7, 0, -2), N(4, 0, -1), N(2, 1, 0), N(5, 0, 2), + N(6, 0, 3), N(3, 4, 4), N(6, 1, 20), N(4, 4, 22), N(4, 5, 38), N(5, 6, 70), N(5, 7, 134), N(6, 7, 262), + N(7, 8, 390), N(6, 10, 646), L(9, -16), U(9, 1670), O(2), + ], + 9 => + [ + N(8, 4, -31), N(9, 2, -15), N(8, 2, -11), N(9, 1, -7), N(7, 1, -5), N(4, 1, -3), N(3, 1, -1), N(3, 1, 1), + N(5, 1, 3), N(6, 1, 5), N(3, 5, 7), N(6, 2, 39), N(4, 5, 43), N(4, 6, 75), N(5, 7, 139), N(5, 8, 267), + N(6, 8, 523), N(7, 9, 779), N(6, 11, 1291), L(9, -32), U(9, 3339), O(2), + ], + 10 => + [ + N(7, 4, -21), N(8, 0, -5), N(7, 0, -4), N(5, 0, -3), N(2, 2, -2), N(5, 0, 2), N(6, 0, 3), N(7, 0, 4), + N(8, 0, 5), N(2, 6, 6), N(5, 5, 70), N(6, 5, 102), N(6, 6, 134), N(6, 7, 198), N(6, 8, 326), + N(6, 9, 582), N(6, 10, 1094), N(7, 11, 2118), L(8, -22), U(8, 4166), O(2), + ], + 11 => + [ + N(1, 0, 1), N(2, 1, 2), N(4, 0, 4), N(4, 1, 5), N(5, 1, 7), N(5, 2, 9), N(6, 2, 13), N(7, 2, 17), + N(7, 3, 21), N(7, 4, 29), N(7, 5, 45), N(7, 6, 77), U(7, 141), + ], + 12 => + [ + N(1, 0, 1), N(2, 0, 2), N(3, 1, 3), N(5, 0, 5), N(5, 1, 6), N(6, 1, 8), N(7, 0, 10), N(7, 1, 11), + N(7, 2, 13), N(7, 3, 17), N(7, 4, 25), N(8, 5, 41), U(8, 73), + ], + 13 => + [ + N(1, 0, 1), N(3, 0, 2), N(4, 0, 3), N(5, 0, 4), N(4, 1, 5), N(3, 3, 7), N(6, 1, 15), N(6, 2, 17), + N(6, 3, 21), N(6, 4, 29), N(6, 5, 45), N(7, 6, 77), U(7, 141), + ], + 14 => [N(3, 0, -2), N(3, 0, -1), N(1, 0, 0), N(3, 0, 1), N(3, 0, 2)], + 15 => + [ + N(7, 4, -24), N(6, 2, -8), N(5, 1, -4), N(4, 0, -2), N(3, 0, -1), N(1, 0, 0), N(3, 0, 1), N(4, 0, 2), + N(5, 1, 3), N(6, 2, 5), N(7, 4, 9), L(7, -25), U(7, 25), + ], + _ => throw new ArgumentOutOfRangeException(nameof(number), number, "Standard tables are B.1 to B.15."), + }; + + private static Line N(int prefix, int range, int low) => new(prefix, range, low); + private static Line L(int prefix, int low) => new(prefix, 32, low, LineKind.Lower); + private static Line U(int prefix, int low) => new(prefix, 32, low, LineKind.Upper); + private static Line O(int prefix) => new(prefix, 0, 0, LineKind.OutOfBand); +} diff --git a/src/Sdcb.SimdCodecs.Jbig2/Jbig2Decoder.cs b/src/Sdcb.SimdCodecs.Jbig2/Jbig2Decoder.cs index 3bb8b2a..bd959b4 100644 --- a/src/Sdcb.SimdCodecs.Jbig2/Jbig2Decoder.cs +++ b/src/Sdcb.SimdCodecs.Jbig2/Jbig2Decoder.cs @@ -21,23 +21,31 @@ namespace Sdcb.SimdCodecs.Jbig2; /// /// /// Standalone .jb2 files — which do have a header, and do carry their own -/// dimensions — go through , and register with the +/// dimensions — go through , and register with the /// Sdcb.SimdCodecs facade via . /// /// -/// Implemented: every region type T.88 defines, arithmetically coded — -/// generic regions both ways (§6.2.5 templates 0-3 with TPGDON and arbitrary AT -/// pixels, and MMR / ITU-T T.6 §6.2.6), refinement regions (§6.3), symbol -/// dictionaries and text regions (§6.4/§6.5, including per-instance refinement -/// and refine/aggregate symbols), pattern dictionaries and halftone regions -/// (§6.6/§6.7), page information, and region composition. -/// Not implemented: the Huffman-coded variants (SDHUFF / SBHUFF and custom -/// table segments), MMR inside pattern dictionaries and halftone regions, -/// HENABLESKIP, and TPGRON in a refinement region. Each throws +/// Implemented: every region type T.88 defines — generic regions (§6.2.5 +/// templates 0-3 with TPGDON and arbitrary AT pixels, MMR / ITU-T T.6 §6.2.6, and +/// the unknown data length of §7.2.7), refinement regions with or without TPGRON +/// (§6.3), symbol dictionaries and text regions both arithmetically and +/// Huffman-coded (§6.4/§6.5, with the standard tables of Annex B, custom table +/// segments, per-instance refinement and refine/aggregate symbols), pattern +/// dictionaries and halftone regions with MMR, HENABLESKIP and any GRAYMAX +/// (§6.6/§6.7), page information, region composition, and every page of a +/// multi-page file. +/// Not implemented: EXTTEMPLATE generic regions, symbol dictionaries that +/// import or retain arithmetic contexts, and T.6's 2D extension codes. Each throws /// naming the feature — a stream this decoder /// cannot fully reconstruct fails loudly rather than returning a /// plausible-looking partial page. /// +/// +/// After changing this decoder, run Issue1Jbig2CorpusTests against the +/// files attached to issue #1 (https://github.com/sdcb/SimdCodecs/issues/1): download +/// and extract them, set SIMDCODECS_ISSUE1_DIR, and every page is compared with +/// jbig2dec. They are not in the repository because their licence is unknown. +/// /// public static class Jbig2Decoder { @@ -113,16 +121,37 @@ public static Jbig2Image Decode(ReadOnlySpan embedded, int width, int heig /// A cancellation token to monitor for cancellation requests. /// The file header or a segment is malformed. /// The file needs a JBIG2 feature this decoder does not implement yet. - public static Jbig2Image DecodeFile(ReadOnlySpan file, CancellationToken cancellationToken = default) + public static Jbig2Image DecodeFile(ReadOnlySpan file, CancellationToken cancellationToken = default) => + DecodeFile(file, pageNumber: 1, cancellationToken); + + /// + /// Decodes one page of a standalone .jb2 file (T.88 Annex D). Segments + /// associated with page 0 (shared dictionaries) apply to every page; the rest + /// are filtered to . + /// + /// The standalone JBIG2 file bytes. + /// + /// The page's association number (T.88 §7.2.6): 1 for the first page. A file's pages + /// are numbered 1 to the page count that + /// reports. + /// + /// A cancellation token to monitor for cancellation requests. + /// is not positive. + /// The file header or a segment is malformed, or the file has no such page. + /// The file needs a JBIG2 feature this decoder does not implement yet. + public static Jbig2Image DecodeFile(ReadOnlySpan file, int pageNumber, CancellationToken cancellationToken = default) { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(pageNumber); + var segments = ReadFile(file, out var body); - if (!TryFindPageSize(body, segments, out var width, out var height)) - throw new InvalidDataException("JBIG2: file has no page information segment for page 1, or its height is indeterminate."); + if (!TryFindPageSize(body, segments, (uint)pageNumber, out var width, out var height)) + throw new InvalidDataException( + $"JBIG2: file has no page information segment for page {pageNumber}, or its height is indeterminate."); cancellationToken.ThrowIfCancellationRequested(); var state = new DecodingState(new Jbig2Bitmap(width, height)); - ProcessStream(body, segments, state, targetPage: 1, cancellationToken); + ProcessStream(body, segments, state, targetPage: (uint)pageNumber, cancellationToken); return new Jbig2Image(width, height, state.Page.Data); } @@ -138,9 +167,10 @@ public static bool TryReadFileInfo(ReadOnlySpan file, out int width, out i /// /// , plus the number of pages: - /// page information segments, one per page (T.88 §7.4.8). Only page 1 is decoded. + /// page information segments, one per page (T.88 §7.4.8). The width and height are page 1's; + /// decode any page with . /// - internal static bool TryReadFileInfo(ReadOnlySpan file, out int width, out int height, out int pages) + public static bool TryReadFileInfo(ReadOnlySpan file, out int width, out int height, out int pages) { width = height = 0; pages = 0; @@ -151,7 +181,7 @@ internal static bool TryReadFileInfo(ReadOnlySpan file, out int width, out { if (segment.Type == SegmentType.PageInformation) pages++; } - return TryFindPageSize(body, segments, out width, out height); + return TryFindPageSize(body, segments, 1, out width, out height); } catch (Exception e) when (e is InvalidDataException or NotSupportedException or ArgumentException) { @@ -193,6 +223,7 @@ private static List ReadSequential(ReadOnlySpan stream) while (position < stream.Length) { var header = Jbig2Segment.ReadHeader(stream, ref position); + if (header.LengthUnknown) header = Jbig2Segment.ResolveUnknownLength(stream, header); segments.Add(header); position = header.DataStart + header.DataLength; } @@ -216,6 +247,10 @@ private static List ReadRandomAccess(ReadOnlySpan stream) while (position + declared < stream.Length) { var header = Jbig2Segment.ReadHeader(stream, ref position); + if (header.LengthUnknown) + throw new InvalidDataException( + "JBIG2: a random-access file has a segment of unknown data length, which T.88 §7.2.7 " + + "allows only where each data part follows its header."); headers.Add(header); declared += header.DataLength; } @@ -236,13 +271,17 @@ private static List ReadRandomAccess(ReadOnlySpan stream) // ---- page geometry ----------------------------------------------------------- - private static bool TryFindPageSize(ReadOnlySpan stream, List segments, out int width, out int height) + private static bool TryFindPageSize( + ReadOnlySpan stream, List segments, uint page, out int width, out int height) { width = height = 0; foreach (var segment in segments) { - if (segment.Type != SegmentType.PageInformation || segment.Page is not (0 or 1)) continue; + // Page 1 also accepts a page information segment on page 0, which some + // single-page encoders write. + if (segment.Type != SegmentType.PageInformation + || !(segment.Page == page || (page == 1 && segment.Page == 0))) continue; if (segment.DataLength < 19) return false; var info = stream.Slice(segment.DataStart, segment.DataLength); @@ -251,7 +290,7 @@ private static bool TryFindPageSize(ReadOnlySpan stream, List int.MaxValue || h > int.MaxValue @@ -266,14 +305,15 @@ private static bool TryFindPageSize(ReadOnlySpan stream, List stream, List segments, out uint height) + private static bool TryFindStripedHeight( + ReadOnlySpan stream, List segments, uint page, out uint height) { // §7.4.10: an end-of-stripe segment carries the row number of the stripe's // last row, so the tallest one plus one is the page height. long last = -1; foreach (var segment in segments) { - if (segment.Type != SegmentType.EndOfStripe || segment.DataLength < 4) continue; + if (segment.Type != SegmentType.EndOfStripe || segment.DataLength < 4 || segment.Page != page) continue; last = Math.Max(last, BinaryPrimitives.ReadUInt32BigEndian(stream.Slice(segment.DataStart, 4))); } @@ -301,6 +341,7 @@ private sealed class DecodingState(Jbig2Bitmap page) private readonly Dictionary _intermediateRegions = []; private readonly Dictionary _symbolDictionaries = []; private readonly Dictionary _patternDictionaries = []; + private readonly Dictionary _tables = []; /// The page being composited onto. public Jbig2Bitmap Page { get; } = page; @@ -324,6 +365,24 @@ public void StoreIntermediateRegion(uint segment, Jbig2Bitmap bitmap) => public void StoreSymbols(uint segment, Jbig2Bitmap[] symbols) => _symbolDictionaries[segment] = symbols; + /// Records a custom Huffman table (§7.4.13). + public void StoreTable(uint segment, HuffmanTable table) => _tables[segment] = table; + + /// + /// The custom Huffman tables a segment refers to, in referred-to order: the + /// order in which its table selection flags consume them (§7.4.2.1.6, + /// §7.4.3.1.6). + /// + public Queue GatherTables(SegmentHeader segment) + { + var tables = new Queue(); + foreach (var referred in segment.ReferredTo) + if (_tables.TryGetValue(referred, out var table)) + tables.Enqueue(table); + + return tables; + } + /// Records a pattern dictionary's dither patterns. public void StorePatterns(uint segment, Jbig2Bitmap[] patterns) => _patternDictionaries[segment] = patterns; @@ -417,7 +476,7 @@ private static void ProcessSegment(SegmentHeader segment, ReadOnlySpan dat case SegmentType.ImmediateGenericRegion: case SegmentType.ImmediateLosslessGenericRegion: - Compose(state, DecodeGenericRegion(data, state.Budget, out var genericInfo), genericInfo); + Compose(state, DecodeGenericRegion(data, state.Budget, out var genericInfo, segment.RowCount), genericInfo); break; case SegmentType.IntermediateGenericRegion: @@ -457,9 +516,8 @@ private static void ProcessSegment(SegmentHeader segment, ReadOnlySpan dat break; case SegmentType.Tables: - throw new NotSupportedException( - "JBIG2: custom Huffman table segments are not implemented — this decoder handles " + - "arithmetic symbol dictionaries and text regions only."); + state.StoreTable(segment.Number, HuffmanTable.ReadSegment(data)); + break; case SegmentType.PatternDictionary: state.StorePatterns(segment.Number, DecodePatternDictionary(data, state.Budget)); @@ -511,10 +569,10 @@ private static void ApplyPageInformation(ReadOnlySpan data, Jbig2Bitmap pa /// Generic region segment (T.88 §7.4.6): region info, flags, AT pixels, then MQ-coded data. private static Jbig2Bitmap DecodeGenericRegion( - ReadOnlySpan data, Jbig2PixelBudget budget, out RegionInfo info) + ReadOnlySpan data, Jbig2PixelBudget budget, out RegionInfo info, uint? rowCount = null) { var position = 0; - var region = Jbig2Segment.ReadRegionInfo(data, ref position); + var region = Jbig2Segment.ReadRegionInfo(data, ref position, rowCount); info = region; if (position >= data.Length) @@ -644,10 +702,6 @@ private static Jbig2Bitmap[] DecodeSymbolDictionary( var contextUsed = (flags & 0x0100) != 0; var contextRetained = (flags & 0x0200) != 0; - if (huffman) - throw new NotSupportedException( - "JBIG2: Huffman-coded symbol dictionaries (SDHUFF = 1) are not implemented."); - // §7.4.3.1.1 bits 8-9: carrying adaptive state across segments. Refusing // is not laziness — a dictionary decoded with the wrong initial contexts // produces plausible but wrong glyphs, which is precisely the failure this @@ -656,12 +710,16 @@ private static Jbig2Bitmap[] DecodeSymbolDictionary( throw new NotSupportedException( "JBIG2: symbol dictionaries that import or retain arithmetic contexts are not implemented."); + // §7.4.2.1.2: the generic AT pixels are present only for arithmetic coding. Span at = stackalloc sbyte[8]; - var atPixels = template == 0 ? 4 : 1; - if (position + atPixels * 2 > data.Length) - throw new InvalidDataException("JBIG2: truncated symbol dictionary AT pixel list."); + if (!huffman) + { + var atPixels = template == 0 ? 4 : 1; + if (position + atPixels * 2 > data.Length) + throw new InvalidDataException("JBIG2: truncated symbol dictionary AT pixel list."); - for (var i = 0; i < atPixels * 2; i++) at[i] = (sbyte)data[position++]; + for (var i = 0; i < atPixels * 2; i++) at[i] = (sbyte)data[position++]; + } Span refinementAt = stackalloc sbyte[4]; RefinementRegionDecoder.NominalAt.CopyTo(refinementAt); @@ -694,6 +752,21 @@ private static Jbig2Bitmap[] DecodeSymbolDictionary( var parameters = new SymbolDictionaryParameters( (int)created, (int)exported, template, refinementAggregate, refinementTemplate); + if (huffman) + { + // §7.4.2.1.1 bits 2-7: which table each number is read with, standard or + // the next custom one this segment refers to. + var custom = state.GatherTables(segment); + var tables = new SymbolDictionaryDecoder.HuffmanTables( + SelectTable((flags >> 2) & 0x03, [4, 5, 0], custom, "SDHUFFDH"), + SelectTable((flags >> 4) & 0x03, [2, 3, 0], custom, "SDHUFFDW"), + SelectTable((flags >> 6) & 0x01, [1], custom, "SDHUFFBMSIZE"), + SelectTable((flags >> 7) & 0x01, [1], custom, "SDHUFFAGGINST")); + + return SymbolDictionaryDecoder.DecodeHuffman( + data[position..], parameters, inputSymbols, tables, refinementAt, state.Budget); + } + var mq = new MqDecoder(data[position..]); return SymbolDictionaryDecoder.Decode( ref mq, parameters, inputSymbols, at, refinementAt, state.Budget); @@ -729,9 +802,15 @@ private static Jbig2Bitmap DecodeTextRegion( var dsOffset = (flags >> 10) & 0x1F; if (dsOffset > 15) dsOffset -= 32; + // §7.4.3.1.2: the Huffman table selections come next, when SBHUFF is set. + var huffmanFlags = 0; if (huffman) - throw new NotSupportedException( - "JBIG2: Huffman-coded text regions (SBHUFF = 1) are not implemented."); + { + if (position + 2 > data.Length) + throw new InvalidDataException("JBIG2: truncated text region Huffman flags."); + huffmanFlags = BinaryPrimitives.ReadUInt16BigEndian(data[position..]); + position += 2; + } Span refinementAt = stackalloc sbyte[4]; RefinementRegionDecoder.NominalAt.CopyTo(refinementAt); @@ -762,18 +841,59 @@ private static Jbig2Bitmap DecodeTextRegion( region.Width, region.Height, (int)instances, 1 << logStrips, defaultPixel, combination, corner, transposed, dsOffset, refine, refinementTemplate); - var codeLength = TextRegionDecoder.SymbolCodeLength(symbols.Length); - var idContexts = new byte[ArithIntDecoder.IdContextSize(codeLength)]; var refinementContexts = refine ? new byte[1 << RefinementRegionDecoder.ContextBits(refinementTemplate)] : []; + if (huffman) + { + var custom = state.GatherTables(segment); + var tables = new TextRegionDecoder.HuffmanTables( + SelectTable(huffmanFlags & 0x03, [6, 7, 0], custom, "SBHUFFFS"), + SelectTable((huffmanFlags >> 2) & 0x03, [8, 9, 10], custom, "SBHUFFDS"), + SelectTable((huffmanFlags >> 4) & 0x03, [11, 12, 13], custom, "SBHUFFDT"), + SelectTable((huffmanFlags >> 6) & 0x03, [14, 15, 0], custom, "SBHUFFRDW"), + SelectTable((huffmanFlags >> 8) & 0x03, [14, 15, 0], custom, "SBHUFFRDH"), + SelectTable((huffmanFlags >> 10) & 0x03, [14, 15, 0], custom, "SBHUFFRDX"), + SelectTable((huffmanFlags >> 12) & 0x03, [14, 15, 0], custom, "SBHUFFRDY"), + SelectTable((huffmanFlags >> 14) & 0x01, [1], custom, "SBHUFFRSIZE")); + + var reader = new Jbig2BitReader(data[position..]); + var symbolCodes = TextRegionDecoder.ReadSymbolCodeTable(ref reader, symbols.Length); + return TextRegionDecoder.DecodeHuffman( + ref reader, parameters, symbols, tables, symbolCodes, codeLength: 0, + refinementContexts, refinementAt, state.Budget); + } + + var codeLength = TextRegionDecoder.SymbolCodeLength(symbols.Length); + var idContexts = new byte[ArithIntDecoder.IdContextSize(codeLength)]; + var mq = new MqDecoder(data[position..]); return TextRegionDecoder.Decode( ref mq, parameters, symbols, new TextRegionDecoder.Fields(), idContexts, refinementContexts, refinementAt, state.Budget); } + /// + /// Resolves one Huffman table selection field. maps + /// each value to a standard table number, 0 marking a reserved value; the value + /// one past the list (3 for a two-bit field, 1 for a one-bit field) means "the + /// next custom table". + /// + private static HuffmanTable SelectTable(int selection, int[] standard, Queue custom, string field) + { + var customValue = standard.Length == 1 ? 1 : 3; + if (selection == customValue) + return custom.Count > 0 + ? custom.Dequeue() + : throw new InvalidDataException($"JBIG2: {field} selects a custom Huffman table the segment does not refer to."); + + var number = selection < standard.Length ? standard[selection] : 0; + return number != 0 + ? HuffmanTable.Get(number) + : throw new InvalidDataException($"JBIG2: {field} selection {selection} is reserved."); + } + /// /// Pattern dictionary segment (T.88 §7.4.4): flags, the pattern size, the /// highest grey level, then one wide collective bitmap holding every pattern. @@ -791,15 +911,23 @@ private static Jbig2Bitmap[] DecodePatternDictionary(ReadOnlySpan data, Jb int patternHeight = data[2]; var maxIndex = BinaryPrimitives.ReadUInt32BigEndian(data[3..]); - if (mmr) - throw new NotSupportedException("JBIG2: MMR-coded pattern dictionaries are not implemented."); if (patternWidth == 0 || patternHeight == 0) throw new InvalidDataException($"JBIG2: pattern dictionary has a {patternWidth}x{patternHeight} pattern size."); - // §7.4.4.1.4 caps GRAYMAX at 255, and the collective bitmap is - // (GRAYMAX + 1) patterns wide, so this also bounds the allocation. - if (maxIndex > 255) - throw new InvalidDataException($"JBIG2: pattern dictionary GRAYMAX {maxIndex} exceeds 255."); + // GRAYMAX is a four-byte field (§7.4.5.1.4), and real dictionaries go past + // 255 (a 20x20 cell has 401 grey levels). The collective bitmap is + // (GRAYMAX + 1) patterns wide, so bounding that one bitmap bounds the + // allocation; the pixel budget is charged by the region decoder as usual. + var collectiveWidth = (maxIndex + 1L) * patternWidth; + if (collectiveWidth > int.MaxValue || collectiveWidth * patternHeight > Jbig2Limits.MaxBitmapPixels) + throw new InvalidDataException( + $"JBIG2: pattern dictionary GRAYMAX {maxIndex} with {patternWidth}x{patternHeight} patterns " + + $"needs a {collectiveWidth}x{patternHeight} collective bitmap, past the " + + $"{Jbig2Limits.MaxBitmapPixels:N0} pixel ceiling."); + + if (mmr) + return HalftoneDecoder.DecodePatternDictionaryMmr( + data[7..], patternWidth, patternHeight, (int)maxIndex, budget); var mq = new MqDecoder(data[7..]); return HalftoneDecoder.DecodePatternDictionary( @@ -828,14 +956,9 @@ private static Jbig2Bitmap DecodeHalftoneRegion( var combination = (CombinationOperator)((flags >> 4) & 0x07); var defaultPixel = (byte)((flags >> 7) & 0x01); - if (mmr) - throw new NotSupportedException("JBIG2: MMR-coded halftone regions are not implemented."); - - // HENABLESKIP changes what gets coded, not just how fast: skipped pixels - // are absent from the stream entirely, so ignoring the flag would - // desynchronise rather than merely run slower. - if (enableSkip) - throw new NotSupportedException("JBIG2: halftone regions using HENABLESKIP are not implemented."); + // HENABLESKIP changes what gets coded, not just how fast: skipped cells are + // absent from the stream entirely, which is why HalftoneDecoder rebuilds + // the same SKIP bitmap the encoder used before reading any plane. if (combination > CombinationOperator.Replace) throw new InvalidDataException($"JBIG2: reserved halftone combination operator {(int)combination}."); @@ -858,10 +981,9 @@ private static Jbig2Bitmap DecodeHalftoneRegion( var parameters = new HalftoneDecoder.HalftoneParameters( region.Width, region.Height, (int)gridWidth, (int)gridHeight, - gridX, gridY, vectorX, vectorY, template, defaultPixel, combination); + gridX, gridY, vectorX, vectorY, template, defaultPixel, combination, mmr, enableSkip); - var mq = new MqDecoder(data[position..]); - return HalftoneDecoder.DecodeHalftoneRegion(ref mq, parameters, patterns, state.Budget); + return HalftoneDecoder.DecodeHalftoneRegion(data[position..], parameters, patterns, state.Budget); } } diff --git a/src/Sdcb.SimdCodecs.Jbig2/Jbig2ImageDecoder.cs b/src/Sdcb.SimdCodecs.Jbig2/Jbig2ImageDecoder.cs index 2b466cd..52535d7 100644 --- a/src/Sdcb.SimdCodecs.Jbig2/Jbig2ImageDecoder.cs +++ b/src/Sdcb.SimdCodecs.Jbig2/Jbig2ImageDecoder.cs @@ -9,7 +9,7 @@ namespace Sdcb.SimdCodecs.Jbig2; /// /// adapter for standalone .jb2 files, bridging -/// into the Sdcb.SimdCodecs facade. +/// into the Sdcb.SimdCodecs facade. /// /// This is the courtesy entry point, not the main one. JBIG2's reason to /// exist in this family is PDF's /JBIG2Decode filter, and a PDF-embedded diff --git a/src/Sdcb.SimdCodecs.Jbig2/Jbig2Segment.cs b/src/Sdcb.SimdCodecs.Jbig2/Jbig2Segment.cs index e856469..d9edc26 100644 --- a/src/Sdcb.SimdCodecs.Jbig2/Jbig2Segment.cs +++ b/src/Sdcb.SimdCodecs.Jbig2/Jbig2Segment.cs @@ -47,15 +47,28 @@ internal readonly record struct SegmentHeader( uint[] ReferredTo, uint Page, int DataStart, - int DataLength); + int DataLength) +{ + /// + /// For an immediate generic region whose header declared an unknown data length + /// (T.88 §7.2.7), the row count that follows its end sequence, which replaces the + /// region's declared height (§7.4.6.4). Null for every other segment. + /// + public uint? RowCount { get; init; } + + /// Whether the header declared 0xFFFFFFFF and the length is still to be found. + public bool LengthUnknown => DataLength < 0; +} /// Parsing of T.88 segment headers and the segment-level fields they introduce. internal static class Jbig2Segment { /// /// T.88 §7.2.7 unknown-length marker. Legal only on an immediate generic - /// region, where the data part is instead terminated by a search sequence; - /// this decoder rejects it rather than guessing. + /// region in a sequential or embedded stream, where the data part is instead + /// terminated by an end sequence and a row count. + /// reports it as a negative , and the + /// sequential reader resolves it with . /// public const uint UnknownDataLength = 0xFFFFFFFF; @@ -114,25 +127,74 @@ public static SegmentHeader ReadHeader(ReadOnlySpan data, ref int position var length = ReadUInt32(data, ref position); if (length == UnknownDataLength) - throw new NotSupportedException( - "JBIG2: segment with unknown data length (0xFFFFFFFF, T.88 §7.2.7) is not supported."); + { + if (type is not (SegmentType.ImmediateGenericRegion or SegmentType.ImmediateLosslessGenericRegion)) + throw new InvalidDataException( + $"JBIG2: segment {number} of type {(int)type} declares an unknown data length, which " + + "T.88 §7.2.7 allows only for an immediate generic region."); + return new SegmentHeader(number, type, referredTo, page, position, -1); + } if (length > int.MaxValue || position + (long)length > data.Length) throw new InvalidDataException($"JBIG2: segment {number} data length {length} runs past the end of the stream."); return new SegmentHeader(number, type, referredTo, page, position, (int)length); } + /// + /// Finds the end of an immediate generic region whose data length is unknown + /// (T.88 §7.2.7, §7.4.6.4). After the 18 bytes of region info and flags (and the + /// AT pixels, for arithmetic coding) the coded data runs until an end sequence, + /// 00 00 for MMR and FF AC for arithmetic coding, followed by a + /// four-byte row count. Neither sequence can occur inside its own kind of coded + /// data: MQ output never has FF followed by a byte above 8F, and + /// T.6 codes never hold sixteen zero bits in a row. The resolved data length + /// covers the sequence and the row count. + /// + public static SegmentHeader ResolveUnknownLength(ReadOnlySpan stream, SegmentHeader header) + { + var start = header.DataStart; + if (start + 18 > stream.Length) + throw new InvalidDataException("JBIG2: truncated generic region of unknown length."); + + var flags = stream[start + 17]; + var mmr = (flags & 0x01) != 0; + var codedStart = start + 18; + if (!mmr) codedStart += ((flags >> 1) & 0x03) == 0 ? 8 : 2; + + if (codedStart > stream.Length) + throw new InvalidDataException("JBIG2: truncated generic region of unknown length."); + + var (first, second) = mmr ? ((byte)0x00, (byte)0x00) : ((byte)0xFF, (byte)0xAC); + var at = stream[codedStart..].IndexOf([first, second]); + // The search is linear in the stream, once per segment, so a hostile stream + // costs no more than reading it. + if (at < 0 || codedStart + at + 6 > stream.Length) + throw new InvalidDataException( + $"JBIG2: generic region segment {header.Number} has an unknown data length and no end sequence."); + + var end = codedStart + at; + var rows = BinaryPrimitives.ReadUInt32BigEndian(stream[(end + 2)..]); + return header with { DataLength = end + 6 - start, RowCount = rows }; + } + /// /// The region segment information field (T.88 §7.4.1): 17 bytes of geometry /// and the external combination operator, prefixed to every region segment. /// - public static RegionInfo ReadRegionInfo(ReadOnlySpan data, ref int position) + /// The segment data. + /// Where the field starts; advanced past it. + /// + /// The row count of an unknown-length generic region, which replaces the declared + /// height (§7.4.6.4), or null. + /// + public static RegionInfo ReadRegionInfo(ReadOnlySpan data, ref int position, uint? rowCount = null) { if (position + 17 > data.Length) throw new InvalidDataException("JBIG2: truncated region segment information field."); var width = ReadUInt32(data, ref position); var height = ReadUInt32(data, ref position); + if (rowCount is { } rows) height = rows; var x = ReadUInt32(data, ref position); var y = ReadUInt32(data, ref position); var flags = ReadByte(data, ref position); diff --git a/src/Sdcb.SimdCodecs.Jbig2/MmrDecoder.cs b/src/Sdcb.SimdCodecs.Jbig2/MmrDecoder.cs index 2d0e99e..03c37b3 100644 --- a/src/Sdcb.SimdCodecs.Jbig2/MmrDecoder.cs +++ b/src/Sdcb.SimdCodecs.Jbig2/MmrDecoder.cs @@ -49,7 +49,17 @@ private MmrDecoder(ReadOnlySpan data) /// /// The coded data is malformed or ends early. /// The data uses a 2D extension code, which includes T.6's uncompressed mode. - public static Jbig2Bitmap Decode(ReadOnlySpan data, int width, int height, Jbig2PixelBudget budget) + public static Jbig2Bitmap Decode(ReadOnlySpan data, int width, int height, Jbig2PixelBudget budget) => + Decode(data, width, height, budget, out _); + + /// + /// , also reporting how many + /// bytes the bitmap occupied: its rows, an EOFB if one follows them, and the padding to the + /// next byte boundary. That is where the next MMR bitmap starts when several are coded back to + /// back, as the bitplanes of a halftone region's grey-scale image are (T.88 Annex C.5). + /// + public static Jbig2Bitmap Decode( + ReadOnlySpan data, int width, int height, Jbig2PixelBudget budget, out int bytesConsumed) { budget.Charge(width, height); var bitmap = new Jbig2Bitmap(width, height); @@ -75,6 +85,10 @@ public static Jbig2Bitmap Decode(ReadOnlySpan data, int width, int height, referenceCount = count; } + // T.6 §2.4: EOFB is two EOL codes, 000000000001 twice. It is optional after a + // region of known height, so it is consumed only when it is actually there. + if (decoder.Peek(24) == 0x001001) decoder._bitPosition += 24; + bytesConsumed = Math.Min((decoder._bitPosition + 7) >> 3, data.Length); return bitmap; } @@ -246,7 +260,7 @@ private readonly uint Peek(int count) for (var i = 0; i < 4; i++) window = (window << 8) | (index + i < _data.Length ? _data[index + i] : 0u); - // count is at most 13 and shift at most 7, so the wanted bits are always + // count is at most 24 and shift at most 7, so the wanted bits are always // inside the 32-bit window. return (window << shift) >> (32 - count); } diff --git a/src/Sdcb.SimdCodecs.Jbig2/PublicAPI.Unshipped.txt b/src/Sdcb.SimdCodecs.Jbig2/PublicAPI.Unshipped.txt index 7dc5c58..4395a9c 100644 --- a/src/Sdcb.SimdCodecs.Jbig2/PublicAPI.Unshipped.txt +++ b/src/Sdcb.SimdCodecs.Jbig2/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +static Sdcb.SimdCodecs.Jbig2.Jbig2Decoder.DecodeFile(System.ReadOnlySpan file, int pageNumber, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Sdcb.SimdCodecs.Jbig2.Jbig2Image! +static Sdcb.SimdCodecs.Jbig2.Jbig2Decoder.TryReadFileInfo(System.ReadOnlySpan file, out int width, out int height, out int pages) -> bool diff --git a/src/Sdcb.SimdCodecs.Jbig2/RefinementRegionDecoder.cs b/src/Sdcb.SimdCodecs.Jbig2/RefinementRegionDecoder.cs index c8b4533..c736d9e 100644 --- a/src/Sdcb.SimdCodecs.Jbig2/RefinementRegionDecoder.cs +++ b/src/Sdcb.SimdCodecs.Jbig2/RefinementRegionDecoder.cs @@ -25,26 +25,19 @@ namespace Sdcb.SimdCodecs.Jbig2; /// contexts that name a specific neighbourhood in the spec's numbering. /// /// -/// TPGRON is refused. Everything else here is confirmed against jbig2dec -/// — both templates, six reference/target relationships, and moved AT pixels — -/// but with TPGRON on, this decoder and jbig2dec part company. What is known, -/// from measurement rather than assumption: -/// -/// -/// GRTEMPLATE 1 never agrees. Sweeping all 1024 possible SLTP -/// context values produced no agreeing candidate, so the SLTP constant is not -/// the cause — something about how the skip itself applies is. -/// GRTEMPLATE 0 agrees on five of six relationships and desynchronises -/// part-way down the sixth, which rules out a simple constant just as -/// firmly. -/// jbig2dec emits no diagnostic either way, so there is no evidence about -/// which side is wrong — and no second oracle available to break the tie. -/// -/// -/// So the flag throws. Partial credit is the wrong instinct here: "agrees on -/// five of six" describes a decoder that silently corrupts the sixth, and a -/// caller cannot tell which one it has. TPGRON is an encoder's option, never a -/// requirement, so refusing it costs strictly less than getting it wrong. +/// TPGRON (§6.3.5.6) toggles LTP with one SLTP decision per row, and +/// while LTP is on a pixel over a uniform 3x3 patch of the reference takes the +/// patch's colour without a decision. The SLTP decision has a context of its +/// own, a fixed value: 0x0010 for GRTEMPLATE 0 and 0x0004 for GRTEMPLATE 1 in +/// the numbering below. An earlier version of this file used 0x0100 / 0x0080 +/// and refused TPGRON because it disagreed with jbig2dec. The constants were +/// settled by measurement: for GRTEMPLATE 1, sweeping all 1024 values against +/// jbig2dec over the six Jbig2RefinementTests.Cases leaves 0x0004 as the +/// only one that agrees on every case; for GRTEMPLATE 0 those cases agree with +/// many values (the slot is rarely reached), and 0x0010 is the one that also +/// decodes the TPGRON refinement region of the issue #1 corpus +/// (042_24.jb2, see Issue1Jbig2CorpusTests) exactly as +/// jbig2dec does. /// /// internal static class RefinementRegionDecoder @@ -57,7 +50,7 @@ internal static class RefinementRegionDecoder public static ReadOnlySpan NominalAt => [-1, -1, -1, -1]; // T.88 §6.3.5.6: the pseudo-context the SLTP decision uses when TPGRON is on. - private static ReadOnlySpan TypicalPredictionContexts => [0x0100, 0x0080]; + private static ReadOnlySpan TypicalPredictionContexts => [0x0010, 0x0004]; /// Number of context bits (hence the context array size) for each GRTEMPLATE. public static int ContextBits(int template) => template switch @@ -102,14 +95,6 @@ public static Jbig2Bitmap Decode( if (contexts.Length < 1 << ContextBits(template)) throw new ArgumentException("Context array too small for this GRTEMPLATE.", nameof(contexts)); - // See the class remarks. Refinement without TPGRON is confirmed against - // jbig2dec on both templates; with it, this decoder disagrees on inputs it - // cannot explain, so the flag is refused rather than guessed at. - if (typicalPrediction) - throw new NotSupportedException( - "JBIG2: TPGRON (typical prediction) in a refinement region is not implemented — the decoded " + - "result cannot be reconciled with the reference decoder, and a wrong page is worse than none."); - budget.Charge(width, height); var bitmap = new Jbig2Bitmap(width, height); var sltpContext = TypicalPredictionContexts[template]; diff --git a/src/Sdcb.SimdCodecs.Jbig2/SymbolDictionaryDecoder.cs b/src/Sdcb.SimdCodecs.Jbig2/SymbolDictionaryDecoder.cs index 71ad2ae..be16533 100644 --- a/src/Sdcb.SimdCodecs.Jbig2/SymbolDictionaryDecoder.cs +++ b/src/Sdcb.SimdCodecs.Jbig2/SymbolDictionaryDecoder.cs @@ -33,8 +33,10 @@ internal readonly record struct SymbolDictionaryParameters( /// every page of a scanned document. /// /// -/// Huffman-coded dictionaries (SDHUFF = 1) are not implemented; the caller -/// rejects them before reaching here. +/// Huffman-coded dictionaries (SDHUFF = 1) go through : +/// the same height classes, but the numbers come from Huffman tables, and unless +/// SDREFAGG is set each height class's bitmaps arrive together as one collective +/// bitmap, uncompressed or MMR-coded (§6.5.9). /// /// internal static class SymbolDictionaryDecoder @@ -133,7 +135,224 @@ public static Jbig2Bitmap[] Decode( $"JBIG2: symbol dictionary height class at height {height} codes no symbols."); } - return SelectExported(ref mq, ex, inputSymbols, newSymbols, p.ExportedSymbols); + var selector = new ExportSelector(inputSymbols, newSymbols, p.ExportedSymbols); + while (!selector.Done) selector.Take(ex.Decode(ref mq)); + return selector.Result(); + } + + /// The Huffman tables a Huffman-coded dictionary reads its numbers with (§7.4.2.1.1). + internal sealed record HuffmanTables( + HuffmanTable DeltaHeight, HuffmanTable DeltaWidth, HuffmanTable BitmapSize, HuffmanTable AggregateInstances); + + /// + /// Decodes a Huffman-coded dictionary (SDHUFF = 1, T.88 §6.5.5 with the Huffman + /// branches of §6.5.6 to §6.5.10). + /// + /// The coded data, after the segment's fixed fields. + /// Parameters from the segment header. + /// SDINSYMS. + /// SDHUFFDH, SDHUFFDW, SDHUFFBMSIZE and SDHUFFAGGINST. + /// SDRAT, used only when SDREFAGG is set. + /// The decode's remaining pixel allowance. + public static Jbig2Bitmap[] DecodeHuffman( + ReadOnlySpan data, + SymbolDictionaryParameters p, + Jbig2Bitmap[] inputSymbols, + HuffmanTables tables, + scoped ReadOnlySpan refinementAt, + Jbig2PixelBudget budget) + { + var reader = new Jbig2BitReader(data); + var refinementContexts = p.RefinementAggregate + ? new byte[1 << RefinementRegionDecoder.ContextBits(p.RefinementTemplate)] + : []; + + // §6.5.8.2.3, Huffman case: the symbol IDs of a refinement or aggregate are + // plain binary numbers SBSYMCODELEN = ceil(log2(SDNUMINSYMS + SDNUMNEWSYMS)) + // bits wide. + var total = inputSymbols.Length + p.NewSymbols; + var codeLength = 0; + while (1L << codeLength < total) codeLength++; + + var newSymbols = new List(Math.Min(p.NewSymbols, 1 << 16)); + var widths = new List(); + var height = 0; + + while (newSymbols.Count < p.NewSymbols) + { + var deltaHeight = tables.DeltaHeight.Decode(ref reader); + if (deltaHeight == HuffmanTable.OutOfBand) + throw new InvalidDataException("JBIG2: unexpected OOB decoding a symbol dictionary height class."); + + height = (int)Math.Clamp((long)height + deltaHeight, int.MinValue, int.MaxValue); + if (height <= 0) + throw new InvalidDataException($"JBIG2: symbol dictionary height class has non-positive height {height}."); + + var countBeforeClass = newSymbols.Count; + var width = 0; + long totalWidth = 0; + widths.Clear(); + + while (true) + { + var deltaWidth = tables.DeltaWidth.Decode(ref reader); + if (deltaWidth == HuffmanTable.OutOfBand) break; + + width = (int)Math.Clamp((long)width + deltaWidth, int.MinValue, int.MaxValue); + if (width <= 0) + throw new InvalidDataException($"JBIG2: symbol dictionary symbol has non-positive width {width}."); + if (newSymbols.Count + widths.Count >= p.NewSymbols) + throw new InvalidDataException("JBIG2: symbol dictionary codes more symbols than it declared."); + + if (p.RefinementAggregate) + { + newSymbols.Add(DecodeAggregateHuffman( + ref reader, p, inputSymbols, newSymbols, width, height, codeLength, + tables, refinementContexts, refinementAt, budget)); + } + else + { + widths.Add(width); + totalWidth += width; + } + } + + if (!p.RefinementAggregate && widths.Count > 0) + DecodeCollective(ref reader, tables, widths, totalWidth, height, newSymbols, budget); + + // The same progress guard as the arithmetic path. + if (newSymbols.Count == countBeforeClass) + throw new InvalidDataException( + $"JBIG2: symbol dictionary height class at height {height} codes no symbols."); + } + + // §6.5.10: with SDHUFF = 1 the export run lengths use table B.1. + var runs = HuffmanTable.Get(1); + var selector = new ExportSelector(inputSymbols, newSymbols, p.ExportedSymbols); + while (!selector.Done) selector.Take(runs.Decode(ref reader)); + return selector.Result(); + } + + /// + /// §6.5.9: a height class's bitmaps, side by side in one collective bitmap + /// TOTWIDTH wide, byte-aligned, BMSIZE bytes long. BMSIZE 0 means stored + /// uncompressed, row by row, each row padded to a byte; anything else is that + /// many bytes of MMR. + /// + private static void DecodeCollective( + ref Jbig2BitReader reader, HuffmanTables tables, List widths, long totalWidth, int height, + List newSymbols, Jbig2PixelBudget budget) + { + var size = tables.BitmapSize.Decode(ref reader); + if (size == HuffmanTable.OutOfBand || size < 0) + throw new InvalidDataException("JBIG2: symbol dictionary collective bitmap size is not a non-negative integer."); + + if (totalWidth > int.MaxValue || totalWidth * height > Jbig2Limits.MaxBitmapPixels) + throw new InvalidDataException( + $"JBIG2: symbol dictionary height class is {totalWidth}x{height}, past the " + + $"{Jbig2Limits.MaxBitmapPixels:N0} pixel ceiling for one bitmap."); + + reader.AlignToByte(); + var start = reader.BytePosition; + var data = reader.Data; + var w = (int)totalWidth; + Jbig2Bitmap collective; + long length; + + if (size == 0) + { + var stride = (w + 7) / 8; + length = (long)stride * height; + if (start + length > data.Length) + throw new InvalidDataException("JBIG2: uncompressed symbol dictionary collective bitmap ends early."); + + budget.Charge(w, height); + collective = new Jbig2Bitmap(w, height); + for (var y = 0; y < height; y++) + { + var row = data.Slice(start + y * stride, stride); + for (var x = 0; x < w; x++) + collective.Data[y * w + x] = (byte)((row[x >> 3] >> (7 - (x & 7))) & 1); + } + } + else + { + length = size; + if (start + length > data.Length) + throw new InvalidDataException("JBIG2: MMR symbol dictionary collective bitmap ends early."); + collective = MmrDecoder.Decode(data.Slice(start, size), w, height, budget); + } + + reader.SeekToByte(start + length); + + var x0 = 0; + foreach (var width in widths) + { + newSymbols.Add(collective.Crop(x0, 0, width, height)); + x0 += width; + } + } + + /// §6.5.8.2 with SDHUFF = 1: a refined or aggregated symbol. + private static Jbig2Bitmap DecodeAggregateHuffman( + ref Jbig2BitReader reader, + SymbolDictionaryParameters p, + Jbig2Bitmap[] inputSymbols, + List newSymbols, + int width, + int height, + int codeLength, + HuffmanTables tables, + Span refinementContexts, + scoped ReadOnlySpan refinementAt, + Jbig2PixelBudget budget) + { + var instances = tables.AggregateInstances.Decode(ref reader); + if (instances == HuffmanTable.OutOfBand || instances < 1) + throw new InvalidDataException("JBIG2: symbol dictionary aggregate instance count is not a positive integer."); + + var alphabet = new Jbig2Bitmap[inputSymbols.Length + p.NewSymbols]; + inputSymbols.CopyTo(alphabet, 0); + newSymbols.CopyTo(alphabet, inputSymbols.Length); + for (var i = inputSymbols.Length + newSymbols.Count; i < alphabet.Length; i++) + alphabet[i] = Jbig2Bitmap.Empty; + + if (instances == 1) + { + // §6.5.8.2.2: ID as SBSYMCODELEN raw bits, RDX and RDY with B.15, the + // refinement's byte count with B.1, then the refinement itself, + // arithmetically coded, starting on the next byte. + var id = (int)reader.ReadBits(codeLength); + if ((uint)id >= (uint)alphabet.Length) + throw new InvalidDataException($"JBIG2: aggregate symbol names symbol {id} of {alphabet.Length}."); + + var rdx = Require(HuffmanTable.Get(15).Decode(ref reader), "RDX"); + var rdy = Require(HuffmanTable.Get(15).Decode(ref reader), "RDY"); + var size = Require(HuffmanTable.Get(1).Decode(ref reader), "BMSIZE"); + + return TextRegionDecoder.DecodeHuffmanRefinement( + ref reader, size, refinementContexts, width, height, p.RefinementTemplate, + alphabet[id], rdx, rdy, refinementAt, budget); + } + + // §6.5.8.2.1: a text region inside the dictionary, with the tables Table 17 + // fixes and uniform-length symbol IDs. + var parameters = new TextRegionParameters( + Width: width, + Height: height, + Instances: instances, + Strips: 1, + DefaultPixel: 0, + Combination: CombinationOperator.Or, + Corner: ReferenceCorner.TopLeft, + Transposed: false, + DsOffset: 0, + Refine: true, + RefinementTemplate: p.RefinementTemplate); + + return TextRegionDecoder.DecodeHuffman( + ref reader, parameters, alphabet, TextRegionDecoder.HuffmanTables.SymbolDictionaryAggregate, + symbolCodes: null, codeLength, refinementContexts, refinementAt, budget); } /// @@ -206,57 +425,49 @@ private static Jbig2Bitmap DecodeAggregate( /// /// §6.5.10: which symbols leave the dictionary, as alternating runs of /// "skip this many" and "export this many" over the imports followed by the - /// new symbols. + /// new symbols. Fed one run at a time, so the arithmetic and Huffman paths can + /// share it whatever decodes their runs. /// - private static Jbig2Bitmap[] SelectExported( - ref MqDecoder mq, - ArithIntDecoder ex, - Jbig2Bitmap[] inputSymbols, - List newSymbols, - int expected) + private sealed class ExportSelector(Jbig2Bitmap[] inputSymbols, List newSymbols, int expected) { - var total = inputSymbols.Length + newSymbols.Count; - var exported = new List(expected); - var index = 0; - var exporting = false; - - // The same non-termination trap as the height-class loop above: a run of - // length zero advances nothing, and a never-dry MQ decoder can yield zero - // for ever. A *leading* zero run is completely normal — a dictionary that - // exports from index 0 codes one — so this is a ceiling on the number of - // runs rather than a ban on empty ones. Every run that advances costs at - // least one symbol, so any real encoding is far below the bound. - var runs = 0; - var maxRuns = 2 * total + 8; - - while (index < total) + private readonly int _total = inputSymbols.Length + newSymbols.Count; + private readonly List _exported = new(Math.Min(expected, 1 << 16)); + private int _index; + private bool _exporting; + + // A zero-length run advances nothing, and a never-dry MQ decoder can yield + // zero for ever. A *leading* zero run is completely normal (a dictionary + // that exports from index 0 codes one), so this is a ceiling on the number + // of runs rather than a ban on empty ones. + private int _runs; + + public bool Done => _index >= _total; + + public void Take(int run) { - if (++runs > maxRuns) + if (++_runs > 2 * _total + 8) throw new InvalidDataException( - $"JBIG2: symbol dictionary export runs do not terminate — more than {maxRuns} runs " + - $"over {total} symbols."); - - var run = ex.Decode(ref mq); + $"JBIG2: symbol dictionary export runs do not terminate — more than {2 * _total + 8} runs " + + $"over {_total} symbols."); if (run == ArithIntDecoder.OutOfBand || run < 0) throw new InvalidDataException("JBIG2: symbol dictionary export run length is not a non-negative integer."); - if (index + run > total) + if (_index + run > _total) throw new InvalidDataException("JBIG2: symbol dictionary export runs overrun the symbol list."); - if (exporting) + if (_exporting) for (var i = 0; i < run; i++) - exported.Add(index + i < inputSymbols.Length - ? inputSymbols[index + i] - : newSymbols[index + i - inputSymbols.Length]); + _exported.Add(_index + i < inputSymbols.Length + ? inputSymbols[_index + i] + : newSymbols[_index + i - inputSymbols.Length]); - index += run; - exporting = !exporting; + _index += run; + _exporting = !_exporting; } - if (exported.Count != expected) - throw new InvalidDataException( - $"JBIG2: symbol dictionary exported {exported.Count} symbols but declared {expected}."); - - return [.. exported]; + public Jbig2Bitmap[] Result() => _exported.Count == expected + ? [.. _exported] + : throw new InvalidDataException( + $"JBIG2: symbol dictionary exported {_exported.Count} symbols but declared {expected}."); } private static int Require(int value, string field) => value != ArithIntDecoder.OutOfBand diff --git a/src/Sdcb.SimdCodecs.Jbig2/TextRegionDecoder.cs b/src/Sdcb.SimdCodecs.Jbig2/TextRegionDecoder.cs index 365d27d..6bb07e5 100644 --- a/src/Sdcb.SimdCodecs.Jbig2/TextRegionDecoder.cs +++ b/src/Sdcb.SimdCodecs.Jbig2/TextRegionDecoder.cs @@ -52,7 +52,8 @@ internal readonly record struct TextRegionParameters( /// Every number here comes from an — one per field, /// as T.88 names them (IADT, IAFS, IADS, IAIT, IARI, IARDW…), because each /// field's values have their own distribution and sharing contexts between them -/// would cost compression. +/// would cost compression. A Huffman-coded region (SBHUFF = 1) reads the same +/// fields from Huffman tables instead, in . /// /// internal static class TextRegionDecoder @@ -228,6 +229,203 @@ private static int PlaceSymbol(Jbig2Bitmap region, Jbig2Bitmap symbol, TextRegio return advanceFirst ? curs : curs + extent - 1; } + /// The Huffman tables a Huffman-coded text region reads its fields with (§7.4.3.1.2). + internal sealed record HuffmanTables( + HuffmanTable Fs, HuffmanTable Ds, HuffmanTable Dt, + HuffmanTable Rdw, HuffmanTable Rdh, HuffmanTable Rdx, HuffmanTable Rdy, HuffmanTable RefinementSize) + { + /// + /// The tables T.88 Table 17 fixes for a text region decoded inside a + /// Huffman-coded symbol dictionary's aggregate symbol (§6.5.8.2.1). + /// + public static HuffmanTables SymbolDictionaryAggregate => new( + HuffmanTable.Get(6), HuffmanTable.Get(8), HuffmanTable.Get(11), + HuffmanTable.Get(15), HuffmanTable.Get(15), HuffmanTable.Get(15), HuffmanTable.Get(15), + HuffmanTable.Get(1)); + } + + /// + /// Reads the symbol ID Huffman table that opens a Huffman-coded text region's data + /// (§7.4.3.1.7): 35 four-bit lengths for the run codes, then one code length per + /// symbol coded with those run codes, then padding to the next byte. + /// + public static HuffmanTable ReadSymbolCodeTable(ref Jbig2BitReader reader, int symbolCount) + { + Span runLengths = stackalloc int[35]; + for (var i = 0; i < runLengths.Length; i++) runLengths[i] = (int)reader.ReadBits(4); + var runCodes = HuffmanTable.FromCodeLengths(runLengths); + + var lengths = new int[symbolCount]; + var count = 0; + while (count < symbolCount) + { + var code = runCodes.Decode(ref reader); + int repeat, value; + switch (code) + { + case >= 0 and < 32: + lengths[count++] = code; + continue; + case 32: + // RUNCODE32: the previous length again, 3 to 6 times. + if (count == 0) + throw new InvalidDataException("JBIG2: symbol ID table repeats a length before any is given."); + value = lengths[count - 1]; + repeat = 3 + (int)reader.ReadBits(2); + break; + case 33: + value = 0; + repeat = 3 + (int)reader.ReadBits(3); + break; + case 34: + value = 0; + repeat = 11 + (int)reader.ReadBits(7); + break; + default: + throw new InvalidDataException($"JBIG2: symbol ID table has run code {code}."); + } + + if (count + repeat > symbolCount) + throw new InvalidDataException("JBIG2: symbol ID table runs past the symbol count."); + lengths.AsSpan(count, repeat).Fill(value); + count += repeat; + } + + reader.AlignToByte(); + return HuffmanTable.FromCodeLengths(lengths); + } + + /// + /// Decodes a Huffman-coded text region (SBHUFF = 1, §6.4.5 with the Huffman + /// branches of §6.4.6 to §6.4.11). + /// + /// Positioned at the first field after the symbol ID table. + /// The region parameters. + /// SBSYMS. + /// The field tables. + /// + /// SBSYMCODES, or null for the uniform -bit IDs a symbol + /// dictionary's aggregate region uses. + /// + /// The width of a uniform ID; unused when is given. + /// Contexts for per-instance refinement. + /// SBRAT. + /// The decode's remaining pixel allowance. + public static Jbig2Bitmap DecodeHuffman( + ref Jbig2BitReader reader, + TextRegionParameters p, + Jbig2Bitmap[] symbols, + HuffmanTables tables, + HuffmanTable? symbolCodes, + int codeLength, + Span refinementContexts, + scoped ReadOnlySpan refinementAt, + Jbig2PixelBudget budget) + { + budget.Charge(p.Width, p.Height); + var region = new Jbig2Bitmap(p.Width, p.Height, p.DefaultPixel); + var logStrips = System.Numerics.BitOperations.Log2((uint)p.Strips); + + var stript = -(long)RequireHuffman(tables.Dt.Decode(ref reader), "DT") * p.Strips; + long firsts = 0; + var instances = 0; + + while (instances < p.Instances) + { + stript += (long)RequireHuffman(tables.Dt.Decode(ref reader), "DT") * p.Strips; + firsts += RequireHuffman(tables.Fs.Decode(ref reader), "FS"); + var curs = firsts; + + while (true) + { + if (instances >= p.Instances) + throw new InvalidDataException("JBIG2: text region codes more symbol instances than it declared."); + + // §6.4.9: with SBHUFF the within-strip T offset is LOG2SBSTRIPS raw bits. + var curt = (long)reader.ReadBits(logStrips); + var t = ClampCoordinate(stript + curt); + + var id = symbolCodes is null ? (int)reader.ReadBits(codeLength) : symbolCodes.Decode(ref reader); + if ((uint)id >= (uint)symbols.Length) + throw new InvalidDataException($"JBIG2: text region names symbol {id} of {symbols.Length}."); + + var symbol = symbols[id]; + if (p.Refine && reader.ReadBit() != 0) + { + var rdw = RequireHuffman(tables.Rdw.Decode(ref reader), "RDW"); + var rdh = RequireHuffman(tables.Rdh.Decode(ref reader), "RDH"); + var rdx = RequireHuffman(tables.Rdx.Decode(ref reader), "RDX"); + var rdy = RequireHuffman(tables.Rdy.Decode(ref reader), "RDY"); + var size = RequireHuffman(tables.RefinementSize.Decode(ref reader), "RSIZE"); + + var width = (long)symbol.Width + rdw; + var height = (long)symbol.Height + rdh; + if (width is <= 0 or > int.MaxValue || height is <= 0 or > int.MaxValue) + throw new InvalidDataException($"JBIG2: refined text instance has size {width}x{height}."); + + symbol = DecodeHuffmanRefinement( + ref reader, size, refinementContexts, (int)width, (int)height, p.RefinementTemplate, + symbol, FloorHalf(rdw) + rdx, FloorHalf(rdh) + rdy, refinementAt, budget); + } + + curs = PlaceSymbol(region, symbol, p, ClampCoordinate(curs), t); + instances++; + + var ds = tables.Ds.Decode(ref reader); + if (ds == HuffmanTable.OutOfBand) break; + + curs += (long)ds + p.DsOffset; + } + } + + return region; + } + + /// + /// A refinement inside Huffman-coded data (§6.4.11.1 and §6.5.8.2.2): it starts + /// on the next byte boundary, is arithmetically coded, and occupies exactly + /// bytes, after which the Huffman fields resume. + /// + public static Jbig2Bitmap DecodeHuffmanRefinement( + ref Jbig2BitReader reader, + int size, + Span contexts, + int width, + int height, + int template, + Jbig2Bitmap reference, + int dx, + int dy, + scoped ReadOnlySpan at, + Jbig2PixelBudget budget) + { + if (size < 0) throw new InvalidDataException($"JBIG2: refinement data size {size} is negative."); + + reader.AlignToByte(); + var start = reader.BytePosition; + if ((long)start + size > reader.Data.Length) + throw new InvalidDataException("JBIG2: Huffman-coded refinement data runs past the segment."); + + // Each refinement is its own arithmetically coded unit, so it starts from + // fresh adaptive state. + contexts.Clear(); + var mq = new MqDecoder(reader.Data.Slice(start, size)); + var bitmap = RefinementRegionDecoder.Decode( + ref mq, contexts, width, height, template, reference, dx, dy, + typicalPrediction: false, at, budget); + + reader.SeekToByte((long)start + size); + return bitmap; + } + + // Coordinates are accumulated in long from coded deltas; anything outside int + // can only place a symbol off the region, so pin it somewhere that still is. + private static int ClampCoordinate(long value) => (int)Math.Clamp(value, int.MinValue / 2, int.MaxValue / 2); + + private static int RequireHuffman(int value, string field) => value != HuffmanTable.OutOfBand + ? value + : throw new InvalidDataException($"JBIG2: unexpected OOB decoding {field} in a text region."); + /// Floor division by two that keeps working for negative deltas, which / 2 does not. private static int FloorHalf(int value) => value >> 1; diff --git a/src/Sdcb.SimdCodecs.Jpeg2000/BlockDecoder.cs b/src/Sdcb.SimdCodecs.Jpeg2000/BlockDecoder.cs index 07ec114..13f8380 100644 --- a/src/Sdcb.SimdCodecs.Jpeg2000/BlockDecoder.cs +++ b/src/Sdcb.SimdCodecs.Jpeg2000/BlockDecoder.cs @@ -81,35 +81,33 @@ private static void DecodePasses( var mq = new MqDecoder(coded); Span contexts = stackalloc byte[ContextCount]; - contexts.Clear(); - - // The three states T.800 Table D.7 seeds away from zero. This is the - // roadmap's hazard 1: the coder is shared with JBIG2 but its - // initialisation is not, and getting it wrong decodes the first - // code-block plausibly and then drifts. - contexts[0] = 4 << 1; // zero coding, all-insignificant neighbourhood - contexts[RunLengthContext] = 3 << 1; - contexts[UniformContext] = 46 << 1; + ResetContexts(contexts); var passesLeft = block.PassCount; var plane = planes - 1; + var reset = (band.CodeBlockStyle & CodeBlockStyles.ResetContexts) != 0; + var segmentation = (band.CodeBlockStyle & CodeBlockStyles.SegmentationSymbols) != 0; // The first pass of a code-block is always a cleanup pass: there is // nothing significant yet for a significance-propagation pass to // propagate from, and nothing to refine. - state.CleanupPass(ref mq, contexts, plane); + Cleanup(ref mq, contexts, state, plane, segmentation); + if (reset) ResetContexts(contexts); passesLeft--; plane--; while (plane >= 0 && passesLeft > 0) { state.SignificancePropagationPass(ref mq, contexts, plane); + if (reset) ResetContexts(contexts); if (--passesLeft == 0) break; state.MagnitudeRefinementPass(ref mq, contexts, plane); + if (reset) ResetContexts(contexts); if (--passesLeft == 0) break; - state.CleanupPass(ref mq, contexts, plane); + Cleanup(ref mq, contexts, state, plane, segmentation); + if (reset) ResetContexts(contexts); passesLeft--; plane--; } @@ -117,6 +115,37 @@ private static void DecodePasses( state.WriteTo(band, block); } + /// + /// The cleanup pass, followed when the code-block style asks for it by the segmentation symbol + /// of D.5: four decisions in the uniform context, 1010 in a sound codestream. They carry no + /// coefficient data, only a check an error-resilient decoder could make, so they are decoded to + /// keep the codeword in step and their value is not acted on. + /// + private static void Cleanup(ref MqDecoder mq, scoped Span contexts, BlockState state, int plane, bool segmentation) + { + state.CleanupPass(ref mq, contexts, plane); + if (!segmentation) return; + + for (var i = 0; i < 4; i++) mq.Decode(contexts, UniformContext); + } + + /// + /// The initial states of T.800 Table D.7: every context at state 0 bar three. Called once + /// before the first pass, and after every pass when the code-block style resets contexts (D.4.1). + /// + private static void ResetContexts(Span contexts) + { + contexts.Clear(); + + // The three states T.800 Table D.7 seeds away from zero. This is the + // roadmap's hazard 1: the coder is shared with JBIG2 but its + // initialisation is not, and getting it wrong decodes the first + // code-block plausibly and then drifts. + contexts[0] = 4 << 1; // zero coding, all-insignificant neighbourhood + contexts[RunLengthContext] = 3 << 1; + contexts[UniformContext] = 46 << 1; + } + /// /// Concatenates the block's coded segments. /// diff --git a/src/Sdcb.SimdCodecs.Jpeg2000/CodestreamReader.cs b/src/Sdcb.SimdCodecs.Jpeg2000/CodestreamReader.cs index 8aa2421..3203808 100644 --- a/src/Sdcb.SimdCodecs.Jpeg2000/CodestreamReader.cs +++ b/src/Sdcb.SimdCodecs.Jpeg2000/CodestreamReader.cs @@ -51,7 +51,7 @@ internal sealed record CodestreamHeader( /// /// The distinction kept throughout: means the /// bytes are malformed or hostile, means -/// they are a perfectly legal codestream using a feature this rung does not +/// they are a perfectly legal codestream using a feature this decoder does not /// implement yet. A caller can reasonably retry the second with another decoder /// and cannot do anything with the first. /// @@ -232,7 +232,7 @@ private static void ReadTilePart(ReadOnlySpan data, ref int offset, List data, ref int offset, List body) /// /// Refuses, by name, everything outside the envelope this decoder has actually - /// been validated against. Each message says which rung owns the feature, so - /// the refusal reads as a roadmap position rather than a dead end. + /// been validated against, naming the feature in the message. /// private static void Validate(CodestreamHeader header) { @@ -522,23 +521,18 @@ private static void Validate(CodestreamHeader header) $"{first.BitDepth}-bit. Components of different precisions are not implemented."); } - if (siz.TileCount != 1) - throw new NotSupportedException( - $"JPEG 2000: this codestream has {siz.TileCount} tiles; only single-tile images are " + - "implemented. Multiple tiles are rung 3."); - if (header.TileParts.Count != 1) - throw new NotSupportedException( - $"JPEG 2000: this tile is split into {header.TileParts.Count} tile-parts, which is rung 3."); - - if (header.Progression != ProgressionOrder.Lrcp) - throw new NotSupportedException( - $"JPEG 2000: progression order {header.Progression} is not implemented; only LRCP is. " + - "With one tile and maximal precincts the five orders differ only in how they nest layer, " + - "resolution and component, and only LRCP's nesting has a fixture behind it."); + // A.4.2: Isot is 16 bits, so a codestream cannot address more tiles than that; one that + // declares more is not a JPEG 2000 codestream. + if (siz.TileCount > 65535) + throw new InvalidDataException( + $"JPEG 2000: SIZ declares {siz.TileCount:N0} tiles, more than the 65,535 a tile index can name."); - if (header.UseSopMarkers || header.UseEphMarkers) - throw new NotSupportedException( - "JPEG 2000: SOP and EPH markers are not implemented; they are rung 3."); + foreach (var part in header.TileParts) + { + if (part.TileIndex >= siz.TileCount) + throw new InvalidDataException( + $"JPEG 2000: a tile-part names tile {part.TileIndex}, but SIZ declares only {siz.TileCount}."); + } for (var c = 0; c < siz.Components.Length; c++) { @@ -559,18 +553,28 @@ private static void Validate(CodestreamHeader header) "sizes. Only scalar quantization, derived or expounded, is implemented for the 9/7 " + "wavelet."); - if (coding.CodeBlockStyle != 0) + // T.800 Table A.19. Context reset (0x02), predictable termination (0x10, which only + // constrains how the encoder terminates) and segmentation symbols (0x20) are decoded. + // Selective arithmetic bypass, termination on every pass and the vertically causal + // context each change how the coded bytes are read, so ignoring one would desynchronise + // tier-1 rather than degrade quality. + var unsupported = coding.CodeBlockStyle & ~(CodeBlockStyles.ResetContexts | + CodeBlockStyles.PredictableTermination | CodeBlockStyles.SegmentationSymbols); + if (unsupported != 0) throw new NotSupportedException( - $"JPEG 2000: code-block style flags 0x{coding.CodeBlockStyle:X2} are not implemented " + - "(T.800 Table A.19: selective arithmetic bypass, context reset, termination per pass, " + - "vertically causal context, predictable termination, segmentation symbols). Each changes " + - "how the coded bytes are read, so ignoring one desynchronises tier-1 rather than degrading " + - "quality."); + $"JPEG 2000: code-block style flags 0x{unsupported:X2} are not implemented (T.800 Table " + + "A.19: 0x01 selective arithmetic bypass, 0x04 termination on each pass, 0x08 vertically " + + "causal context). Each changes how the coded bytes are read."); - if (coding.PrecinctSizes.Length != 0) - throw new NotSupportedException( - "JPEG 2000: custom precinct sizes are not implemented; only the maximal default, which " + - "makes each resolution a single precinct. Precincts are rung 3."); + // A.6.1: a precinct exponent of 0 is allowed only at resolution 0. Above it the bands see + // the precinct at half size, which a zero exponent cannot be halved into. + for (var r = 1; r < coding.ResolutionCount; r++) + { + if (coding.PrecinctWidthExponent(r) == 0 || coding.PrecinctHeightExponent(r) == 0) + throw new InvalidDataException( + $"JPEG 2000: component {c} declares a precinct exponent of 0 at resolution {r}, " + + "which T.800 allows only at resolution 0."); + } } if (header.MultipleComponentTransform) diff --git a/src/Sdcb.SimdCodecs.Jpeg2000/Geometry.cs b/src/Sdcb.SimdCodecs.Jpeg2000/Geometry.cs index 237417d..be61f23 100644 --- a/src/Sdcb.SimdCodecs.Jpeg2000/Geometry.cs +++ b/src/Sdcb.SimdCodecs.Jpeg2000/Geometry.cs @@ -56,8 +56,8 @@ internal readonly record struct Rect(int X0, int Y0, int X1, int Y1) /// /// Mutable, and deliberately so — , /// and persist across the packets of successive -/// quality layers, which is the state that makes tier-2 stateful. Rung 1 has one -/// layer and so cannot exercise it, which is exactly why hazard 5 warns that a +/// quality layers, which is the state that makes tier-2 stateful. A one-layer +/// codestream cannot exercise it, which is exactly why hazard 5 warns that a /// single-layer fixture proves nothing about this. /// /// @@ -142,35 +142,20 @@ internal sealed class Subband /// public required float[] Values { get; init; } - /// - /// The precinct's inclusion tag tree: for each code-block, the quality layer - /// that first carries it. - /// - public TagTree? InclusionTree { get; private set; } + /// T.800 Table A.19 code-block style flags of the tile-component this band belongs to. + public required int CodeBlockStyle { get; init; } - /// - /// The precinct's imsb tag tree: for each code-block, how many leading - /// magnitude bit-planes are entirely zero. - /// - public TagTree? ZeroBitPlaneTree { get; private set; } + /// xcb', the code-block width exponent after the precinct cap of Equation B-16. + public required int BlockWidthExponent { get; init; } - /// - /// Creates the pair once, on first use. - /// - /// One pair per band is correct only because rung 1 has maximal - /// precincts, which makes each resolution a single precinct. The trees - /// genuinely belong to a precinct, and rung 3 must move them there when - /// custom precinct sizes arrive. What must not change either way is the - /// lifetime: they persist across the packets of successive quality layers, - /// and rebuilding them per packet decodes layer 0 correctly and everything - /// after it wrongly. - /// - /// - public void EnsureTagTrees() - { - InclusionTree ??= new TagTree(BlocksWide, BlocksHigh); - ZeroBitPlaneTree ??= new TagTree(BlocksWide, BlocksHigh); - } + /// ycb', the code-block height exponent after the precinct cap. + public required int BlockHeightExponent { get; init; } + + /// The absolute column of the first code-block, on the grid anchored at the band origin. + public required int FirstBlockColumn { get; init; } + + /// The absolute row of the first code-block. + public required int FirstBlockRow { get; init; } } /// @@ -187,6 +172,71 @@ internal sealed class Resolution /// One band (LL) at level 0, three (HL, LH, HH) above it. public required Subband[] Bands { get; init; } + + /// + /// The resolution's precincts in raster order (T.800 B.6), which is the order their index k + /// counts them in. Empty when the resolution itself is empty: such a resolution has no packets. + /// + public required Precinct[] Precincts { get; init; } +} + +/// +/// One precinct of one resolution (T.800 B.6): the unit a packet carries. It holds, for each band +/// of its resolution, the rectangle of code-blocks that fall inside it and that band's two tag +/// trees, which belong to the precinct and persist across the packets of successive layers. +/// +internal sealed class Precinct +{ + /// One entry per band of the resolution, in the band order the packet codes them. + public required PrecinctBand[] Bands { get; init; } + + /// + /// Where the position-driven progressions (RPCL, PCRL, CPRL; T.800 B.12.1.3 to B.12.1.5) visit + /// this precinct on the reference grid: the precinct's top-left corner scaled up to the reference + /// grid, clipped to the tile's corner. Those orders walk the reference grid and take a precinct + /// at the first point that falls on it, which is exactly this point. + /// + public required long VisitX { get; init; } + + /// The vertical counterpart of . + public required long VisitY { get; init; } +} + +/// The code-blocks one precinct takes from one band, and that band's tag trees for the precinct. +internal sealed class PrecinctBand +{ + /// The band. + public required Subband Band { get; init; } + + /// The first code-block column, as an index into ' grid. + public required int BlockX0 { get; init; } + + /// The first code-block row, likewise. + public required int BlockY0 { get; init; } + + /// Code-blocks across this precinct in this band; zero when none fall inside it. + public required int BlocksWide { get; init; } + + /// Code-blocks down. + public required int BlocksHigh { get; init; } + + /// + /// The inclusion tag tree over this precinct's code-blocks: for each, the layer that first carries it. + /// + public TagTree? InclusionTree { get; private set; } + + /// The imsb tag tree: for each code-block, how many leading magnitude bit-planes are zero. + public TagTree? ZeroBitPlaneTree { get; private set; } + + /// + /// Creates the pair once, on first use. They persist across the packets of successive quality + /// layers: rebuilding them per packet decodes layer 0 correctly and every later layer wrongly. + /// + public void EnsureTagTrees() + { + InclusionTree ??= new TagTree(BlocksWide, BlocksHigh); + ZeroBitPlaneTree ??= new TagTree(BlocksWide, BlocksHigh); + } } /// @@ -194,7 +244,7 @@ internal sealed class Resolution /// tier-2 and the DWT both need (T.800 Annex B). /// /// Everything here is computed from declared numbers before a coded byte is -/// read, which is why is where the resource ceilings are +/// read, which is why is where the resource ceilings are /// charged. Hazard 6's point exactly: a SIZ and a COD alone can /// ask for an arbitrarily large object graph, and no amount of running out of /// input will stop it. @@ -220,30 +270,41 @@ internal sealed class TileComponent public required int DecodedResolutions { get; init; } /// - /// Builds the geometry for one component of a single-tile codestream, from that component's own - /// coding style and quantization (COD and QCD with its COC and QCC applied). + /// for the tile that + /// covers the whole image region: the only tile of a single-tile codestream. + /// + public static TileComponent Build( + CodestreamHeader header, int componentIndex, Jpeg2000SampleBudget budget, int decodedResolutions) + { + var siz = header.Siz; + return Build(header, componentIndex, new Rect(siz.X0, siz.Y0, siz.X1, siz.Y1), budget, decodedResolutions); + } + + /// + /// Builds the geometry for one component of one tile, from that component's own coding style + /// and quantization (COD and QCD with its COC and QCC applied). /// + /// The tile's extent on the reference grid (T.800 Equation B-7). /// /// How many resolution levels from 0 will be reconstructed; only their subbands get coefficient /// storage, and only theirs are charged to the budget as samples. /// public static TileComponent Build( - CodestreamHeader header, int componentIndex, Jpeg2000SampleBudget budget, int decodedResolutions) + CodestreamHeader header, int componentIndex, Rect tile, Jpeg2000SampleBudget budget, int decodedResolutions) { var siz = header.Siz; var cod = header.ComponentCoding[componentIndex]; var quantization = header.ComponentQuantization[componentIndex]; var component = siz.Components[componentIndex]; - // T.800 Equation B-12. With one tile the tile IS the image region, and - // with no subsampling the separation divides out to 1 -- but the formula - // is written in full because the coordinates, not the sizes, are what - // later equations consume. + // T.800 Equation B-12: the tile divided by the component's subsampling, rounding up. The + // formula is written in full because the coordinates, not the sizes, are what later + // equations consume. var bounds = new Rect( - CeilDiv(siz.X0, component.HorizontalSeparation), - CeilDiv(siz.Y0, component.VerticalSeparation), - CeilDiv(siz.X1, component.HorizontalSeparation), - CeilDiv(siz.Y1, component.VerticalSeparation)); + CeilDiv(tile.X0, component.HorizontalSeparation), + CeilDiv(tile.Y0, component.VerticalSeparation), + CeilDiv(tile.X1, component.HorizontalSeparation), + CeilDiv(tile.Y1, component.VerticalSeparation)); if (bounds.Area > Jpeg2000Limits.MaxTileComponentSamples) throw new InvalidDataException( @@ -301,7 +362,13 @@ public static TileComponent Build( bands[b] = band; } - resolutions[r] = new Resolution { Index = r, Bounds = resolutionBounds, Bands = bands }; + resolutions[r] = new Resolution + { + Index = r, + Bounds = resolutionBounds, + Bands = bands, + Precincts = BuildPrecincts(resolutionBounds, bands, cod, r, levels, tile, component, budget), + }; } return new TileComponent @@ -313,6 +380,88 @@ public static TileComponent Build( }; } + /// + /// T.800 B.6: partitions a resolution into precincts of 2^PPx x 2^PPy on the resolution grid, + /// anchored at its origin, and finds, for each precinct, the code-blocks of every band that fall + /// inside it. A band above resolution 0 sees the precinct at half size, 2^(PPx-1), because band + /// coordinates are resolution coordinates halved; and since Equation B-16 caps the code-block size + /// at that, code-blocks nest inside precincts exactly. + /// + private static Precinct[] BuildPrecincts( + Rect resolution, Subband[] bands, CodingStyle cod, int r, int levels, Rect tile, ComponentInfo component, + Jpeg2000SampleBudget budget) + { + if (resolution.IsEmpty) return []; + + var ppx = cod.PrecinctWidthExponent(r); + var ppy = cod.PrecinctHeightExponent(r); + var firstX = FloorDiv(resolution.X0, 1 << ppx); + var firstY = FloorDiv(resolution.Y0, 1 << ppy); + var wide = CeilDiv(resolution.X1, 1 << ppx) - firstX; + var high = CeilDiv(resolution.Y1, 1 << ppy) - firstY; + + // A precinct is a handful of objects, so the count is booked against the code-block + // ceiling: a header with tiny precincts on a large image must not buy an object graph its + // data could never fill. + var count = (long)wide * high; + if (count > Jpeg2000Limits.MaxCodeBlocks) + throw new InvalidDataException( + $"JPEG 2000: a resolution declares {count:N0} precincts, more than the " + + $"{Jpeg2000Limits.MaxCodeBlocks:N0} ceiling."); + budget.ChargeCodeBlocks((int)count); + + var bandPpx = r == 0 ? ppx : ppx - 1; + var bandPpy = r == 0 ? ppy : ppy - 1; + var scale = levels - r; + + var precincts = new Precinct[count]; + for (var py = 0; py < high; py++) + { + for (var px = 0; px < wide; px++) + { + long column = firstX + px; + long row = firstY + py; + + var precinctBands = new PrecinctBand[bands.Length]; + for (var b = 0; b < bands.Length; b++) + { + var band = bands[b]; + + // The precinct's code-blocks on the band's absolute block grid, clipped to the + // blocks the band has. + var x0 = Math.Max(band.FirstBlockColumn, (column << bandPpx) >> band.BlockWidthExponent); + var x1 = Math.Min(band.FirstBlockColumn + band.BlocksWide, + ((column + 1) << bandPpx) >> band.BlockWidthExponent); + var y0 = Math.Max(band.FirstBlockRow, (row << bandPpy) >> band.BlockHeightExponent); + var y1 = Math.Min(band.FirstBlockRow + band.BlocksHigh, + ((row + 1) << bandPpy) >> band.BlockHeightExponent); + + var empty = x1 <= x0 || y1 <= y0; + precinctBands[b] = new PrecinctBand + { + Band = band, + BlockX0 = empty ? 0 : (int)(x0 - band.FirstBlockColumn), + BlockY0 = empty ? 0 : (int)(y0 - band.FirstBlockRow), + BlocksWide = empty ? 0 : (int)(x1 - x0), + BlocksHigh = empty ? 0 : (int)(y1 - y0), + }; + } + + // Where B.12.1.3's walk over the reference grid first lands in this precinct: its + // corner on the resolution grid times 2^(NL - r) and the subsampling, or the tile's + // corner for the precinct that starts before the tile does. + precincts[py * wide + px] = new Precinct + { + Bands = precinctBands, + VisitX = Math.Max(tile.X0, ((column << ppx) << scale) * component.HorizontalSeparation), + VisitY = Math.Max(tile.Y0, ((row << ppy) << scale) * component.VerticalSeparation), + }; + } + } + + return precincts; + } + private static Subband BuildBand( BandKind kind, Rect bounds, int exponent, int guardBits, CodingStyle cod, int resolution, bool irreversible, float stepSize, bool decoded) @@ -375,6 +524,11 @@ private static Subband BuildBand( Blocks = blocks, Irreversible = irreversible, StepSize = stepSize, + CodeBlockStyle = cod.CodeBlockStyle, + BlockWidthExponent = blockWidthExponent, + BlockHeightExponent = blockHeightExponent, + FirstBlockColumn = firstColumn, + FirstBlockRow = firstRow, Coefficients = decoded && !irreversible ? new int[bounds.Area] : [], Values = decoded && irreversible ? new float[bounds.Area] : [], }; diff --git a/src/Sdcb.SimdCodecs.Jpeg2000/InverseWavelet.cs b/src/Sdcb.SimdCodecs.Jpeg2000/InverseWavelet.cs index 21c2a19..1d85d1e 100644 --- a/src/Sdcb.SimdCodecs.Jpeg2000/InverseWavelet.cs +++ b/src/Sdcb.SimdCodecs.Jpeg2000/InverseWavelet.cs @@ -248,7 +248,7 @@ private static void Filter(Span signal, int i0, int i1, int[] extended, int if (length == 1) { - RefuseALoneHighPassSample(i0); + if ((i0 & 1) != 0) signal[0] >>= 1; return; } @@ -302,7 +302,7 @@ private static void FilterColumns(Span samples, int width, int y0, int y1) var height = y1 - y0; if (height <= 1) { - if (height == 1) RefuseALoneHighPassSample(y0); + if (height == 1 && (y0 & 1) != 0) HalveRow(samples[..width]); return; } @@ -374,7 +374,7 @@ private static void Filter97(Span signal, int i0, int i1, float[] scratch if (length == 1) { - RefuseALoneHighPassSample(i0); + if ((i0 & 1) != 0) signal[0] *= 0.5f; return; } @@ -419,7 +419,7 @@ private static void FilterColumns97(Span samples, int width, int y0, int var height = y1 - y0; if (height <= 1) { - if (height == 1) RefuseALoneHighPassSample(y0); + if (height == 1 && (y0 & 1) != 0) HalveRow(samples[..width]); return; } @@ -464,23 +464,22 @@ private static void LiftRows97(Span samples, int width, int height, int f } } - /// - /// F.3.7's degenerate case. An even index is a lone low-pass sample and passes - /// through untouched; that happens for any image one sample wide, so it is - /// ordinary and reachable. An odd index is a lone HIGH-pass sample. The spec - /// halves it, but reaching this needs an odd image origin, which nothing - /// available here can encode — so there is no way to check it against the - /// reference. Refusing beats emitting a number no oracle has ever confirmed; - /// the whole family's discipline. - /// - private static void RefuseALoneHighPassSample(int i0) + // F.3.7's degenerate case, a signal one sample long. At an even index it is a lone low-pass + // sample and passes through untouched, which happens for any image one sample wide. At an odd + // index it is a lone HIGH-pass sample, which the forward transform doubled, so it is halved. + // That needs an odd tile or image origin on a resolution one sample across, which a tiled + // image whose tile size is odd reaches at its low resolutions. + + /// Halves a one-row signal that is a lone high-pass row, for the reversible filter. + private static void HalveRow(Span row) { - if ((i0 & 1) == 0) return; + for (var x = 0; x < row.Length; x++) row[x] >>= 1; + } - throw new NotSupportedException( - "JPEG 2000: a subband one sample wide starting at an odd coordinate is not implemented. " + - "It needs an odd image or tile origin, which opj_compress cannot emit, so this path has " + - "no reference to be validated against."); + /// Halves a one-row signal that is a lone high-pass row, for the irreversible filter. + private static void HalveRow(Span row) + { + for (var x = 0; x < row.Length; x++) row[x] *= 0.5f; } /// diff --git a/src/Sdcb.SimdCodecs.Jpeg2000/Jp2Reader.cs b/src/Sdcb.SimdCodecs.Jpeg2000/Jp2Reader.cs index 7a40fbc..f11cdae 100644 --- a/src/Sdcb.SimdCodecs.Jpeg2000/Jp2Reader.cs +++ b/src/Sdcb.SimdCodecs.Jpeg2000/Jp2Reader.cs @@ -1,5 +1,6 @@ using System; using System.Buffers.Binary; +using System.Collections.Generic; using System.IO; namespace Sdcb.SimdCodecs.Jpeg2000; @@ -37,19 +38,66 @@ public enum Jp2EnumeratedColourSpace public sealed record Jp2Colour(int Method, Jp2EnumeratedColourSpace? EnumeratedColourSpace, byte[]? IccProfile); /// Where a JP2 file's codestream is, and what its header said about colour. -internal readonly record struct Jp2Layout(int CodestreamStart, int CodestreamLength, Jp2Colour? Colour); +internal readonly record struct Jp2Layout(int CodestreamStart, int CodestreamLength, Jp2Colour? Colour) +{ + /// + /// The palette and component mapping boxes, or null when the header has neither and the + /// codestream's components are the image's channels as they stand. + /// + public Jp2ChannelMapping? Channels { get; init; } +} + +/// +/// A JP2 palette (pclr, T.800 I.5.3.4) together with the component mapping (cmap, +/// I.5.3.5) that says which channel of the image comes from which component, directly or through +/// which palette column. +/// +/// NE, the number of palette entries; 0 when there is no palette. +/// NPC, the number of palette columns. +/// The bit depth of each palette column. +/// The palette, Values[entry * Columns + column]. +/// Each output channel: the codestream component, and the palette column or -1 for direct use. +internal sealed record Jp2ChannelMapping( + int Entries, int Columns, int[] ColumnDepths, ushort[] Values, (int Component, int Column)[] Map) +{ + /// + /// The bit depth of every output channel, which must agree, since the decoded image carries one + /// precision. supplies it for the directly mapped channels. + /// + /// The mapping names a component that does not exist. + /// The channels come out at different precisions. + public int OutputDepth(IReadOnlyList componentDepths) + { + var depth = 0; + foreach (var (component, column) in Map) + { + if (component >= componentDepths.Count) + throw new InvalidDataException( + $"JPEG 2000: the component mapping box names component {component}, but the codestream has " + + $"{componentDepths.Count}."); + + var channelDepth = column < 0 ? componentDepths[component] : ColumnDepths[column]; + if (depth != 0 && channelDepth != depth) + throw new NotSupportedException( + "JPEG 2000: the JP2 channels come out at different bit depths, and the decoded image has one " + + "precision for all of them."); + depth = channelDepth; + } + + return depth; + } +} /// /// Reads the JP2 file format of T.800 Annex I far enough to find the codestream and its colour /// specification: the signature and file type boxes, the header superbox with its image header and /// colour boxes, and the contiguous codestream box. /// -/// What is refused, and why each is a refusal rather than something skipped. The palette -/// (pclr), component mapping (cmap) and channel definition (cdef) boxes each -/// change what the decoded components mean: a palette turns one component of indices into three of -/// colour, a mapping reorders them, a channel definition says one is alpha. A reader that skipped -/// them would hand back samples whose meaning it had silently got wrong, which is the failure this -/// decoder exists to avoid. None of the JP2 images found in real PDFs carries one. +/// The palette (pclr) and component mapping (cmap) boxes are read here and applied by +/// the decoder: a palette turns one component of indices into channels of colour. The channel +/// definition box (cdef) is refused, because it can say a component is alpha or reorder the +/// colour channels, and a reader that skipped it would hand back samples whose meaning it had +/// silently got wrong. /// /// /// The image header's own width, height and component count are not checked against the @@ -88,6 +136,7 @@ public static Jp2Layout Read(ReadOnlySpan data) var sawFileType = false; var sawHeader = false; Jp2Colour? colour = null; + Jp2ChannelMapping? channels = null; while (offset < data.Length) { @@ -109,7 +158,7 @@ public static Jp2Layout Read(ReadOnlySpan data) case Header: if (!sawFileType) throw new InvalidDataException("JPEG 2000: the JP2 header box comes before the file type box."); - colour = ReadHeader(body); + (colour, channels) = ReadHeader(body); sawHeader = true; break; @@ -118,7 +167,7 @@ public static Jp2Layout Read(ReadOnlySpan data) throw new InvalidDataException("JPEG 2000: the codestream box comes before the JP2 header box."); // The first codestream is the image (I.5.4); anything after it is not read. - return new Jp2Layout(bodyStart, bodyLength, colour); + return new Jp2Layout(bodyStart, bodyLength, colour) { Channels = channels }; } // Every other top-level box (resolution, XML, UUID, IPR) is metadata about the image, @@ -133,9 +182,11 @@ public static Jp2Layout Read(ReadOnlySpan data) /// The header superbox: the image header first (I.5.3.1), then at least one colour /// specification, of which only the first counts (I.5.3.3). /// - private static Jp2Colour? ReadHeader(ReadOnlySpan header) + private static (Jp2Colour? Colour, Jp2ChannelMapping? Channels) ReadHeader(ReadOnlySpan header) { Jp2Colour? colour = null; + Jp2ChannelMapping? palette = null; + (int Component, int Column)[]? map = null; var offset = 0; var first = true; @@ -166,14 +217,12 @@ public static Jp2Layout Read(ReadOnlySpan data) break; case Palette: - throw new NotSupportedException( - "JPEG 2000: the JP2 file has a palette box (pclr), which turns a component of " + - "indices into colours. It is not implemented, and ignoring it would return the " + - "indices as if they were the picture."); + palette ??= ReadPalette(body); + break; case ComponentMapping: - throw new NotSupportedException( - "JPEG 2000: the JP2 file has a component mapping box (cmap), which is not implemented."); + map ??= ReadComponentMapping(body); + break; case ChannelDefinition: throw new NotSupportedException( @@ -185,7 +234,115 @@ public static Jp2Layout Read(ReadOnlySpan data) } if (first) throw new InvalidDataException("JPEG 2000: the JP2 header box is empty."); - return colour; + return (colour, Combine(palette, map)); + } + + /// + /// I.5.3.4 and I.5.3.5 together. A palette is meaningless without the mapping that says which + /// component indexes it, so one without the other is malformed; a mapping without a palette can + /// only select components directly. + /// + private static Jp2ChannelMapping? Combine(Jp2ChannelMapping? palette, (int Component, int Column)[]? map) + { + if (palette is null && map is null) return null; + if (map is null) + throw new InvalidDataException("JPEG 2000: the JP2 file has a palette box but no component mapping box."); + + palette ??= new Jp2ChannelMapping(0, 0, [], [], []); + foreach (var (_, column) in map) + { + if (column >= palette.Columns) + throw new InvalidDataException( + $"JPEG 2000: the component mapping box names palette column {column}, but the palette has " + + $"{palette.Columns}."); + } + + return palette with { Map = map }; + } + + /// + /// The palette box (I.5.3.4): NE(2) entries and NPC(1) columns, a depth byte per column (the low + /// seven bits are the depth less one, the top bit the sign), then the entries one after another, + /// each value in as many whole bytes as its column's depth needs. + /// + private static Jp2ChannelMapping ReadPalette(ReadOnlySpan body) + { + if (body.Length < 3) + throw new InvalidDataException("JPEG 2000: the palette box is too short."); + + var entries = BinaryPrimitives.ReadUInt16BigEndian(body); + int columns = body[2]; + + // I.5.3.4 bounds NE at 1024 and NPC at 255, and neither below 1. The box is checked against + // its own length before anything is sized from it, so the table it allocates is never more + // than the bytes that describe it. + if (entries is 0 or > 1024 || columns == 0) + throw new InvalidDataException( + $"JPEG 2000: the palette box declares {entries} entries of {columns} columns, outside what T.800 allows."); + if (body.Length < 3 + columns) + throw new InvalidDataException("JPEG 2000: the palette box is too short for its column depths."); + + var depths = new int[columns]; + var bytes = new int[columns]; + var rowBytes = 0; + for (var c = 0; c < columns; c++) + { + var b = body[3 + c]; + if ((b & 0x80) != 0) + throw new NotSupportedException("JPEG 2000: signed palette columns are not implemented."); + + depths[c] = (b & 0x7F) + 1; + if (depths[c] > 16) + throw new NotSupportedException( + $"JPEG 2000: a {depths[c]}-bit palette column is not implemented; the limit here is 16."); + bytes[c] = (depths[c] + 7) / 8; + rowBytes += bytes[c]; + } + + var offset = 3 + columns; + if (body.Length - offset < (long)entries * rowBytes) + throw new InvalidDataException("JPEG 2000: the palette box is too short for its entries."); + + var values = new ushort[entries * columns]; + for (var e = 0; e < entries; e++) + { + for (var c = 0; c < columns; c++) + { + int value = bytes[c] == 1 ? body[offset] : BinaryPrimitives.ReadUInt16BigEndian(body[offset..]); + offset += bytes[c]; + + // Bits above the column's declared depth are not part of the value. + values[e * columns + c] = (ushort)(value & ((1 << depths[c]) - 1)); + } + } + + return new Jp2ChannelMapping(entries, columns, depths, values, []); + } + + /// + /// The component mapping box (I.5.3.5): four bytes per output channel, CMP(2) MTYP(1) PCOL(1). + /// MTYP 0 takes component CMP as it is, MTYP 1 looks it up in palette column PCOL. + /// + private static (int Component, int Column)[] ReadComponentMapping(ReadOnlySpan body) + { + if (body.Length == 0 || body.Length % 4 != 0) + throw new InvalidDataException("JPEG 2000: the component mapping box is not a whole number of entries."); + + var map = new (int Component, int Column)[body.Length / 4]; + for (var i = 0; i < map.Length; i++) + { + var entry = body.Slice(i * 4, 4); + int component = BinaryPrimitives.ReadUInt16BigEndian(entry); + map[i] = entry[2] switch + { + 0 => (component, -1), + 1 => (component, entry[3]), + _ => throw new InvalidDataException( + $"JPEG 2000: the component mapping box has mapping type {entry[2]}, which T.800 does not define."), + }; + } + + return map; } /// diff --git a/src/Sdcb.SimdCodecs.Jpeg2000/Jpeg2000Decoder.cs b/src/Sdcb.SimdCodecs.Jpeg2000/Jpeg2000Decoder.cs index 02c6e3c..b568c66 100644 --- a/src/Sdcb.SimdCodecs.Jpeg2000/Jpeg2000Decoder.cs +++ b/src/Sdcb.SimdCodecs.Jpeg2000/Jpeg2000Decoder.cs @@ -1,4 +1,6 @@ using System; +using System.Buffers; +using System.Collections.Generic; using System.IO; using System.Threading; @@ -100,15 +102,23 @@ public sealed record Jpeg2000Info( /// 15444-1, Part 1). /// /// What this decodes today. A raw J2K codestream, or one in a JP2 file -/// with no palette, component mapping or channel definition, holding any number of -/// 1-to-16-bit unsigned components of one precision, in a single tile, at full -/// resolution, coded in any number of quality layers with maximal precincts and -/// LRCP progression: losslessly with the reversible 5/3 wavelet, or lossily with -/// the irreversible 9/7 wavelet and scalar quantization, with or without the -/// matching component transform (RCT or ICT). Quality layers are supported -/// because every /JPXDecode image measured in real PDFs has several. -/// Anything outside it raises naming the -/// feature and the rung that owns it — never a plausible-looking wrong raster. +/// (with or without a palette and component mapping, but no channel definition), +/// holding any number of 1-to-16-bit unsigned components of one precision, in any +/// number of tiles and tile-parts, coded in any number of quality layers, with any +/// precinct sizes, in any of the five progression orders, with or without SOP and +/// EPH markers: losslessly with the reversible 5/3 wavelet, or lossily with the +/// irreversible 9/7 wavelet and scalar quantization, with or without the matching +/// component transform (RCT or ICT). Of the code-block styles, context reset, +/// predictable termination and segmentation symbols are decoded. Anything outside +/// it (POC, PPM / PPT, RGN, tile-part header overrides, the bypass, per-pass +/// termination and vertically causal code-block styles, subsampling, signed or +/// mixed-precision components) raises naming +/// the feature — never a plausible-looking wrong raster. +/// +/// +/// Issue #1's JPEG 2000 files are the real-world check on all of that; see +/// Issue1Jpeg2000CorpusTests in the test project for how to run them after +/// changing this decoder. /// /// /// Why there is no partial-credit mode. JPEG 2000 has no early pipeline @@ -171,10 +181,9 @@ public static Jpeg2000Image Decode(ReadOnlySpan data, int reduce, Cancella if (!Jp2Reader.LooksLikeJp2(data)) return DecodeCodestream(data, reduce, cancellationToken); var layout = Jp2Reader.Read(data); - return DecodeCodestream(data.Slice(layout.CodestreamStart, layout.CodestreamLength), reduce, cancellationToken) with - { - Colour = layout.Colour, - }; + var image = DecodeCodestream(data.Slice(layout.CodestreamStart, layout.CodestreamLength), reduce, cancellationToken); + if (layout.Channels is { } channels) image = ApplyChannels(image, channels); + return image with { Colour = layout.Colour }; } /// @@ -188,11 +197,13 @@ public static Jpeg2000Info ReadInfo(ReadOnlySpan data) { var codestream = data; Jp2Colour? colour = null; + Jp2ChannelMapping? channels = null; if (Jp2Reader.LooksLikeJp2(data)) { var layout = Jp2Reader.Read(data); codestream = data.Slice(layout.CodestreamStart, layout.CodestreamLength); colour = layout.Colour; + channels = layout.Channels; } var header = CodestreamReader.Read(codestream); @@ -200,7 +211,15 @@ public static Jpeg2000Info ReadInfo(ReadOnlySpan data) var levels = int.MaxValue; foreach (var coding in header.ComponentCoding) levels = Math.Min(levels, coding.DecompositionLevels); - return new Jpeg2000Info(siz.Width, siz.Height, siz.Components.Length, siz.Components[0].BitDepth, levels, colour) + var components = siz.Components.Length; + var depth = siz.Components[0].BitDepth; + if (channels is not null) + { + depth = channels.OutputDepth(ComponentDepths(components, depth)); + components = channels.Map.Length; + } + + return new Jpeg2000Info(siz.Width, siz.Height, components, depth, levels, colour) { Region = new Rect(siz.X0, siz.Y0, siz.X1, siz.Y1), }; @@ -228,6 +247,42 @@ public static int ReductionFor(int width, int height, int minLongEdge) return reduce; } + private static int[] ComponentDepths(int components, int depth) + { + var depths = new int[components]; + Array.Fill(depths, depth); + return depths; + } + + /// + /// The JP2 component mapping (T.800 I.5.3.5) applied to the decoded components: each output + /// channel is either a component as it is or a palette column looked up by a component's sample. + /// An index past the last palette entry takes the last entry, which only a damaged file needs. + /// + private static Jpeg2000Image ApplyChannels(Jpeg2000Image image, Jp2ChannelMapping channels) + { + var depth = channels.OutputDepth(ComponentDepths(image.Components, image.BitDepth)); + var map = channels.Map; + var pixels = (long)image.Width * image.Height; + var output = new ushort[pixels * map.Length]; + + for (var i = 0; i < map.Length; i++) + { + var (component, column) = map[i]; + var source = image.Samples; + var stride = image.Components; + for (long p = 0, o = i, s = component; p < pixels; p++, o += map.Length, s += stride) + { + var value = source[s]; + output[o] = column < 0 + ? value + : channels.Values[Math.Min(value, channels.Entries - 1) * channels.Columns + column]; + } + } + + return new Jpeg2000Image(image.Width, image.Height, depth, output, map.Length); + } + private static Jpeg2000Image DecodeCodestream(ReadOnlySpan data, int reduce, CancellationToken cancellationToken = default) { var header = CodestreamReader.Read(data); @@ -239,70 +294,179 @@ private static Jpeg2000Image DecodeCodestream(ReadOnlySpan data, int reduc foreach (var coding in header.ComponentCoding) levels = Math.Min(levels, coding.DecompositionLevels); reduce = Math.Min(reduce, levels); + var tiles = GroupTileParts(header); + var componentCount = siz.Components.Length; + + // The decoded image's region on the reference grid at this reduction (Equation B-14): the + // tiles' reduced regions, rounded the same way, cover it exactly. + var region = new Rect( + TileComponent.CeilDivPow2(siz.X0, reduce), TileComponent.CeilDivPow2(siz.Y0, reduce), + TileComponent.CeilDivPow2(siz.X1, reduce), TileComponent.CeilDivPow2(siz.Y1, reduce)); + var total = region.Area * componentCount; + if (total > Array.MaxLength) + throw new InvalidDataException( + $"JPEG 2000: a {region.Width}x{region.Height} image of {componentCount} components is " + + $"{total:N0} samples, more than one array can hold."); + + // Allocated once and written tile by tile, so a decode holds the output and one tile's + // working set at a time. A codestream must carry at least one tile-part per tile (checked + // above), so the tile count, and with it this loop, is bounded by the data itself. + var output = new ushort[total]; + var blockState = new BlockState(); + var tilesWide = (int)siz.TilesWide; + + for (var t = 0; t < tiles.Length; t++) + { + cancellationToken.ThrowIfCancellationRequested(); + + // T.800 Equation B-7: the tile's extent, clipped to the image region. + var p = t % tilesWide; + var q = t / tilesWide; + var tile = new Rect( + (int)Math.Max(siz.TileX0 + (long)p * siz.TileWidth, siz.X0), + (int)Math.Max(siz.TileY0 + (long)q * siz.TileHeight, siz.Y0), + (int)Math.Min(siz.TileX0 + (long)(p + 1) * siz.TileWidth, siz.X1), + (int)Math.Min(siz.TileY0 + (long)(q + 1) * siz.TileHeight, siz.Y1)); + + DecodeTile(header, data, tiles[t], tile, reduce, region, output, blockState, cancellationToken); + } + + return new Jpeg2000Image(region.Width, region.Height, siz.Components[0].BitDepth, output, componentCount); + } + + /// + /// Every tile's tile-parts, in TPsot order. T.800 A.4.2 lets the parts of different tiles + /// interleave, but each tile's own parts carry its packets in sequence, and every tile has at + /// least one. + /// + private static List[] GroupTileParts(CodestreamHeader header) + { + var count = (int)header.Siz.TileCount; + if (header.TileParts.Count < count) + throw new InvalidDataException( + $"JPEG 2000: SIZ declares {count:N0} tiles, but the codestream has only " + + $"{header.TileParts.Count:N0} tile-parts."); + + var tiles = new List[count]; + foreach (var part in header.TileParts) (tiles[part.TileIndex] ??= []).Add(part); + + for (var t = 0; t < count; t++) + { + var parts = tiles[t] + ?? throw new InvalidDataException($"JPEG 2000: tile {t} has no tile-part."); + parts.Sort(static (a, b) => a.PartIndex.CompareTo(b.PartIndex)); + for (var i = 0; i < parts.Count; i++) + { + if (parts[i].PartIndex != i) + throw new InvalidDataException( + $"JPEG 2000: tile {t}'s tile-parts are numbered {parts[i].PartIndex} where {i} was due."); + } + } + + return tiles; + } + + /// One tile, from its packets to its samples in . + private static void DecodeTile( + CodestreamHeader header, ReadOnlySpan data, List parts, Rect tile, int reduce, Rect region, + ushort[] output, BlockState blockState, CancellationToken cancellationToken) + { + var siz = header.Siz; + + // Each tile is metered against its own extent: the budget bounds amplification, which is + // per tile, and a tile's buffers are released before the next one is built. var budget = new Jpeg2000SampleBudget( - Jpeg2000Limits.BudgetFor(siz.Width, siz.Height, siz.Components.Length)); + Jpeg2000Limits.BudgetFor(tile.Width, tile.Height, siz.Components.Length)); var components = new TileComponent[siz.Components.Length]; for (var c = 0; c < components.Length; c++) { cancellationToken.ThrowIfCancellationRequested(); components[c] = TileComponent.Build( - header, c, budget, header.ComponentCoding[c].ResolutionCount - reduce); + header, c, tile, budget, header.ComponentCoding[c].ResolutionCount - reduce); } - cancellationToken.ThrowIfCancellationRequested(); - var part = header.TileParts[0]; - var tilePartData = data.Slice(part.Start, part.Length); + // The packets run on from one tile-part into the next, so a tile split into parts is read + // as their bytes put back together. + byte[]? rented = null; + ReadOnlySpan tileData; + if (parts.Count == 1) + { + tileData = data.Slice(parts[0].Start, parts[0].Length); + } + else + { + var length = 0; + foreach (var part in parts) length += part.Length; + rented = ArrayPool.Shared.Rent(length); + var offset = 0; + foreach (var part in parts) + { + data.Slice(part.Start, part.Length).CopyTo(rented.AsSpan(offset)); + offset += part.Length; + } - // Tier-2 first, over the whole tile-part: it discovers which code-blocks - // carry data and where, without decoding any of it. The components' - // packets are interleaved in the tile-part, so it reads them all at once. - Tier2.ReadPackets(header, components, tilePartData); + tileData = rented.AsSpan(0, length); + } - // Then tier-1, block by block, and the inverse wavelet, component by - // component: in integers for a 5/3 component, in floats for a 9/7 one. - var integers = new int[components.Length][]; - var reals = new float[components.Length][]; - var blockState = new BlockState(); - for (var c = 0; c < components.Length; c++) + try { - cancellationToken.ThrowIfCancellationRequested(); - var tile = components[c]; - for (var r = 0; r < tile.DecodedResolutions; r++) + // Tier-2 first, over the whole tile: it discovers which code-blocks carry data and + // where, without decoding any of it. The components' packets are interleaved, so it + // reads them all at once. + Tier2.ReadPackets(header, components, tileData, tile); + + // Then tier-1, block by block, and the inverse wavelet, component by component: in + // integers for a 5/3 component, in floats for a 9/7 one. + var integers = new int[components.Length][]; + var reals = new float[components.Length][]; + for (var c = 0; c < components.Length; c++) { - foreach (var band in tile.Resolutions[r].Bands) + cancellationToken.ThrowIfCancellationRequested(); + var tileComponent = components[c]; + for (var r = 0; r < tileComponent.DecodedResolutions; r++) { - foreach (var block in band.Blocks) + foreach (var band in tileComponent.Resolutions[r].Bands) { - BlockDecoder.Decode(band, block, tilePartData, blockState); + foreach (var block in band.Blocks) + { + BlockDecoder.Decode(band, block, tileData, blockState); + } } } + + var decodedBounds = tileComponent.Resolutions[tileComponent.DecodedResolutions - 1].Bounds; + budget.Charge(decodedBounds.Width, decodedBounds.Height); + if (tileComponent.Irreversible) + reals[c] = InverseWavelet.ReconstructIrreversible(tileComponent, tileComponent.DecodedResolutions); + else + integers[c] = InverseWavelet.Reconstruct(tileComponent, tileComponent.DecodedResolutions); } - var decodedBounds = tile.Resolutions[tile.DecodedResolutions - 1].Bounds; - budget.Charge(decodedBounds.Width, decodedBounds.Height); - if (tile.Irreversible) reals[c] = InverseWavelet.ReconstructIrreversible(tile, tile.DecodedResolutions); - else integers[c] = InverseWavelet.Reconstruct(tile, tile.DecodedResolutions); - } + // G.1.2: the component transform comes between the wavelet and the level shift. The + // reader has already checked that components 0 to 2 share one wavelet, which picks RCT + // for the 5/3 and ICT for the 9/7. + if (header.MultipleComponentTransform) + { + if (components[0].Irreversible) ComponentTransform.InverseIct(reals[0], reals[1], reals[2]); + else ComponentTransform.InverseRct(integers[0], integers[1], integers[2]); + } - // G.1.2: the component transform comes between the wavelet and the - // level shift. The reader has already checked that components 0 to 2 - // share one wavelet, which picks RCT for the 5/3 and ICT for the 9/7. - if (header.MultipleComponentTransform) + var first = components[0]; + LevelShift(integers, reals, first.Resolutions[first.DecodedResolutions - 1].Bounds, + siz.Components[0].BitDepth, output, region); + } + finally { - if (components[0].Irreversible) ComponentTransform.InverseIct(reals[0], reals[1], reals[2]); - else ComponentTransform.InverseRct(integers[0], integers[1], integers[2]); + if (rented is not null) ArrayPool.Shared.Return(rented); } - - var first = components[0]; - return LevelShift( - integers, reals, first.Resolutions[first.DecodedResolutions - 1].Bounds, siz.Components[0].BitDepth); } /// /// T.800 G.1.2: undo the DC level shift the encoder applied to make an /// unsigned component signed, then clamp to the declared precision, and - /// interleave the components into one sample per component per pixel. + /// interleave the components into one sample per component per pixel, at the + /// tile's place in the image. /// /// The clamp is not belt-and-braces. A lossless codestream reconstructs /// exactly and needs none, but a truncated or hostile one can produce @@ -318,33 +482,38 @@ private static Jpeg2000Image DecodeCodestream(ReadOnlySpan data, int reduc /// /// Each reversible component's samples, null for an irreversible one. /// Each irreversible component's samples, null for a reversible one. - private static Jpeg2000Image LevelShift(int[]?[] integers, float[]?[] reals, Rect bounds, int bitDepth) + /// The tile's decoded extent, on the same grid as . + /// The whole image's interleaved samples. + /// The whole image's decoded extent. + private static void LevelShift( + int[]?[] integers, float[]?[] reals, Rect bounds, int bitDepth, ushort[] output, Rect region) { var shift = 1 << (bitDepth - 1); var maximum = (1 << bitDepth) - 1; var count = integers.Length; + var width = bounds.Width; - var output = new ushort[(long)bounds.Width * bounds.Height * count]; for (var c = 0; c < count; c++) { - if (integers[c] is { } plane) + for (var y = 0; y < bounds.Height; y++) { - for (int i = 0, o = c; i < plane.Length; i++, o += count) + var o = (((long)(bounds.Y0 - region.Y0 + y) * region.Width) + (bounds.X0 - region.X0)) * count + c; + var i = y * width; + if (integers[c] is { } plane) { - output[o] = (ushort)Math.Clamp(plane[i] + shift, 0, maximum); + for (var x = 0; x < width; x++, i++, o += count) + output[o] = (ushort)Math.Clamp(plane[i] + shift, 0, maximum); } - } - else - { - var real = reals[c]!; - for (int i = 0, o = c; i < real.Length; i++, o += count) + else { - // Clamped as a float, so a value no int can hold still lands on a rail. - output[o] = (ushort)Math.Clamp(MathF.Round(real[i]) + shift, 0f, maximum); + var real = reals[c]!; + for (var x = 0; x < width; x++, i++, o += count) + { + // Clamped as a float, so a value no int can hold still lands on a rail. + output[o] = (ushort)Math.Clamp(MathF.Round(real[i]) + shift, 0f, maximum); + } } } } - - return new Jpeg2000Image(bounds.Width, bounds.Height, bitDepth, output, count); } } diff --git a/src/Sdcb.SimdCodecs.Jpeg2000/Jpeg2000ImageDecoder.cs b/src/Sdcb.SimdCodecs.Jpeg2000/Jpeg2000ImageDecoder.cs index 5644b1f..611a528 100644 --- a/src/Sdcb.SimdCodecs.Jpeg2000/Jpeg2000ImageDecoder.cs +++ b/src/Sdcb.SimdCodecs.Jpeg2000/Jpeg2000ImageDecoder.cs @@ -61,20 +61,13 @@ public static bool TryReadInfo(ReadOnlySpan data, out ImageInfo info) try { - var codestream = data; - Jp2Colour? colour = null; - if (Jpeg2000Decoder.IsJp2(data)) - { - var layout = Jp2Reader.Read(data); - codestream = data.Slice(layout.CodestreamStart, layout.CodestreamLength); - colour = layout.Colour; - } - - var siz = CodestreamReader.Read(codestream).Siz; - if (!Presentable(siz.Components.Length, colour)) return false; + // ReadInfo, not SIZ alone: a JP2 palette changes how many channels, and of what depth, + // the decoded image has. + var j2k = Jpeg2000Decoder.ReadInfo(data); + if (!Presentable(j2k.Components, j2k.Colour)) return false; - var depth = siz.Components[0].BitDepth; - info = new ImageInfo(siz.Width, siz.Height, siz.Components.Length, FormatFor(depth)) + var depth = j2k.BitDepth; + info = new ImageInfo(j2k.Width, j2k.Height, j2k.Components, FormatFor(depth)) { Format = ImageFormat.Jpeg2000, BitDepth = depth, diff --git a/src/Sdcb.SimdCodecs.Jpeg2000/Markers.cs b/src/Sdcb.SimdCodecs.Jpeg2000/Markers.cs index 29d709f..afb675e 100644 --- a/src/Sdcb.SimdCodecs.Jpeg2000/Markers.cs +++ b/src/Sdcb.SimdCodecs.Jpeg2000/Markers.cs @@ -209,6 +209,28 @@ public int PrecinctHeightExponent(int resolution) => PrecinctSizes.Length == 0 ? 15 : (PrecinctSizes[Math.Min(resolution, PrecinctSizes.Length - 1)] >> 4) & 0x0F; } +/// The code-block style flags of T.800 Table A.19. +internal static class CodeBlockStyles +{ + /// Selective arithmetic coding bypass. + public const int Bypass = 0x01; + + /// Reset the context probabilities at the end of every coding pass. + public const int ResetContexts = 0x02; + + /// Terminate the codeword at the end of every coding pass. + public const int TerminateEachPass = 0x04; + + /// Vertically causal context formation. + public const int VerticallyCausal = 0x08; + + /// Predictable termination: a constraint on the encoder, invisible to a decoder. + public const int PredictableTermination = 0x10; + + /// A four-symbol segmentation marker after every cleanup pass. + public const int SegmentationSymbols = 0x20; +} + /// Quantization style (T.800 Table A.28), the low five bits of Sqcd. internal enum QuantizationStyle { diff --git a/src/Sdcb.SimdCodecs.Jpeg2000/PacketBitReader.cs b/src/Sdcb.SimdCodecs.Jpeg2000/PacketBitReader.cs index cbc7a5f..6708a96 100644 --- a/src/Sdcb.SimdCodecs.Jpeg2000/PacketBitReader.cs +++ b/src/Sdcb.SimdCodecs.Jpeg2000/PacketBitReader.cs @@ -89,9 +89,9 @@ public void AlignToByte() /// Hazard 5. That kept state spans the packets of successive quality /// layers, not just the leaves within one packet. Re-creating the trees per /// packet decodes layer 0 correctly and every later layer wrongly, and a -/// single-layer fixture cannot tell the difference — which is why rung 1 owning -/// this class does not mean rung 1 has tested it. That is rung 3's job, and it -/// needs a multi-layer fixture built for the purpose. +/// single-layer fixture cannot tell the difference, which is why the multi-layer +/// fixtures exist. The trees belong to a precinct () +/// and live as long as it does. /// /// internal sealed class TagTree diff --git a/src/Sdcb.SimdCodecs.Jpeg2000/Tier2.cs b/src/Sdcb.SimdCodecs.Jpeg2000/Tier2.cs index 9e6ad69..1be6a6c 100644 --- a/src/Sdcb.SimdCodecs.Jpeg2000/Tier2.cs +++ b/src/Sdcb.SimdCodecs.Jpeg2000/Tier2.cs @@ -5,64 +5,184 @@ namespace Sdcb.SimdCodecs.Jpeg2000; /// -/// Tier-2: the packet layer (T.800 B.9 and B.10). It reads packet headers to +/// Tier-2: the packet layer (T.800 B.9 to B.12). It reads packet headers to /// discover which code-blocks carry data, how many coding passes each /// got and how many bytes those passes occupy, then hands the byte ranges to /// tier-1. It never touches a coefficient. /// -/// The envelope keeps the packet sequence simple: one tile, and maximal precincts -/// so each resolution of each component is a single precinct. Under LRCP that is -/// one packet per layer, resolution and component, nested in that order. The five -/// progression orders differ only in how they nest those loops, which is why -/// still refuses the other four rather than -/// accepting them for free: with several layers and components they really do -/// visit the packets in different sequences, and only this one has a fixture. +/// A tile's packets are one per layer, resolution, component and precinct, and +/// the progression order of COD says how those four loops nest (B.12.1). The two +/// layer-and-resolution orders, LRCP and RLCP, walk precincts by their index. The +/// three position-driven ones, RPCL, PCRL and CPRL, walk the reference grid and +/// take each precinct at the first grid point that lands in it; that point is +/// worked out once per precinct (), so the walk +/// becomes a sort by position rather than a visit of every grid point. /// /// /// Layers are where tier-2 becomes stateful. A code-block's inclusion, /// zero bit-planes, Lblock and pass count, and its precinct's tag trees, /// all carry over from one layer's packet to the next. A decoder that rebuilt any -/// of them per packet would read layer 0 correctly and every later layer wrongly, -/// which is hazard 5 of the roadmap and the reason the multi-layer fixtures exist. +/// of them per packet would read layer 0 correctly and every later layer wrongly. /// /// internal static class Tier2 { /// - /// Reads every packet of the tile-part, populating each code-block's - /// inclusion state and coded byte ranges. + /// Reads every packet of a single-tile codestream's one tile. See + /// . + /// + public static void ReadPackets(CodestreamHeader header, TileComponent[] components, ReadOnlySpan data) + { + var siz = header.Siz; + ReadPackets(header, components, data, new Rect(siz.X0, siz.Y0, siz.X1, siz.Y1)); + } + + /// + /// Reads every packet of one tile, populating each code-block's inclusion state and coded byte + /// ranges. /// /// The parsed main header. /// Each component's tile-component geometry to fill in. - /// The tile-part's coded data, starting just after SOD. - public static void ReadPackets(CodestreamHeader header, TileComponent[] components, ReadOnlySpan data) + /// The tile's coded data: every tile-part's bytes after SOD, in order. + /// The tile's extent on the reference grid, unused here but kept for the caller's symmetry. + public static void ReadPackets(CodestreamHeader header, TileComponent[] components, ReadOnlySpan data, Rect tile) { - var offset = 0; + _ = tile; + var reader = new PacketStream(header.UseSopMarkers, header.UseEphMarkers); + var layers = header.Layers; var resolutions = 0; foreach (var component in components) resolutions = Math.Max(resolutions, component.Resolutions.Length); - // B.12.1.1, LRCP: layer, then resolution, then component, then precinct, the last of which is - // length 1 here. A COC can give components different numbers of decomposition levels, and a - // component simply has no packet at a resolution it does not have. - for (var layer = 0; layer < header.Layers; layer++) + // A COC can give components different numbers of decomposition levels, and a component + // simply has no packet at a resolution it does not have. + switch (header.Progression) { - for (var r = 0; r < resolutions; r++) - { - foreach (var component in components) + case ProgressionOrder.Lrcp: + // B.12.1.1: layer, resolution, component, precinct. + for (var layer = 0; layer < layers; layer++) + for (var r = 0; r < resolutions; r++) + foreach (var component in components) + { + if (r >= component.Resolutions.Length) continue; + foreach (var precinct in component.Resolutions[r].Precincts) + reader.Read(precinct, data, layer); + } + break; + + case ProgressionOrder.Rlcp: + // B.12.1.2: resolution, layer, component, precinct. + for (var r = 0; r < resolutions; r++) + for (var layer = 0; layer < layers; layer++) + foreach (var component in components) + { + if (r >= component.Resolutions.Length) continue; + foreach (var precinct in component.Resolutions[r].Precincts) + reader.Read(precinct, data, layer); + } + break; + + case ProgressionOrder.Rpcl: + // B.12.1.3: resolution, then position (y, x), then component, then layer. + for (var r = 0; r < resolutions; r++) { - if (r >= component.Resolutions.Length) continue; - offset = ReadPacket(component.Resolutions[r], data, offset, layer); + var visits = new List(); + for (var c = 0; c < components.Length; c++) + if (r < components[c].Resolutions.Length) + AddVisits(visits, components[c].Resolutions[r], c, r); + + visits.Sort(static (a, b) => Compare(a, b, componentFirst: true)); + ReadAllLayers(ref reader, visits, data, layers); } + break; + + case ProgressionOrder.Pcrl: + { + // B.12.1.4: position (y, x), then component, then resolution, then layer. + var visits = new List(); + for (var c = 0; c < components.Length; c++) + for (var r = 0; r < components[c].Resolutions.Length; r++) + AddVisits(visits, components[c].Resolutions[r], c, r); + + visits.Sort(static (a, b) => Compare(a, b, componentFirst: true)); + ReadAllLayers(ref reader, visits, data, layers); + break; } + + case ProgressionOrder.Cprl: + // B.12.1.5: component, then position (y, x), then resolution, then layer. + for (var c = 0; c < components.Length; c++) + { + var visits = new List(); + for (var r = 0; r < components[c].Resolutions.Length; r++) + AddVisits(visits, components[c].Resolutions[r], c, r); + + visits.Sort(static (a, b) => Compare(a, b, componentFirst: false)); + ReadAllLayers(ref reader, visits, data, layers); + } + break; + + default: + throw new InvalidDataException($"JPEG 2000: progression order {header.Progression} is not defined."); } } - private static int ReadPacket(Resolution resolution, ReadOnlySpan data, int offset, int layer) + /// One precinct, with what the position-driven orders sort it by. + private readonly record struct Visit(Precinct Precinct, long Y, long X, int Component, int Resolution); + + private static void AddVisits(List visits, Resolution resolution, int component, int r) + { + foreach (var precinct in resolution.Precincts) + visits.Add(new Visit(precinct, precinct.VisitY, precinct.VisitX, component, r)); + } + + /// + /// The position orders sort by y, then x, then the two inner loops: component before resolution + /// for RPCL (where the resolution is fixed anyway) and PCRL, resolution alone for CPRL (where the + /// component is). Two precincts of the same component and resolution never share a visiting + /// point, so the order is total. + /// + private static int Compare(Visit a, Visit b, bool componentFirst) + { + var order = a.Y.CompareTo(b.Y); + if (order != 0) return order; + order = a.X.CompareTo(b.X); + if (order != 0) return order; + if (componentFirst) + { + order = a.Component.CompareTo(b.Component); + return order != 0 ? order : a.Resolution.CompareTo(b.Resolution); + } + + return a.Resolution.CompareTo(b.Resolution); + } + + private static void ReadAllLayers(ref PacketStream reader, List visits, ReadOnlySpan data, int layers) + { + foreach (var visit in visits) + for (var layer = 0; layer < layers; layer++) + reader.Read(visit.Precinct, data, layer); + } + + /// The read position through a tile's packets, and the two optional markers around each. + private struct PacketStream(bool sop, bool eph) + { + private int _offset; + + public void Read(Precinct precinct, ReadOnlySpan data, int layer) => + _offset = ReadPacket(precinct, data, _offset, layer, sop, eph); + } + + private static int ReadPacket(Precinct precinct, ReadOnlySpan data, int offset, int layer, bool sop, bool eph) { if (offset > data.Length) throw new InvalidDataException("JPEG 2000: ran out of tile-part data before the last packet."); + // A.8.1: when COD allows SOP markers, each packet MAY start with one: FF91, a length of 4 + // and a 16-bit sequence number. Nothing in it changes how the packet is read. + if (sop && offset + 6 <= data.Length && data[offset] == 0xFF && data[offset + 1] == 0x91) + offset += 6; + var reader = new PacketBitReader(data[offset..]); // B.10.3: a leading zero says the packet is empty. It is not a corner @@ -71,25 +191,24 @@ private static int ReadPacket(Resolution resolution, ReadOnlySpan data, in if (reader.ReadBit() == 0) { reader.AlignToByte(); - return offset + reader.Position; + return SkipEph(data, offset + reader.Position, eph); } var pending = new List<(CodeBlock Block, int Length)>(); - foreach (var band in resolution.Bands) + foreach (var part in precinct.Bands) { - if (band.Bounds.IsEmpty) continue; + if (part.BlocksWide == 0 || part.BlocksHigh == 0) continue; - // One precinct per resolution, so one pair of tag trees per band, - // covering the whole code-block grid. Rung 3 makes these per-precinct - // and must keep them alive across layers -- see TagTree's remarks. - band.EnsureTagTrees(); + var band = part.Band; + part.EnsureTagTrees(); - for (var by = 0; by < band.BlocksHigh; by++) + // B.10: the precinct's code-blocks in each band, in raster order. + for (var by = 0; by < part.BlocksHigh; by++) { - for (var bx = 0; bx < band.BlocksWide; bx++) + for (var bx = 0; bx < part.BlocksWide; bx++) { - var block = band.Blocks[by * band.BlocksWide + bx]; + var block = band.Blocks[(part.BlockY0 + by) * band.BlocksWide + part.BlockX0 + bx]; bool included; if (block.Included) @@ -103,7 +222,7 @@ private static int ReadPacket(Resolution resolution, ReadOnlySpan data, in // Not yet included: the tag tree holds the layer number in // which it first will be, so "included now" is "that // number is at most this layer". - included = band.InclusionTree!.Decode(ref reader, bx, by, layer + 1); + included = part.InclusionTree!.Decode(ref reader, bx, by, layer + 1); } if (!included) continue; @@ -118,7 +237,7 @@ private static int ReadPacket(Resolution resolution, ReadOnlySpan data, in // driven by attacker-supplied bits and must be guaranteed // to advance. var threshold = 1; - while (!band.ZeroBitPlaneTree!.Decode(ref reader, bx, by, threshold)) + while (!part.ZeroBitPlaneTree!.Decode(ref reader, bx, by, threshold)) { if (++threshold > MaxZeroBitPlanes) throw new InvalidDataException( @@ -126,7 +245,7 @@ private static int ReadPacket(Resolution resolution, ReadOnlySpan data, in "all-zero bit-planes, which no legal codestream does."); } - block.ZeroBitPlanes = band.ZeroBitPlaneTree!.Value(bx, by); + block.ZeroBitPlanes = part.ZeroBitPlaneTree!.Value(bx, by); } var newPasses = ReadPassCount(ref reader); @@ -160,7 +279,7 @@ private static int ReadPacket(Resolution resolution, ReadOnlySpan data, in // The bodies follow the header in the same order the header described // them, back to back with nothing between. - var bodyOffset = offset + reader.Position; + var bodyOffset = SkipEph(data, offset + reader.Position, eph); foreach (var (block, length) in pending) { if (length > data.Length - bodyOffset) @@ -182,6 +301,13 @@ private static int ReadPacket(Resolution resolution, ReadOnlySpan data, in /// private const int MaxZeroBitPlanes = 74; + /// + /// A.8.2: when COD allows EPH markers, FF92 may follow each packet header, before the bodies. + /// Skipped when present; a header without one is read as though it were not allowed. + /// + private static int SkipEph(ReadOnlySpan data, int offset, bool eph) => + eph && offset + 2 <= data.Length && data[offset] == 0xFF && data[offset + 1] == 0x92 ? offset + 2 : offset; + /// Lblock starts at 3 and only grows; 32 already makes the length field unreadable. private const int MaxLBlock = 32; diff --git a/tests/Sdcb.SimdCodecs.Tests/Issue1Jbig2CorpusTests.cs b/tests/Sdcb.SimdCodecs.Tests/Issue1Jbig2CorpusTests.cs new file mode 100644 index 0000000..bcf8ff5 --- /dev/null +++ b/tests/Sdcb.SimdCodecs.Tests/Issue1Jbig2CorpusTests.cs @@ -0,0 +1,148 @@ +using Sdcb.SimdCodecs.Jbig2; +using Shouldly; + +namespace Sdcb.SimdCodecs.Tests; + +/// +/// The JBIG2 files attached to issue #1 (https://github.com/sdcb/SimdCodecs/issues/1), +/// every page of every one checked against jbig2dec. +/// +/// The files are not in the repository and must not be. The reporter took them +/// from a test set whose source and licence they no longer know, and AGENTS.md admits +/// no third-party data of uncertain provenance, not even as a fetch-by-URL fixture. +/// So this test runs only when SIMDCODECS_ISSUE1_DIR names a directory holding +/// them, and skips otherwise (REQUIRE_ORACLES does not change that: this is an optional +/// corpus, not an oracle). +/// +/// +/// After changing the JBIG2 decoder, run it. Download +/// https://github.com/user-attachments/files/34160416/JPX_JBig2.zip (and, for the JPEG 2000 +/// side, https://github.com/user-attachments/files/34161061/JPXDecode.zip), extract them +/// somewhere outside the repository, set SIMDCODECS_ISSUE1_DIR to that directory +/// (it is searched recursively for *.jb2), and run +/// --filter "FullyQualifiedName~Issue1Jbig2Corpus" with jbig2dec available. +/// +/// +/// Expected: all 114 files decode to jbig2dec's pages, except two. 042_13.jb2 and +/// 042_14.jb2 are the same page as the other 042_* files, coded with a +/// refining symbol dictionary (arithmetic and Huffman respectively), and jbig2dec rejects +/// both at the first refined symbol of their second dictionary ("OOB in single +/// refinement/aggregate coded symbol data", "invalid number of symbols in aggregate +/// glyph"). This decoder, written independently from T.88, reads the same fields at the +/// same place and finds them malformed too: the first refinement of the arithmetic one +/// codes an out-of-band IARDX, which §6.5.8.2.2 does not allow. Two independent +/// readings agreeing that a stream is invalid is the best evidence available, so for +/// those two the expectation is a clean . If a later +/// change makes them decode, they must decode to the page the other 042_* files +/// give (jbig2dec's output for 042_1.jb2). +/// +/// +/// Three files (a.jb2, test.jb2, XeroxWorkCentre5230A_Huffman.jb2) +/// are PDF-embedded streams with no file header; they go through +/// +/// with the page size jbig2dec reports for them. +/// +/// +public sealed class Issue1Jbig2CorpusTests(ITestOutputHelper output) +{ + private static readonly string[] RejectedByBoth = ["042_13", "042_14"]; + + [Fact] + public void EveryFile_DecodesToJbig2decsPages() + { + var directory = Environment.GetEnvironmentVariable("SIMDCODECS_ISSUE1_DIR"); + if (string.IsNullOrEmpty(directory)) + Assert.Skip("SIMDCODECS_ISSUE1_DIR is not set; see Issue1Jbig2CorpusTests for where the issue #1 files come from."); + if (!Directory.Exists(directory)) + Assert.Fail($"SIMDCODECS_ISSUE1_DIR is '{directory}', which is not a directory."); + + Jbig2Oracle.RequireOrSkip(); + var ct = TestContext.Current.CancellationToken; + + var files = Directory.GetFiles(directory, "*.jb2", SearchOption.AllDirectories).Order().ToArray(); + files.ShouldNotBeEmpty($"no .jb2 files under {directory}"); + + var failures = new List(); + var byName = files.ToDictionary(f => Path.GetFileNameWithoutExtension(f), StringComparer.OrdinalIgnoreCase); + + foreach (var path in files) + { + ct.ThrowIfCancellationRequested(); + var name = Path.GetFileNameWithoutExtension(path); + var data = File.ReadAllBytes(path); + var isFile = data.AsSpan().StartsWith(Jbig2Decoder.FileSignature); + + try + { + if (RejectedByBoth.Contains(name)) + { + Jbig2Bitmap[]? ours = null; + try { ours = DecodeAll(data, ct); } + catch (InvalidDataException) { } + + if (ours is not null) + { + // Decodes now: then it has to be the page its siblings give. + var reference = Jbig2Oracle.DecodePages(File.ReadAllBytes(byName["042_1"])); + Compare(name, ours, reference, failures); + } + + output.WriteLine($"{name}: {(ours is null ? "rejected as invalid, as jbig2dec does" : "decoded")}"); + continue; + } + + var pages = Jbig2Oracle.DecodePages(data, embedded: !isFile); + var decoded = isFile + ? DecodeAll(data, ct) + : [ToBitmap(Jbig2Decoder.Decode(data, pages[0].Width, pages[0].Height, ct))]; + + Compare(name, decoded, pages, failures); + output.WriteLine($"{name}: {decoded.Length} page(s) match"); + } + catch (Exception e) when (e is InvalidDataException or NotSupportedException) + { + failures.Add($"{name}: {e.GetType().Name}: {e.Message}"); + } + } + + failures.ShouldBeEmpty(string.Join('\n', failures)); + } + + private static Jbig2Bitmap[] DecodeAll(byte[] data, CancellationToken ct) + { + Jbig2Decoder.TryReadFileInfo(data, out _, out _, out var pages).ShouldBeTrue(); + var result = new Jbig2Bitmap[Math.Max(1, pages)]; + for (var p = 0; p < result.Length; p++) result[p] = ToBitmap(Jbig2Decoder.DecodeFile(data, p + 1, ct)); + return result; + } + + private static Jbig2Bitmap ToBitmap(Jbig2Image image) + { + var bitmap = new Jbig2Bitmap(image.Width, image.Height); + image.Bits.CopyTo(bitmap.Data); + return bitmap; + } + + private static void Compare(string name, Jbig2Bitmap[] ours, IReadOnlyList reference, List failures) + { + if (ours.Length != reference.Count) + { + failures.Add($"{name}: {ours.Length} page(s), jbig2dec {reference.Count}"); + return; + } + + for (var p = 0; p < ours.Length; p++) + { + var (w, h, bits) = (reference[p].Width, reference[p].Height, reference[p].Bits); + if (ours[p].Width != w || ours[p].Height != h) + { + failures.Add($"{name} page {p + 1}: {ours[p].Width}x{ours[p].Height}, jbig2dec {w}x{h}"); + continue; + } + + var differing = 0; + for (var i = 0; i < bits.Length; i++) if (bits[i] != ours[p].Data[i]) differing++; + if (differing != 0) failures.Add($"{name} page {p + 1}: {differing} pixels differ from jbig2dec"); + } + } +} diff --git a/tests/Sdcb.SimdCodecs.Tests/Issue1Jpeg2000CorpusTests.cs b/tests/Sdcb.SimdCodecs.Tests/Issue1Jpeg2000CorpusTests.cs new file mode 100644 index 0000000..d36c229 --- /dev/null +++ b/tests/Sdcb.SimdCodecs.Tests/Issue1Jpeg2000CorpusTests.cs @@ -0,0 +1,98 @@ +using Sdcb.SimdCodecs.Jpeg2000; +using Shouldly; + +namespace Sdcb.SimdCodecs.Tests; + +/// +/// The JPEG 2000 files a user attached to issue #1 (https://github.com/sdcb/SimdCodecs/issues/1), +/// decoded and compared with opj_decompress: reversible images exactly, irreversible ones to +/// within one, as everywhere else. +/// +/// The files are not in the repository and must not be added. They came from a test set +/// whose source and licence nobody could name, so under the clean-room rules in AGENTS.md +/// they may be run locally but neither committed nor fetched by a test. This test therefore skips +/// unless SIMDCODECS_ISSUE1_DIR names a directory holding them, and that skip is not an +/// oracle skip: REQUIRE_ORACLES does not turn it into a failure. +/// +/// +/// After changing the JPEG 2000 decoder, run it. Download both attachments of issue #1, +/// https://github.com/user-attachments/files/34160416/JPX_JBig2.zip and +/// https://github.com/user-attachments/files/34161061/JPXDecode.zip, unzip them into one +/// directory (the .jb2 files beside them are the JBIG2 half of the same issue), set +/// SIMDCODECS_ISSUE1_DIR to it and run this class. Its sixteen files cover multiple tiles +/// and tile-parts, RLCP and RPCL progressions, precincts, SOP and EPH markers, segmentation symbols +/// and JP2 palettes, as real encoders write them; the committed tests that cover the same ground +/// are in . +/// +/// +public class Issue1Jpeg2000CorpusTests +{ + /// The environment variable naming the unzipped issue #1 attachments. + internal const string DirectoryVariable = "SIMDCODECS_ISSUE1_DIR"; + + [Fact] + public void EveryCorpusFile_DecodesAsOpenJpegDoes() + { + var directory = Environment.GetEnvironmentVariable(DirectoryVariable); + if (string.IsNullOrWhiteSpace(directory)) + Assert.Skip($"{DirectoryVariable} is not set; see this class's remarks for where issue #1's files are."); + + OpenJpegOracle.RequireOrSkip(); + + var files = Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories) + .Where(f => Path.GetExtension(f).ToLowerInvariant() is ".j2k" or ".j2c" or ".jp2" or ".jpx") + .Order(StringComparer.Ordinal) + .ToList(); + files.ShouldNotBeEmpty($"{DirectoryVariable} names {directory}, which holds no JPEG 2000 files."); + + var failures = new List(); + foreach (var path in files) + { + var name = Path.GetFileName(path); + try + { + var bytes = File.ReadAllBytes(path); + var decoded = Jpeg2000Decoder.Decode(bytes, TestContext.Current.CancellationToken); + var expected = OpenJpegOracle.DecodeFile( + bytes, Path.GetExtension(path), decoded.Components == 1 ? ".pgm" : ".ppm"); + + if (decoded.Width != expected.Width || decoded.Height != expected.Height || + decoded.Components != expected.Components) + { + failures.Add($"{name}: {decoded.Width}x{decoded.Height}x{decoded.Components}, OpenJPEG " + + $"{expected.Width}x{expected.Height}x{expected.Components}"); + continue; + } + + // A reversible codestream must match exactly; one that uses the 9/7 wavelet anywhere + // may differ by the rounding of its last step. + var tolerance = UsesIrreversibleWavelet(bytes) ? 1 : 0; + var worst = 0; + for (var i = 0; i < decoded.Samples.Length; i++) + worst = Math.Max(worst, Math.Abs(decoded.Samples[i] - expected.Samples[i])); + if (worst > tolerance) + failures.Add($"{name}: differs from OpenJPEG by up to {worst} (allowed {tolerance})"); + } + catch (Exception e) when (e is InvalidDataException or NotSupportedException) + { + failures.Add($"{name}: {e.GetType().Name}: {e.Message}"); + } + } + + failures.ShouldBeEmpty(); + } + + /// Whether any COD or COC in the main header picks the 9/7 wavelet. + private static bool UsesIrreversibleWavelet(byte[] bytes) + { + var data = bytes.AsSpan(); + if (Jpeg2000Decoder.IsJp2(data)) + { + var layout = Jp2Reader.Read(data); + data = data.Slice(layout.CodestreamStart, layout.CodestreamLength); + } + + var header = CodestreamReader.Read(data); + return header.ComponentCoding.Any(c => c.Transform == WaveletTransform.Irreversible97); + } +} diff --git a/tests/Sdcb.SimdCodecs.Tests/Jbig2DecoderTests.cs b/tests/Sdcb.SimdCodecs.Tests/Jbig2DecoderTests.cs index 526bb46..465e019 100644 --- a/tests/Sdcb.SimdCodecs.Tests/Jbig2DecoderTests.cs +++ b/tests/Sdcb.SimdCodecs.Tests/Jbig2DecoderTests.cs @@ -183,18 +183,15 @@ public void Decode_UnknownSegmentType_IsSkippedByItsDataLength() } /// - /// What is left unimplemented, and it is now one thing: custom Huffman table - /// segments (§7.4.13). Every region and dictionary type this list used to - /// name — symbol dictionary, text, refinement, pattern, halftone — decodes, - /// so a malformed one of those fails as corrupt data instead, which their own - /// test files cover. + /// Custom Huffman table segments (§7.4.13) are decoded now; one too short to hold + /// its fixed fields is corrupt data. Jbig2HuffmanTests covers well-formed ones. /// [Fact] - public void Decode_CustomHuffmanTableSegment_SaysWhatIsMissing() + public void Decode_TruncatedHuffmanTableSegment_IsInvalidData() { var stream = Jbig2StreamBuilder.Segment(1, SegmentType.Tables, 1, [0x00, 0x00, 0x00, 0x00]); - Should.Throw(() => Jbig2Decoder.Decode(stream, 8, 8)) + Should.Throw(() => Jbig2Decoder.Decode(stream, 8, 8, TestContext.Current.CancellationToken)) .Message.ShouldContain("Huffman table"); } diff --git a/tests/Sdcb.SimdCodecs.Tests/Jbig2ExtendedOracleTests.cs b/tests/Sdcb.SimdCodecs.Tests/Jbig2ExtendedOracleTests.cs new file mode 100644 index 0000000..b9a3f8c --- /dev/null +++ b/tests/Sdcb.SimdCodecs.Tests/Jbig2ExtendedOracleTests.cs @@ -0,0 +1,579 @@ +using System.Buffers.Binary; +using Sdcb.SimdCodecs.Jbig2; +using Shouldly; + +namespace Sdcb.SimdCodecs.Tests; + +/// +/// The JBIG2 features added for issue #1, each in a hand-assembled stream checked +/// against jbig2dec: HENABLESKIP, GRAYMAX above 255, an MMR pattern dictionary, +/// generic regions of unknown data length (§7.2.7), Huffman-coded symbol +/// dictionaries and text regions with standard and custom tables, and multi-page +/// files. The issue's own files are not in the repository (their licence is +/// unknown); Issue1Jbig2CorpusTests runs them when they are on disk. +/// +/// The streams' coded bytes come from encoders written here, so a round trip +/// alone would only prove that encoder and decoder share a reading. jbig2dec is +/// what makes them checks: every test decodes the same bytes with it too. +/// +/// +public sealed class Jbig2ExtendedOracleTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // ---- halftone ------------------------------------------------------------------ + + /// + /// HENABLESKIP with a grid that hangs off the region on each side, so cells are + /// skipped on the left, top, right and bottom, plus a sheared lattice. + /// + [Theory] + [InlineData(-6 * 256, -5 * 256, 1024, 0)] + [InlineData(-2 * 256, 0, 1024, 0)] + [InlineData(0, -9 * 256, 1024, 0)] + [InlineData(-3 * 256, -3 * 256, 1024, 256)] + [InlineData(4 * 256, 2 * 256, 1280, 512)] + public void HalftoneWithSkip_AgreesWithJbig2dec(int gridX, int gridY, int vectorX, int vectorY) + { + Jbig2Oracle.RequireOrSkip(); + + const int levels = 4, gridWidth = 9, gridHeight = 7, width = 22, height = 15; + var patterns = Jbig2HalftoneTests.Patterns(levels); + var grey = Ramp(gridWidth * gridHeight, levels); + + var file = HalftoneFile(width, height, patterns, Jbig2HalftoneTests.PatternDictionarySegment(patterns), + Jbig2HalftoneTests.HalftoneRegionSegment(width, height, grey, gridWidth, gridHeight, levels, patterns, + gridX, gridY, vectorX, vectorY, enableSkip: true)); + + var expected = Stamp(width, height, patterns, grey, gridWidth, gridHeight, gridX, gridY, vectorX, vectorY); + ShouldAgree(file, expected); + } + + /// GRAYMAX 299: more patterns than a byte can count, nine bitplanes. + [Fact] + public void GrayMaxAbove255_AgreesWithJbig2dec() + { + Jbig2Oracle.RequireOrSkip(); + + const int levels = 300, gridWidth = 6, gridHeight = 5; + var patterns = Jbig2HalftoneTests.Patterns(levels, 18, 17); + var grey = new int[gridWidth * gridHeight]; + for (var i = 0; i < grey.Length; i++) grey[i] = i * 37 % levels; + + var width = gridWidth * 18; + var height = gridHeight * 17; + var file = HalftoneFile(width, height, patterns, Jbig2HalftoneTests.PatternDictionarySegment(patterns), + Jbig2HalftoneTests.HalftoneRegionSegment(width, height, grey, gridWidth, gridHeight, levels, patterns)); + + ShouldAgree(file, Stamp(width, height, patterns, grey, gridWidth, gridHeight, 0, 0, 18 * 256, 0)); + } + + /// + /// HDMMR = 1: the collective bitmap is Group 4, and the bytes come from libtiff, + /// not from anything in this repository. + /// + [Fact] + public void MmrPatternDictionary_AgreesWithJbig2dec() + { + Jbig2Oracle.RequireOrSkip(); + + const int levels = 8, gridWidth = 6, gridHeight = 5; + var patterns = Jbig2HalftoneTests.Patterns(levels); + var collective = new Jbig2Bitmap(levels * 4, 4); + for (var i = 0; i < levels; i++) collective.Combine(patterns[i], i * 4, 0, CombinationOperator.Replace); + + var dictionary = new List { 0x01, 4, 4 }; // HDMMR = 1, HDPW, HDPH + WriteUInt32(dictionary, levels - 1); + dictionary.AddRange(Group4Tiff.Encode(collective.Data, collective.Width, collective.Height).Coded); + + var grey = Ramp(gridWidth * gridHeight, levels); + var file = HalftoneFile(gridWidth * 4, gridHeight * 4, patterns, [.. dictionary], + Jbig2HalftoneTests.HalftoneRegionSegment(gridWidth * 4, gridHeight * 4, grey, gridWidth, gridHeight, levels, patterns)); + + ShouldAgree(file, Stamp(gridWidth * 4, gridHeight * 4, patterns, grey, gridWidth, gridHeight, 0, 0, 1024, 0)); + } + + // ---- unknown data length ------------------------------------------------------- + + /// + /// An arithmetic generic region with data length 0xFFFFFFFF, ended by FF AC and a + /// row count smaller than the declared height (§7.4.6.4), in an embedded stream + /// as PDF carries them. + /// + [Fact] + public void UnknownLengthArithmeticRegion_AgreesWithJbig2dec() + { + Jbig2Oracle.RequireOrSkip(); + + const int width = 37, height = 29, rows = 21; + var page = Pattern(width, rows); + var at = GenericRegionDecoder.NominalAt(0).ToArray(); + var data = new List(RegionInfo(width, height)); + data.Add(0x00); // MMR = 0, GBTEMPLATE 0 + data.AddRange(at.Select(a => (byte)a)); + // The MQ flush (T.88 E.3.2) already ends on the FF AC marker, which is + // exactly the end sequence §7.2.7 asks for; the row count follows it. + var coded = Jbig2StreamBuilder.EncodeGenericRegion(page, 0, false, at); + coded[^2..].ShouldBe(new byte[] { 0xFF, 0xAC }); + data.AddRange(coded); + WriteUInt32(data, rows); + + var stream = Jbig2StreamBuilder.Stream( + Jbig2StreamBuilder.Segment(0, SegmentType.PageInformation, 1, Jbig2StreamBuilder.PageInformation(width, height)), + UnknownLength(Jbig2StreamBuilder.Segment(1, SegmentType.ImmediateGenericRegion, 1, [.. data]))); + + var expected = new Jbig2Bitmap(width, height); + expected.Combine(page, 0, 0, CombinationOperator.Or); + + Jbig2Oracle.Decode(stream, embedded: true).Bits.ShouldBe(expected.Data); + Jbig2Decoder.Decode(stream, width, height, Ct).Bits.ToArray().ShouldBe(expected.Data); + } + + /// The same with MMR (libtiff's bytes) and the 00 00 end sequence. + [Fact] + public void UnknownLengthMmrRegion_AgreesWithJbig2dec() + { + Jbig2Oracle.RequireOrSkip(); + + const int width = 64, height = 40; + var page = Pattern(width, height); + var coded = Group4Tiff.Encode(page.Data, width, height).Coded; + + // 00 00 cannot occur inside T.6 codes, but a trailing zero pad byte would run + // into the end sequence and make it ambiguous; libtiff ends on EOFB, so check. + coded[^1].ShouldNotBe((byte)0); + + var data = new List(RegionInfo(width, height)); + data.Add(0x01); // MMR = 1 + data.AddRange(coded); + data.AddRange([0x00, 0x00]); + WriteUInt32(data, height); + + var stream = Jbig2StreamBuilder.Stream( + Jbig2StreamBuilder.Segment(0, SegmentType.PageInformation, 1, Jbig2StreamBuilder.PageInformation(width, height)), + UnknownLength(Jbig2StreamBuilder.Segment(1, SegmentType.ImmediateGenericRegion, 1, [.. data]))); + + Jbig2Oracle.Decode(stream, embedded: true).Bits.ShouldBe(page.Data); + Jbig2Decoder.Decode(stream, width, height, Ct).Bits.ToArray().ShouldBe(page.Data); + } + + [Fact] + public void UnknownLength_WithoutEndSequence_IsInvalidData() + { + var data = new List(RegionInfo(16, 16)) { 0x00, 3, 0xFF, 0xFD, 0xFF, 2, 0xFE, 0xFE, 0xFE, 0x12, 0x34 }; + var stream = Jbig2StreamBuilder.Stream( + Jbig2StreamBuilder.Segment(0, SegmentType.PageInformation, 1, Jbig2StreamBuilder.PageInformation(16, 16)), + UnknownLength(Jbig2StreamBuilder.Segment(1, SegmentType.ImmediateGenericRegion, 1, [.. data]))); + + Should.Throw(() => Jbig2Decoder.Decode(stream, 16, 16, Ct)) + .Message.ShouldContain("end sequence"); + } + + [Fact] + public void UnknownLength_InARandomAccessFile_IsInvalidData() + { + var region = Jbig2StreamBuilder.Split(1, SegmentType.ImmediateGenericRegion, 1, [.. RegionInfo(8, 8), 0x01, 0, 0, 0, 0, 0, 8]); + var header = region.Header.ToArray(); + header.AsSpan(header.Length - 4).Fill(0xFF); + var file = Jbig2StreamBuilder.RandomAccessFile( + Jbig2StreamBuilder.Split(0, SegmentType.PageInformation, 1, Jbig2StreamBuilder.PageInformation(8, 8)), + (header, region.Data)); + + Should.Throw(() => Jbig2Decoder.DecodeFile(file, Ct)); + } + + // ---- Huffman ------------------------------------------------------------------- + + public static TheoryData HuffmanVariants => new() { "standard", "custom-dh", "mmr-collective" }; + + /// + /// A Huffman-coded symbol dictionary (SDHUFF = 1) and a Huffman-coded text region + /// (SBHUFF = 1) that stamps its symbols, against jbig2dec: standard tables + /// throughout, a custom table segment for SDHUFFDH, and MMR collective bitmaps + /// (libtiff's bytes) in place of uncompressed ones. + /// + [Theory] + [MemberData(nameof(HuffmanVariants))] + public void HuffmanDictionaryAndTextRegion_AgreeWithJbig2dec(string variant) + { + Jbig2Oracle.RequireOrSkip(); + + var (file, expected) = HuffmanFile(variant); + ShouldAgree(file, expected); + } + + // ---- pages --------------------------------------------------------------------- + + /// + /// A two-page file whose pages differ in size, with a page-0 dictionary-free + /// generic region on each: every page decodes to its own raster, and page 1 is + /// still what jbig2dec writes first. + /// + [Fact] + public void MultiPageFile_DecodesEveryPage() + { + var first = Pattern(30, 20); + var second = Pattern(41, 13, seed: 5); + + var file = MultiPageFile(first, second); + + Jbig2Decoder.TryReadFileInfo(file, out var w, out var h, out var pages).ShouldBeTrue(); + (w, h, pages).ShouldBe((30, 20, 2)); + + Jbig2Decoder.DecodeFile(file, 1, Ct).Bits.ToArray().ShouldBe(first.Data); + var page2 = Jbig2Decoder.DecodeFile(file, 2, Ct); + (page2.Width, page2.Height).ShouldBe((41, 13)); + page2.Bits.ToArray().ShouldBe(second.Data); + + Should.Throw(() => Jbig2Decoder.DecodeFile(file, 3, Ct)); + Should.Throw(() => Jbig2Decoder.DecodeFile(file, 0, Ct)); + + if (Jbig2Oracle.IsAvailable) Jbig2Oracle.Decode(file).Bits.ShouldBe(first.Data); + } + + // ---- builders ------------------------------------------------------------------ + + private static void ShouldAgree(byte[] file, Jbig2Bitmap expected) + { + var oracle = Jbig2Oracle.Decode(file); + (oracle.Width, oracle.Height).ShouldBe((expected.Width, expected.Height)); + oracle.Bits.ShouldBe(expected.Data, "jbig2dec"); + Jbig2Decoder.DecodeFile(file, Ct).Bits.ToArray().ShouldBe(expected.Data, "Sdcb.SimdCodecs.Jbig2"); + } + + private static int[] Ramp(int count, int levels) + { + var grey = new int[count]; + for (var i = 0; i < count; i++) grey[i] = (i * 7 + i / 5) % levels; + return grey; + } + + private static Jbig2Bitmap Stamp( + int width, int height, Jbig2Bitmap[] patterns, int[] grey, int gridWidth, int gridHeight, + int gridX, int gridY, int vectorX, int vectorY) + { + var expected = new Jbig2Bitmap(width, height); + for (var m = 0; m < gridHeight; m++) + for (var n = 0; n < gridWidth; n++) + expected.Combine(patterns[grey[m * gridWidth + n]], + (gridX + m * vectorY + n * vectorX) >> 8, (gridY + m * vectorX - n * vectorY) >> 8, + CombinationOperator.Or); + return expected; + } + + private static byte[] HalftoneFile(int width, int height, Jbig2Bitmap[] patterns, byte[] dictionary, byte[] region) => + Jbig2StreamBuilder.SequentialFile( + Jbig2StreamBuilder.Segment(0, SegmentType.PageInformation, 1, Jbig2StreamBuilder.PageInformation(width, height)), + Jbig2StreamBuilder.Segment(1, SegmentType.PatternDictionary, 1, dictionary), + Jbig2StreamBuilder.Segment(2, SegmentType.ImmediateHalftoneRegion, 1, region, referredTo: [1]), + Jbig2StreamBuilder.Segment(3, SegmentType.EndOfPage, 1, [])); + + /// A deterministic bitmap with runs, edges and isolated pixels. + private static Jbig2Bitmap Pattern(int width, int height, int seed = 1) + { + var bitmap = new Jbig2Bitmap(width, height); + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + bitmap.Data[y * width + x] = (byte)((((x + seed) / 3 + y / 2) % 4 == 0 || (x * 7 + y * 13 + seed) % 23 == 0) ? 1 : 0); + return bitmap; + } + + private static List RegionInfo(int width, int height) + { + var body = new List(); + WriteUInt32(body, (uint)width); + WriteUInt32(body, (uint)height); + WriteUInt32(body, 0); + WriteUInt32(body, 0); + body.Add(0); // OR + return body; + } + + /// Rewrites a built segment's data length field to 0xFFFFFFFF. + private static byte[] UnknownLength(byte[] segment) + { + // Short-form header with no referred-to segments and a one-byte page: + // number(4) flags(1) count(1) page(1) length(4). + segment.AsSpan(7, 4).Fill(0xFF); + return segment; + } + + private static byte[] MultiPageFile(Jbig2Bitmap first, Jbig2Bitmap second) + { + var at = GenericRegionDecoder.NominalAt(0).ToArray(); + var file = new List(Jbig2Decoder.FileSignature.ToArray()) { 0x01 }; + WriteUInt32(file, 2); + file.AddRange(Jbig2StreamBuilder.Segment(0, SegmentType.PageInformation, 1, + Jbig2StreamBuilder.PageInformation(first.Width, first.Height))); + file.AddRange(Jbig2StreamBuilder.Segment(1, SegmentType.ImmediateGenericRegion, 1, + Jbig2StreamBuilder.GenericRegionSegment(first))); + file.AddRange(Jbig2StreamBuilder.Segment(2, SegmentType.EndOfPage, 1, [])); + file.AddRange(Jbig2StreamBuilder.Segment(3, SegmentType.PageInformation, 2, + Jbig2StreamBuilder.PageInformation(second.Width, second.Height))); + file.AddRange(Jbig2StreamBuilder.Segment(4, SegmentType.ImmediateGenericRegion, 2, + Jbig2StreamBuilder.GenericRegionSegment(second))); + file.AddRange(Jbig2StreamBuilder.Segment(5, SegmentType.EndOfPage, 2, [])); + file.AddRange(Jbig2StreamBuilder.Segment(6, SegmentType.EndOfFile, 0, [])); + return [.. file]; + } + + /// + /// Builds the Huffman test file: a page, optionally a custom table segment, a + /// Huffman symbol dictionary of glyphs in three height classes, and a Huffman text + /// region placing them on two strips. + /// + private static (byte[] File, Jbig2Bitmap Expected) HuffmanFile(string variant) + { + // Glyphs: heights 5, 7, 7, 9, 9, 9 with varied widths, each a distinct shape. + (int W, int H)[] sizes = [(4, 5), (6, 5), (3, 7), (8, 7), (5, 9), (7, 9), (11, 9)]; + var glyphs = new Jbig2Bitmap[sizes.Length]; + for (var i = 0; i < sizes.Length; i++) + { + var (w, h) = sizes[i]; + glyphs[i] = new Jbig2Bitmap(w, h); + for (var y = 0; y < h; y++) + for (var x = 0; x < w; x++) + glyphs[i].Data[y * w + x] = (byte)((x == 0 || y == h - 1 || (x + y + i) % 3 == 0) ? 1 : 0); + } + + var customDh = variant == "custom-dh"; + var mmr = variant == "mmr-collective"; + + // Custom table for SDHUFFDH: HTPS = 2, HTRS = 3, HTLOW = 1, HTHIGH = 17. Lines: + // 1..8 (PREFLEN 1, RANGELEN 3), 9..16 (2, 3), lower (3), upper (3). + HuffmanTable.Line[] customLines = + [ + new(1, 3, 1), new(2, 3, 9), + new(3, 32, 0, HuffmanTable.LineKind.Lower), new(3, 32, 17, HuffmanTable.LineKind.Upper), + ]; + var table = new BitWriter(); + table.Write(1, 2); table.Write(3, 3); + table.Write(2, 2); table.Write(3, 3); + table.Write(3, 2); table.Write(3, 2); + var tableSegment = new List { (byte)((1 << 1) | (2 << 4)) }; // HTOOB = 0, HTPS - 1 = 1, HTRS - 1 = 2 + WriteUInt32(tableSegment, 1); + WriteUInt32(tableSegment, 17); + tableSegment.AddRange(table.ToArray()); + + // ---- symbol dictionary (§7.4.2), SDHUFF = 1, SDREFAGG = 0. + var dh = customDh ? customLines : HuffmanTable.StandardLines(4); + var dw = HuffmanTable.StandardLines(2); + var b1 = HuffmanTable.StandardLines(1); + + var sd = new BitWriter(); + var height = 0; + foreach (var group in glyphs.Select((g, i) => (g, i)).GroupBy(t => t.g.Height)) + { + HuffmanEncode(sd, dh, group.Key - height); + height = group.Key; + + var width = 0; + var members = group.Select(t => t.g).ToArray(); + foreach (var glyph in members) + { + HuffmanEncode(sd, dw, glyph.Width - width); + width = glyph.Width; + } + HuffmanEncodeOob(sd, dw); + + var total = members.Sum(g => g.Width); + var collective = new Jbig2Bitmap(total, height); + var x0 = 0; + foreach (var glyph in members) { collective.Combine(glyph, x0, 0, CombinationOperator.Replace); x0 += glyph.Width; } + + if (mmr) + { + var coded = Group4Tiff.Encode(collective.Data, total, height).Coded; + HuffmanEncode(sd, b1, coded.Length); + sd.Align(); + sd.WriteBytes(coded); + } + else + { + HuffmanEncode(sd, b1, 0); + sd.Align(); + for (var y = 0; y < height; y++) + { + for (var x = 0; x < total; x++) sd.Write(collective.Data[y * total + x], 1); + sd.Align(); + } + } + } + + // Export everything: a zero-length skip run, then all of them. + HuffmanEncode(sd, b1, 0); + HuffmanEncode(sd, b1, glyphs.Length); + + var dictionary = new List(); + WriteUInt16(dictionary, (ushort)(0x0001 | (customDh ? 0x000C : 0))); // SDHUFF, SDHUFFDH + WriteUInt32(dictionary, (uint)glyphs.Length); + WriteUInt32(dictionary, (uint)glyphs.Length); + dictionary.AddRange(sd.ToArray()); + + // ---- text region (§7.4.3), SBHUFF = 1, REFCORNER = TOPLEFT, one strip line each. + const int pageWidth = 70, pageHeight = 40; + (int Id, int S, int T)[][] strips = + [ + [(0, 2, 3), (3, 8, 3), (1, 17, 3), (6, 24, 3), (2, 36, 3)], + [(5, 5, 18), (4, 13, 18), (0, 20, 18), (6, 26, 18), (3, 40, 18), (1, 49, 18)], + ]; + var instances = strips.Sum(s => s.Length); + + var codeLength = 3; // seven symbols: every code length 3, so symbol i is code i + var tr = new BitWriter(); + for (var r = 0; r < 35; r++) tr.Write(r == codeLength ? 1u : 0u, 4); // run code lengths + for (var i = 0; i < glyphs.Length; i++) tr.Write(0, 1); // RUNCODE3 = "0", once per symbol + tr.Align(); + + var fs = HuffmanTable.StandardLines(6); + var ds = HuffmanTable.StandardLines(8); + var dt = HuffmanTable.StandardLines(11); + + HuffmanEncode(tr, dt, 1); // initial STRIPT = -1 + var stript = -1; + var firsts = 0; + foreach (var strip in strips) + { + HuffmanEncode(tr, dt, strip[0].T - stript); + stript = strip[0].T; + HuffmanEncode(tr, fs, strip[0].S - firsts); + firsts = strip[0].S; + + var curs = strip[0].S; + for (var k = 0; k < strip.Length; k++) + { + tr.Write((uint)strip[k].Id, codeLength); + curs += glyphs[strip[k].Id].Width - 1; + if (k + 1 < strip.Length) + { + HuffmanEncode(tr, ds, strip[k + 1].S - curs); + curs = strip[k + 1].S; + } + else + { + HuffmanEncodeOob(tr, ds); + } + } + } + + var text = new List(RegionInfo(pageWidth, pageHeight)); + WriteUInt16(text, 0x0001 | (1 << 4)); // SBHUFF, REFCORNER = TOPLEFT + WriteUInt16(text, 0x0000); // FS B.6, DS B.8, DT B.11 + WriteUInt32(text, (uint)instances); + text.AddRange(tr.ToArray()); + + var segments = new List + { + Jbig2StreamBuilder.Segment(0, SegmentType.PageInformation, 1, + Jbig2StreamBuilder.PageInformation(pageWidth, pageHeight)), + }; + if (customDh) segments.Add(Jbig2StreamBuilder.Segment(1, SegmentType.Tables, 1, [.. tableSegment])); + segments.Add(Jbig2StreamBuilder.Segment(2, SegmentType.SymbolDictionary, 1, [.. dictionary], + referredTo: customDh ? [1] : null)); + segments.Add(Jbig2StreamBuilder.Segment(3, SegmentType.ImmediateLosslessTextRegion, 1, [.. text], referredTo: [2])); + segments.Add(Jbig2StreamBuilder.Segment(4, SegmentType.EndOfPage, 1, [])); + + var expected = new Jbig2Bitmap(pageWidth, pageHeight); + foreach (var strip in strips) + foreach (var (id, s, t) in strip) + expected.Combine(glyphs[id], s, t, CombinationOperator.Or); + + return (Jbig2StreamBuilder.SequentialFile([.. segments]), expected); + } + + /// + /// Writes with a table given as lines, assigning the + /// prefix codes by T.88 B.3 here rather than through the decoder's table. + /// + private static void HuffmanEncode(BitWriter writer, HuffmanTable.Line[] lines, int value) + { + var codes = Codes(lines); + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + if (line.PrefixLength == 0) continue; + switch (line.Kind) + { + case HuffmanTable.LineKind.Normal when value >= line.RangeLow && value < line.RangeLow + (1L << line.RangeLength): + writer.Write(codes[i], line.PrefixLength); + writer.Write((uint)(value - line.RangeLow), line.RangeLength); + return; + case HuffmanTable.LineKind.Lower when value <= line.RangeLow: + writer.Write(codes[i], line.PrefixLength); + writer.Write((uint)(line.RangeLow - value), 32); + return; + case HuffmanTable.LineKind.Upper when value >= line.RangeLow: + writer.Write(codes[i], line.PrefixLength); + writer.Write((uint)(value - line.RangeLow), 32); + return; + } + } + + throw new ArgumentOutOfRangeException(nameof(value), value, "Not representable in this table."); + } + + private static void HuffmanEncodeOob(BitWriter writer, HuffmanTable.Line[] lines) + { + var codes = Codes(lines); + var i = Array.FindIndex(lines, l => l.Kind == HuffmanTable.LineKind.OutOfBand); + writer.Write(codes[i], lines[i].PrefixLength); + } + + private static uint[] Codes(HuffmanTable.Line[] lines) + { + var max = lines.Max(l => l.PrefixLength); + var codes = new uint[lines.Length]; + uint first = 0; + var previous = 0; + for (var length = 1; length <= max; length++) + { + first = (uint)((first + previous) << 1); + var current = first; + previous = 0; + for (var i = 0; i < lines.Length; i++) + if (lines[i].PrefixLength == length) { codes[i] = current++; previous++; } + } + return codes; + } + + private sealed class BitWriter + { + private readonly List _bytes = []; + private int _bits; + + public void Write(uint value, int count) + { + for (var i = count - 1; i >= 0; i--) + { + if (_bits % 8 == 0) _bytes.Add(0); + if (((value >> i) & 1) != 0) _bytes[^1] |= (byte)(0x80 >> (_bits % 8)); + _bits++; + } + } + + public void Align() => _bits = (_bits + 7) & ~7; + + public void WriteBytes(byte[] data) + { + Align(); + _bytes.AddRange(data); + _bits += data.Length * 8; + } + + public byte[] ToArray() => [.. _bytes]; + } + + private static void WriteUInt16(List target, ushort value) + { + Span buffer = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(buffer, value); + target.AddRange(buffer); + } + + private static void WriteUInt32(List target, uint value) + { + Span buffer = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(buffer, value); + target.AddRange(buffer); + } +} diff --git a/tests/Sdcb.SimdCodecs.Tests/Jbig2FacadeTests.cs b/tests/Sdcb.SimdCodecs.Tests/Jbig2FacadeTests.cs index 39d22ae..28219af 100644 --- a/tests/Sdcb.SimdCodecs.Tests/Jbig2FacadeTests.cs +++ b/tests/Sdcb.SimdCodecs.Tests/Jbig2FacadeTests.cs @@ -144,9 +144,9 @@ public void TryDecode_OnAFileNeedingAnUnimplementedFeature_ReturnsFalse() { // The facade contract is "false for undecodable", not "throws". Every // region type decodes now, so the case has to be a genuinely refused - // feature: a Huffman-coded symbol dictionary. + // feature: a symbol dictionary that imports arithmetic contexts. var dictionary = Jbig2SymbolBuilder.SymbolDictionarySegment(Jbig2TextRegionTests.Alphabet()); - dictionary[1] |= 0x01; // SDHUFF + dictionary[0] |= 0x01; // bitmap coding context used (bit 8 of the flags) var file = Jbig2StreamBuilder.SequentialFile( Jbig2StreamBuilder.Segment(0, SegmentType.PageInformation, 1, diff --git a/tests/Sdcb.SimdCodecs.Tests/Jbig2HalftoneTests.cs b/tests/Sdcb.SimdCodecs.Tests/Jbig2HalftoneTests.cs index 1e77d89..a9233cd 100644 --- a/tests/Sdcb.SimdCodecs.Tests/Jbig2HalftoneTests.cs +++ b/tests/Sdcb.SimdCodecs.Tests/Jbig2HalftoneTests.cs @@ -62,36 +62,24 @@ public void HalftoneRegion_WithNoPatternDictionary_Fails() .Message.ShouldContain("pattern dictionary"); } + /// + /// GRAYMAX is a four-byte field; one that asks for a collective bitmap past the + /// per-bitmap ceiling is refused before anything is allocated. + /// [Fact] - public void HalftoneRegion_WithSkipEnabled_IsRefusedByName() - { - var patterns = Patterns(4); - var segment = HalftoneRegionSegment(32, 32, new int[4], 2, 2, 4, patterns); - segment[17] |= 0x08; // HENABLESKIP, in the flags byte after the region info - - var stream = Jbig2StreamBuilder.Stream( - Jbig2StreamBuilder.Segment(0, SegmentType.PageInformation, 1, - Jbig2StreamBuilder.PageInformation(32, 32)), - Jbig2StreamBuilder.Segment(1, SegmentType.PatternDictionary, 1, PatternDictionarySegment(patterns)), - Jbig2StreamBuilder.Segment(2, SegmentType.ImmediateHalftoneRegion, 1, segment, referredTo: [1])); - - Should.Throw(() => Jbig2Decoder.Decode(stream, 32, 32)) - .Message.ShouldContain("HENABLESKIP"); - } - - [Fact] - public void MmrCodedPatternDictionary_IsRefusedByName() + public void PatternDictionary_ImplausibleGrayMax_IsRefusedBeforeAllocating() { var segment = PatternDictionarySegment(Patterns(4)); - segment[0] |= 0x01; // HDMMR + segment[3] = segment[4] = segment[5] = segment[6] = 0xFF; // GRAYMAX = 0xFFFFFFFF var stream = Jbig2StreamBuilder.Stream( Jbig2StreamBuilder.Segment(0, SegmentType.PageInformation, 1, Jbig2StreamBuilder.PageInformation(32, 32)), Jbig2StreamBuilder.Segment(1, SegmentType.PatternDictionary, 1, segment)); - Should.Throw(() => Jbig2Decoder.Decode(stream, 32, 32)) - .Message.ShouldContain("MMR"); + Should.Throw(() => Jbig2Decoder.Decode(stream, 32, 32, + TestContext.Current.CancellationToken)) + .Message.ShouldContain("GRAYMAX"); } // ---- stream construction ----------------------------------------------------- @@ -199,27 +187,46 @@ internal static byte[] PatternDictionarySegment(Jbig2Bitmap[] patterns, int temp } /// A halftone region segment (T.88 §7.4.5) carrying the grey grid as Gray-coded bitplanes. + /// + /// HENABLESKIP: cells whose pattern lies wholly outside the region are left out of + /// every bitplane (T.88 §6.6.5.1). The skip test is written out here a second time, + /// independently of the decoder's, so the two only agree with jbig2dec if both are right. + /// internal static byte[] HalftoneRegionSegment( int width, int height, int[] grey, int gridWidth, int gridHeight, int levels, Jbig2Bitmap[] patterns, int gridX = 0, int gridY = 0, int vectorX = 0, int vectorY = 0, - int template = 0) + int template = 0, bool enableSkip = false) { if (vectorX == 0) vectorX = patterns[0].Width * 256; + bool[]? skip = null; + if (enableSkip) + { + skip = new bool[gridWidth * gridHeight]; + for (var m = 0; m < gridHeight; m++) + for (var n = 0; n < gridWidth; n++) + { + var x = (gridX + m * vectorY + n * vectorX) >> 8; + var y = (gridY + m * vectorX - n * vectorY) >> 8; + skip[m * gridWidth + n] = x + patterns[0].Width <= 0 || x >= width + || y + patterns[0].Height <= 0 || y >= height; + } + } + var body = new List(); WriteUInt32(body, (uint)width); WriteUInt32(body, (uint)height); WriteUInt32(body, 0); WriteUInt32(body, 0); body.Add((byte)CombinationOperator.Or); // external combination - body.Add((byte)(template << 1)); // HMMR=0, HENABLESKIP=0, HCOMBOP=OR, HDEFPIXEL=0 + body.Add((byte)((template << 1) | (enableSkip ? 0x08 : 0))); // HMMR=0, HCOMBOP=OR, HDEFPIXEL=0 WriteUInt32(body, (uint)gridWidth); WriteUInt32(body, (uint)gridHeight); WriteUInt32(body, (uint)gridX); WriteUInt32(body, (uint)gridY); WriteUInt16(body, (ushort)vectorX); WriteUInt16(body, (ushort)vectorY); - body.AddRange(EncodeGrayscale(grey, gridWidth, gridHeight, levels, template)); + body.AddRange(EncodeGrayscale(grey, gridWidth, gridHeight, levels, template, skip)); return [.. body]; } @@ -229,7 +236,7 @@ internal static byte[] HalftoneRegionSegment( /// one arithmetic stream with one shared context array, exactly as the decoder /// reads them back. /// - private static byte[] EncodeGrayscale(int[] values, int width, int height, int levels, int template) + private static byte[] EncodeGrayscale(int[] values, int width, int height, int levels, int template, bool[]? skip = null) { var planes = 0; while (1 << planes < levels) planes++; @@ -249,11 +256,21 @@ private static byte[] EncodeGrayscale(int[] values, int width, int height, int l plane.Data[i] = previous is null ? raw : (byte)(raw ^ previous[i]); } + // A skipped cell is coded nowhere and decodes as 0 in every plane, so + // its plane bit has to be 0 here too for the contexts of its + // neighbours to match the decoder's. + if (skip is not null) + for (var i = 0; i < values.Length; i++) + if (skip[i]) plane.Data[i] = 0; + for (var y = 0; y < height; y++) for (var x = 0; x < width; x++) + { + if (skip is not null && skip[y * width + x]) continue; encoder.Encode(contexts, GenericRegionDecoder.Context(plane, x, y, template, at), plane.Data[y * width + x]); + } // The next plane down is coded against this one *after* decoding, so // the running reference is the reconstructed bit, not the coded one. diff --git a/tests/Sdcb.SimdCodecs.Tests/Jbig2HuffmanTests.cs b/tests/Sdcb.SimdCodecs.Tests/Jbig2HuffmanTests.cs new file mode 100644 index 0000000..e3d31c7 --- /dev/null +++ b/tests/Sdcb.SimdCodecs.Tests/Jbig2HuffmanTests.cs @@ -0,0 +1,118 @@ +using Sdcb.SimdCodecs.Jbig2; +using Shouldly; + +namespace Sdcb.SimdCodecs.Tests; + +/// +/// The Huffman layer of T.88 Annex B: the standard tables, canonical code +/// assignment, custom table segments, and their resource bounds. Whether the +/// tables are the spec's is settled against jbig2dec in +/// Jbig2ExtendedOracleTests and on the issue #1 corpus. +/// +public sealed class Jbig2HuffmanTests +{ + /// + /// Every standard table is a prefix code whose Kraft sum is exactly 1, except B.7, + /// whose lines leave 1/16 of the code space unused. A misremembered line length + /// would almost always break this. + /// + [Theory] + [InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)] [InlineData(5)] + [InlineData(6)] [InlineData(7)] [InlineData(8)] [InlineData(9)] [InlineData(10)] + [InlineData(11)] [InlineData(12)] [InlineData(13)] [InlineData(14)] [InlineData(15)] + public void StandardTable_IsAPrefixCode(int number) + { + var sum = HuffmanTable.StandardLines(number) + .Where(l => l.PrefixLength > 0) + .Sum(l => Math.ScaleB(1.0, -l.PrefixLength)); + + sum.ShouldBe(number == 7 ? 15.0 / 16 : 1.0); + _ = HuffmanTable.Get(number); + } + + /// B.3's codes as the table prints them, decoded bit by bit. + [Theory] + [InlineData("0", 0)] + [InlineData("10", 1)] + [InlineData("110", 2)] + [InlineData("1110101", 8)] // 1110 + 3 bits: 3 + 5 + [InlineData("11110000011", 14)] // 11110 + 6 bits: 11 + 3 + [InlineData("111110", HuffmanTable.OutOfBand)] + [InlineData("1111110" + "00000000000000000000000000000101", 80)] // upper: 75 + 5 + [InlineData("11111110" + "11111111", -1)] // -256 + 255 + [InlineData("11111111" + "00000000000000000000000000000011", -260)] // lower: -257 - 3 + public void TableB3_DecodesItsPrintedCodes(string bits, int value) + { + var data = Pack(bits); + var reader = new Jbig2BitReader(data); + HuffmanTable.Get(3).Decode(ref reader).ShouldBe(value); + } + + /// + /// A custom table segment (§7.4.13): HTPS = 2, HTRS = 3, values 1 to 16 in two + /// lines, then the lower and upper range lines. + /// + [Fact] + public void TableSegment_BuildsItsLines() + { + // flags: HTOOB = 0, HTPS - 1 = 1, HTRS - 1 = 2; HTLOW = 1; HTHIGH = 17; + // lines (PREFLEN, RANGELEN) = (1, 3), (2, 3); lower PREFLEN 3; upper PREFLEN 3. + byte[] segment = [0x22, 0, 0, 0, 1, 0, 0, 0, 17, .. Pack("01011" + "10011" + "11" + "11")]; + var table = HuffmanTable.ReadSegment(segment); + + Decode(table, "0" + "000").ShouldBe(1); + Decode(table, "0" + "111").ShouldBe(8); + Decode(table, "10" + "101").ShouldBe(14); + Decode(table, "110" + new string('0', 31) + "1").ShouldBe(-1); // lower: HTLOW - 1 - 1 + Decode(table, "111" + new string('0', 30) + "10").ShouldBe(19); // upper: HTHIGH + 2 + } + + [Fact] + public void TableSegment_WithLowNotBelowHigh_IsInvalidData() + { + byte[] segment = [0x22, 0, 0, 0, 9, 0, 0, 0, 9, 0xFF, 0xFF]; + Should.Throw(() => HuffmanTable.ReadSegment(segment)); + } + + /// + /// HTLOW to HTHIGH spanning the whole int range with RANGELEN 0 would be four + /// billion lines; each one costs bits of the segment, so the reader runs out of + /// data long before it runs out of memory. + /// + [Fact] + public void TableSegment_DeclaringBillionsOfLines_FailsOnItsData() + { + byte[] segment = [0x00, 0x80, 0, 0, 0, 0x7F, 0xFF, 0xFF, 0xFF, .. new byte[64]]; + Should.Throw(() => HuffmanTable.ReadSegment(segment)); + } + + [Fact] + public void Lengths_OverSubscribingTheCodeSpace_AreInvalidData() + { + Should.Throw(() => HuffmanTable.FromCodeLengths([1, 1, 1])); + } + + [Fact] + public void Reader_PastTheEnd_IsInvalidData() + { + Should.Throw(() => + { + var reader = new Jbig2BitReader([0xFF]); + reader.ReadBits(9); + }); + } + + private static int Decode(HuffmanTable table, string bits) + { + var reader = new Jbig2BitReader(Pack(bits)); + return table.Decode(ref reader); + } + + private static byte[] Pack(string bits) + { + var bytes = new byte[(bits.Length + 7) / 8]; + for (var i = 0; i < bits.Length; i++) + if (bits[i] == '1') bytes[i >> 3] |= (byte)(0x80 >> (i & 7)); + return bytes; + } +} diff --git a/tests/Sdcb.SimdCodecs.Tests/Jbig2Oracle.cs b/tests/Sdcb.SimdCodecs.Tests/Jbig2Oracle.cs index 974313d..8ec3adc 100644 --- a/tests/Sdcb.SimdCodecs.Tests/Jbig2Oracle.cs +++ b/tests/Sdcb.SimdCodecs.Tests/Jbig2Oracle.cs @@ -130,6 +130,31 @@ private static bool TryVersion(string exe, string[] prefix, out string output) /// file header) over a standalone .jb2 file. /// public static Raster Decode(byte[] stream, bool embedded = false) + { + var pbm = Run(stream, embedded); + var position = 0; + return ParsePbm(pbm, ref position); + } + + /// + /// Decodes every page of : jbig2dec writes a + /// multi-page file as consecutive PBM images in one output. + /// + public static IReadOnlyList DecodePages(byte[] stream, bool embedded = false) + { + var pbm = Run(stream, embedded); + var pages = new List(); + var position = 0; + while (position < pbm.Length && ReadToken(pbm, ref position) is { Length: > 0 } magic) + { + position -= magic.Length; + pages.Add(ParsePbm(pbm, ref position)); + } + + return pages; + } + + private static byte[] Run(byte[] stream, bool embedded) { var (exe, viaWsl, _) = Resolved.Value; if (exe is null) throw new InvalidOperationException("jbig2dec oracle is not available."); @@ -184,7 +209,7 @@ public static Raster Decode(byte[] stream, bool embedded = false) throw new InvalidOperationException( $"jbig2dec produced no output (exit {process.ExitCode}).\n{stdout}\n{stderr}"); - return ParsePbm(File.ReadAllBytes(output)); + return File.ReadAllBytes(output); } finally { @@ -209,10 +234,8 @@ private static string ToWslPath(string path) /// and a set bit means black — so the unpacked result is already in T.88 /// polarity and needs no inversion. /// - private static Raster ParsePbm(byte[] pbm) + private static Raster ParsePbm(byte[] pbm, ref int position) { - var position = 0; - if (ReadToken(pbm, ref position) != "P4") throw new InvalidDataException("jbig2dec did not produce a binary PBM (P4)."); @@ -234,6 +257,7 @@ private static Raster ParsePbm(byte[] pbm) bits[y * width + x] = (byte)((pbm[row + (x >> 3)] >> (7 - (x & 7))) & 1); } + position += stride * height; return new Raster(width, height, bits); } diff --git a/tests/Sdcb.SimdCodecs.Tests/Jbig2RefinementTests.cs b/tests/Sdcb.SimdCodecs.Tests/Jbig2RefinementTests.cs index cc0b1d7..6476065 100644 --- a/tests/Sdcb.SimdCodecs.Tests/Jbig2RefinementTests.cs +++ b/tests/Sdcb.SimdCodecs.Tests/Jbig2RefinementTests.cs @@ -29,9 +29,8 @@ public static TheoryData Cases() var data = new TheoryData(); foreach (var edit in (string[])["identical", "speckle", "grow", "shrink", "shift", "inverted"]) foreach (var template in (int[])[0, 1]) - // TPGRON is refused rather than decoded — see - // RefinementRegionDecoder's remarks and the refusal test below. - data.Add(edit, template, false); + foreach (var tpgron in (bool[])[false, true]) + data.Add(edit, template, tpgron); return data; } @@ -135,47 +134,27 @@ public void RefinementRegion_RefinesThePageUnderItsOwnRectangle() } /// - /// TPGRON is refused. §6.3 is otherwise confirmed against jbig2dec, but with - /// this flag the two decoders disagree in ways that measurement narrowed and - /// did not resolve — see RefinementRegionDecoder's remarks. "Mostly agrees" - /// is not a decoder a caller can use, so it throws. + /// TPGRON survives the segment layer: a refinement segment with the flag set + /// decodes to its target. The pixel-level agreement with jbig2dec is + /// Jbig2RefinementOracleTests. /// [Theory] [InlineData(0)] [InlineData(1)] - public void Tpgron_IsRefusedByName(int template) + public void Tpgron_InARefinementSegment_Decodes(int template) { - var (reference, target) = Build("identical", 16, 8); - var contexts = new byte[1 << RefinementRegionDecoder.ContextBits(template)]; - - var exception = Should.Throw(() => - { - var mq = new MqDecoder(new byte[16]); - RefinementRegionDecoder.Decode( - ref mq, contexts, target.Width, target.Height, template, - reference, 0, 0, typicalPrediction: true, RefinementRegionDecoder.NominalAt, - Jbig2PixelBudget.Unmetered()); - }); - - exception.Message.ShouldContain("TPGRON"); - } - - /// And the refusal has to survive the segment layer, not just the inner call. - [Fact] - public void Tpgron_InARefinementSegment_IsRefused() - { - var (reference, target) = Build("identical", 16, 8); + var (reference, target) = Build("speckle", 40, 24); var stream = Jbig2StreamBuilder.Stream( Jbig2StreamBuilder.Segment(0, SegmentType.PageInformation, 1, - Jbig2StreamBuilder.PageInformation(16, 8)), + Jbig2StreamBuilder.PageInformation(40, 24)), Jbig2StreamBuilder.Segment(1, SegmentType.ImmediateGenericRegion, 1, Jbig2StreamBuilder.GenericRegionSegment(reference)), Jbig2StreamBuilder.Segment(2, SegmentType.ImmediateRefinementRegion, 1, - Jbig2StreamBuilder.RefinementRegionSegment(target, reference, typicalPrediction: true))); + Jbig2StreamBuilder.RefinementRegionSegment( + target, reference, op: CombinationOperator.Replace, template: template, typicalPrediction: true))); - Should.Throw(() => Jbig2Decoder.Decode(stream, 16, 8)) - .Message.ShouldContain("TPGRON"); + Jbig2Decoder.Decode(stream, 40, 24, TestContext.Current.CancellationToken).Bits.ToArray().ShouldBe(target.Data); } [Fact] diff --git a/tests/Sdcb.SimdCodecs.Tests/Jbig2SegmentTests.cs b/tests/Sdcb.SimdCodecs.Tests/Jbig2SegmentTests.cs index 2cff7e6..1363d0c 100644 --- a/tests/Sdcb.SimdCodecs.Tests/Jbig2SegmentTests.cs +++ b/tests/Sdcb.SimdCodecs.Tests/Jbig2SegmentTests.cs @@ -171,11 +171,12 @@ public void ReadHeader_PageAssociationFlag_SelectsTheFourByteForm() } [Fact] - public void ReadHeader_UnknownDataLength_IsRejectedExplicitly() + public void ReadHeader_UnknownDataLength_OnAnImmediateGenericRegion_IsLeftToTheStreamReader() { // §7.2.7 allows 0xFFFFFFFF on an immediate generic region, whose data is - // then terminated by a search sequence instead. Not implemented, and it - // must say so rather than treat the value as a length. + // then terminated by an end sequence instead. The header reader reports + // it rather than treating the value as a length; Jbig2ExtendedOracleTests + // covers the resolution. byte[] header = [ 0x00, 0x00, 0x00, 0x01, @@ -186,7 +187,25 @@ public void ReadHeader_UnknownDataLength_IsRejectedExplicitly() ]; var position = 0; - Should.Throw(() => Jbig2Segment.ReadHeader(header, ref position)) + var segment = Jbig2Segment.ReadHeader(header, ref position); + segment.LengthUnknown.ShouldBeTrue(); + segment.DataStart.ShouldBe(header.Length); + } + + [Fact] + public void ReadHeader_UnknownDataLength_OnAnyOtherType_IsInvalidData() + { + byte[] header = + [ + 0x00, 0x00, 0x00, 0x01, + 0x06, // immediate text region + 0x00, + 0x01, + 0xFF, 0xFF, 0xFF, 0xFF, + ]; + + var position = 0; + Should.Throw(() => Jbig2Segment.ReadHeader(header, ref position)) .Message.ShouldContain("unknown data length"); } diff --git a/tests/Sdcb.SimdCodecs.Tests/Jbig2TextRegionTests.cs b/tests/Sdcb.SimdCodecs.Tests/Jbig2TextRegionTests.cs index 947b409..c0868d8 100644 --- a/tests/Sdcb.SimdCodecs.Tests/Jbig2TextRegionTests.cs +++ b/tests/Sdcb.SimdCodecs.Tests/Jbig2TextRegionTests.cs @@ -114,38 +114,6 @@ public void TextRegion_WithNoSymbolDictionary_Fails() .Message.ShouldContain("symbol dictionary"); } - [Fact] - public void HuffmanCodedTextRegion_IsRefusedByName() - { - var segment = Jbig2SymbolBuilder.TextRegionSegment(16, 16, Alphabet(), [new(0, 0, 0)]); - segment[18] |= 0x01; // SBHUFF, low byte of the flags at offset 17..18 - - var stream = Jbig2StreamBuilder.Stream( - Jbig2StreamBuilder.Segment(0, SegmentType.PageInformation, 1, - Jbig2StreamBuilder.PageInformation(16, 16)), - Jbig2StreamBuilder.Segment(1, SegmentType.SymbolDictionary, 1, - Jbig2SymbolBuilder.SymbolDictionarySegment(Alphabet())), - Jbig2StreamBuilder.Segment(2, SegmentType.ImmediateTextRegion, 1, segment, referredTo: [1])); - - Should.Throw(() => Jbig2Decoder.Decode(stream, 16, 16)) - .Message.ShouldContain("SBHUFF"); - } - - [Fact] - public void HuffmanCodedSymbolDictionary_IsRefusedByName() - { - var segment = Jbig2SymbolBuilder.SymbolDictionarySegment(Alphabet()); - segment[1] |= 0x01; // SDHUFF, low byte of the 16-bit flags - - var stream = Jbig2StreamBuilder.Stream( - Jbig2StreamBuilder.Segment(0, SegmentType.PageInformation, 1, - Jbig2StreamBuilder.PageInformation(16, 16)), - Jbig2StreamBuilder.Segment(1, SegmentType.SymbolDictionary, 1, segment)); - - Should.Throw(() => Jbig2Decoder.Decode(stream, 16, 16)) - .Message.ShouldContain("SDHUFF"); - } - /// /// A dictionary that imports from another — the mechanism PDF's /// /JBIG2Globals is built on, and the one place symbol numbering spans diff --git a/tests/Sdcb.SimdCodecs.Tests/Jpeg2000Jp2Tests.cs b/tests/Sdcb.SimdCodecs.Tests/Jpeg2000Jp2Tests.cs index a265013..8d170db 100644 --- a/tests/Sdcb.SimdCodecs.Tests/Jpeg2000Jp2Tests.cs +++ b/tests/Sdcb.SimdCodecs.Tests/Jpeg2000Jp2Tests.cs @@ -94,21 +94,52 @@ public void OnlyTheFirstColourSpecification_Counts() } /// - /// The three boxes that change what the components mean are refused by name. Skipped, each - /// would hand back samples whose meaning had silently changed: palette indices as if they were - /// colour, channels out of order, an alpha channel drawn as colour. + /// The channel definition box is refused by name: skipped, it would hand back an alpha channel + /// drawn as colour, or channels out of order. (The palette and component mapping boxes are + /// decoded; .) + /// + [Fact] + public void ChannelDefinition_IsRefused() + { + var wrapped = Jp2( + Header(ImageHeader(), EnumeratedColour(16), Box("cdef", [0, 0, 0, 0])), + Box("jp2c", Codestream("rgb-struct64"))); + + Should.Throw(() => Jpeg2000Decoder.Decode(wrapped)).Message.ShouldContain("channel definition"); + } + + /// + /// Palette and component mapping boxes that do not describe a palette are malformed: an empty + /// palette, a palette with no mapping, a mapping into a column the palette lacks, a palette + /// shorter than its entries, an undefined mapping type, a mapping naming a missing component. /// [Theory] - [InlineData("pclr", "palette")] - [InlineData("cmap", "component mapping")] - [InlineData("cdef", "channel definition")] - public void BoxesThatChangeTheMeaningOfComponents_AreRefused(string type, string named) + [InlineData("empty palette")] + [InlineData("palette without mapping")] + [InlineData("missing column")] + [InlineData("short palette")] + [InlineData("bad mapping type")] + [InlineData("missing component")] + [InlineData("too many entries")] + public void MalformedPaletteOrMapping_IsInvalid(string what) { + byte[] palette = [0, 2, 1, 7, 10, 20]; + byte[][] boxes = what switch + { + "empty palette" => [Box("pclr", [0, 0, 0, 0]), Box("cmap", [0, 0, 1, 0])], + "palette without mapping" => [Box("pclr", palette)], + "missing column" => [Box("pclr", palette), Box("cmap", [0, 0, 1, 1])], + "short palette" => [Box("pclr", [0, 200, 1, 7, 1, 2, 3]), Box("cmap", [0, 0, 1, 0])], + "bad mapping type" => [Box("pclr", palette), Box("cmap", [0, 0, 2, 0])], + "too many entries" => [Box("pclr", [0xFF, 0xFF, 1, 7]), Box("cmap", [0, 0, 1, 0])], + _ => [Box("pclr", palette), Box("cmap", [0, 9, 1, 0])], + }; + var wrapped = Jp2( - Header(ImageHeader(), EnumeratedColour(16), Box(type, [0, 0, 0, 0])), + Header([ImageHeader(), EnumeratedColour(16), .. boxes]), Box("jp2c", Codestream("rgb-struct64"))); - Should.Throw(() => Jpeg2000Decoder.Decode(wrapped)).Message.ShouldContain(named); + Should.Throw(() => Jpeg2000Decoder.Decode(wrapped)); } /// diff --git a/tests/Sdcb.SimdCodecs.Tests/Jpeg2000ResourceLimitTests.cs b/tests/Sdcb.SimdCodecs.Tests/Jpeg2000ResourceLimitTests.cs index 60656ba..0350792 100644 --- a/tests/Sdcb.SimdCodecs.Tests/Jpeg2000ResourceLimitTests.cs +++ b/tests/Sdcb.SimdCodecs.Tests/Jpeg2000ResourceLimitTests.cs @@ -169,9 +169,51 @@ public void AnUnmeteredBudget_NeverRunsOut() [InlineData("cblk4-struct64")] [InlineData("odd37x23")] [InlineData("flat64")] - public void SingleByteMutations_NeverEscapeTheContract(string name) + public void SingleByteMutations_NeverEscapeTheContract(string name) => Mutate(Fixture(name)); + + /// + /// The same sweep over a codestream using every tier-2 feature at once: tiles of odd sizes, an + /// odd origin, tile-parts, precincts, a position-driven progression, SOP and EPH markers and + /// segmentation symbols. Made live by opj_compress, so it needs the oracle. + /// + [Fact] + public void SingleByteMutations_OfATiledPrecinctCodestream_NeverEscapeTheContract() + { + OpenJpegOracle.RequireOrSkip(); + + Mutate(OpenJpegOracle.Compress(Jpeg2000TilingAndProgressionTests.Picture(53, 41, 1), + "-t", "23,19", "-d", "1,2", "-T", "1,1", "-n", "3", "-c", "[16,16]", "-p", "RPCL", "-TP", "R", + "-SOP", "-EPH", "-M", "34", "-r", "10,1")); + } + + /// + /// A header declaring thousands of tiles in front of the data for one. Every tile needs a + /// tile-part, so it is refused as soon as the tile-parts are counted, before any geometry is + /// built for the tiles that are missing. + /// + [Fact] + public void ManyDeclaredTiles_WithTheDataForOne_AreRefused() + { + var bytes = Fixture("dwt5-struct64"); + BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(24), 1); + BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(28), 1); + + Should.Throw(() => Jpeg2000Decoder.Decode(bytes)).Message.ShouldContain("tile-parts"); + } + + /// More tiles than a 16-bit tile index can name is not a JPEG 2000 codestream. + [Fact] + public void MoreTilesThanATileIndexCanName_AreRefused() + { + var bytes = WithDeclaredSize("dwt5-struct64", 1000, 1000); + BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(24), 1); + BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(28), 1); + + Should.Throw(() => Jpeg2000Decoder.Decode(bytes)).Message.ShouldContain("65,535"); + } + + private static void Mutate(byte[] original) { - var original = Fixture(name); // A fixed sweep rather than a random one: the same bytes are tried on // every run, so a failure is reproducible from the test name alone. @@ -187,7 +229,7 @@ public void SingleByteMutations_NeverEscapeTheContract(string name) var image = Jpeg2000Decoder.Decode(mutated, cancellationToken: TestContext.Current.CancellationToken); // If it decoded, the result must at least be self-consistent. - image.Samples.Length.ShouldBe(image.Width * image.Height); + image.Samples.Length.ShouldBe(image.Width * image.Height * image.Components); } catch (InvalidDataException) { diff --git a/tests/Sdcb.SimdCodecs.Tests/Jpeg2000TilingAndProgressionTests.cs b/tests/Sdcb.SimdCodecs.Tests/Jpeg2000TilingAndProgressionTests.cs new file mode 100644 index 0000000..8835851 --- /dev/null +++ b/tests/Sdcb.SimdCodecs.Tests/Jpeg2000TilingAndProgressionTests.cs @@ -0,0 +1,209 @@ +using System.Buffers.Binary; +using System.Text; +using Sdcb.SimdCodecs.Jpeg2000; +using Shouldly; + +namespace Sdcb.SimdCodecs.Tests; + +/// +/// Tier-2 beyond one tile and one precinct, made live by opj_compress and checked against +/// opj_decompress: several tiles (odd sizes, an odd image and tile origin), tiles split into +/// tile-parts, custom precincts, all five progression orders, SOP and EPH markers, the code-block +/// styles that are decoded, and the JP2 palette. The reversible codestreams must decode to OpenJPEG's +/// samples exactly; the irreversible ones to within one, as in . +/// +/// Issue #1's corpus () is what showed these were missing; +/// these are the committed, licence-clean version of the same coverage. +/// +/// +public class Jpeg2000TilingAndProgressionTests +{ + /// + /// A deterministic test picture: a gradient with structure and some noise, so every subband has + /// something to code and no two tiles look alike. + /// + internal static byte[] Picture(int width, int height, int components) + { + var header = Encoding.ASCII.GetBytes($"P{(components == 3 ? 6 : 5)}\n{width} {height}\n255\n"); + var pixels = new byte[width * height * components]; + var seed = 12345u; + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + for (var c = 0; c < components; c++) + { + seed = seed * 1664525 + 1013904223; + var smooth = (x * (3 + c) + y * (5 - c)) % 256; + var stripe = ((x / 7 + y / 5 + c) & 1) * 40; + pixels[(y * width + x) * components + c] = (byte)Math.Clamp(smooth / 2 + stripe + (int)(seed >> 28), 0, 255); + } + } + } + + return [.. header, .. pixels]; + } + + public static TheoryData Reversible => new() + { + { "tiles, odd sizes", "-t 37,29 -n 3" }, + { "tiles, odd image and tile origin", "-t 37,29 -d 3,5 -T 1,2 -n 3" }, + { "tiles, odd origin, one-sample resolutions", "-t 9,7 -d 1,1 -n 3" }, + { "RLCP, layers", "-p RLCP -r 20,5,1 -t 64,64" }, + { "RPCL, precincts", "-p RPCL -c [32,32],[16,16] -r 10,1" }, + { "PCRL, precincts", "-p PCRL -c [64,64],[32,32],[16,16] -r 8,2" }, + { "CPRL, precincts", "-p CPRL -c [32,32] -n 4 -r 6,1" }, + { "LRCP, precincts and tiles", "-c [16,16] -t 48,40 -n 3 -r 5,1" }, + { "precincts smaller than code-blocks", "-c [16,16] -b 16,16 -n 4" }, + { "SOP and EPH", "-SOP -EPH -t 64,64 -r 10,1" }, + { "tile-parts by resolution", "-TP R -t 64,64 -p RPCL -r 10,1" }, + { "tile-parts by component", "-TP C -t 48,48 -p CPRL" }, + { "tile-parts by layer", "-TP L -t 64,64 -r 20,5,1" }, + { "segmentation symbols", "-M 32 -t 64,64" }, + { "context reset", "-M 2" }, + { "predictable termination", "-M 16" }, + { "reset, termination and segmentation together", "-M 50 -p RPCL -c [32,32]" }, + }; + + [Theory] + [MemberData(nameof(Reversible))] + public void GreyCodestream_DecodesExactlyAsOpenJpegDoes(string what, string options) + { + OpenJpegOracle.RequireOrSkip(); + _ = what; + + var codestream = OpenJpegOracle.Compress(Picture(157, 123, 1), options.Split(' ')); + AssertMatches(codestream, ".pgm", tolerance: 0); + } + + [Theory] + [MemberData(nameof(Reversible))] + public void RgbCodestream_DecodesExactlyAsOpenJpegDoes(string what, string options) + { + OpenJpegOracle.RequireOrSkip(); + _ = what; + + var codestream = OpenJpegOracle.Compress(Picture(101, 89, 3), options.Split(' ')); + AssertMatches(codestream, ".ppm", tolerance: 0); + } + + public static TheoryData Irreversible => new() + { + { "tiles, odd origin", "-I -t 37,29 -d 3,5 -T 1,2 -n 3 -r 20" }, + { "tiles, odd origin, one-sample resolutions", "-I -t 9,7 -d 1,1 -n 3" }, + { "RPCL, precincts, SOP, EPH, segmentation", "-I -p RPCL -c [32,32],[16,16] -SOP -EPH -M 32 -t 64,64 -r 40,10" }, + { "PCRL, tile-parts", "-I -p PCRL -TP R -t 48,48 -r 30" }, + { "CPRL, layers", "-I -p CPRL -c [64,64] -r 50,20,5" }, + }; + + [Theory] + [MemberData(nameof(Irreversible))] + public void IrreversibleCodestream_DecodesWithinOneOfOpenJpeg(string what, string options) + { + OpenJpegOracle.RequireOrSkip(); + _ = what; + + AssertMatches(OpenJpegOracle.Compress(Picture(157, 123, 1), options.Split(' ')), ".pgm", tolerance: 1); + AssertMatches(OpenJpegOracle.Compress(Picture(101, 89, 3), options.Split(' ')), ".ppm", tolerance: 1); + } + + /// + /// A reduced decode of a tiled image: every tile is reduced on its own, and the reduced tiles + /// must still meet edge to edge as OpenJPEG's -r puts them. + /// + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void TiledCodestream_ReducesAsOpenJpegDoes(int reduce) + { + OpenJpegOracle.RequireOrSkip(); + + var codestream = OpenJpegOracle.Compress(Picture(157, 123, 1), "-t", "37,29", "-d", "3,5", "-T", "1,2", "-n", "4", "-p", "RPCL"); + var decoded = Jpeg2000Decoder.Decode(codestream, reduce, TestContext.Current.CancellationToken); + var expected = OpenJpegOracle.Decode(codestream, ".pgm", "-r", reduce.ToString()); + + decoded.Width.ShouldBe(expected.Width); + decoded.Height.ShouldBe(expected.Height); + decoded.Samples.ShouldBe(expected.Samples); + } + + /// + /// A JP2 palette (pclr) with its component mapping (cmap): one component of indices turned into + /// three of colour, checked against OpenJPEG decoding the same file. + /// + [Fact] + public void PalettedJp2_DecodesAsOpenJpegDoes() + { + OpenJpegOracle.RequireOrSkip(); + + // Indices 0..199 into a 200-entry palette. + const int width = 61, height = 47, entries = 200; + var indices = new byte[width * height]; + for (var i = 0; i < indices.Length; i++) indices[i] = (byte)((i * 7 + i / width * 3) % entries); + byte[] pgm = [.. Encoding.ASCII.GetBytes($"P5\n{width} {height}\n255\n"), .. indices]; + var codestream = OpenJpegOracle.Compress(pgm, "-t", "32,32"); + + var palette = new List { (byte)(entries >> 8), (byte)(entries & 0xFF), 3, 7, 7, 7 }; + for (var e = 0; e < entries; e++) palette.AddRange([(byte)(e * 5 % 256), (byte)(255 - e), (byte)(e * e % 256)]); + + var file = Jp2( + Box("jp2h", [ + .. Box("ihdr", [.. BigEndian32(height), .. BigEndian32(width), 0, 1, 7, 7, 0, 0]), + .. Box("colr", [1, 0, 0, .. BigEndian32(16)]), + .. Box("pclr", [.. palette]), + .. Box("cmap", [0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 1, 2]), + ]), + Box("jp2c", codestream)); + + var decoded = Jpeg2000Decoder.Decode(file, TestContext.Current.CancellationToken); + var expected = OpenJpegOracle.DecodeFile(file, ".jp2", ".ppm"); + + decoded.Components.ShouldBe(3); + decoded.BitDepth.ShouldBe(8); + decoded.Width.ShouldBe(expected.Width); + decoded.Samples.ShouldBe(expected.Samples); + + var info = Jpeg2000Decoder.ReadInfo(file); + info.Components.ShouldBe(3); + info.BitDepth.ShouldBe(8); + } + + private static void AssertMatches(byte[] codestream, string extension, int tolerance) + { + var decoded = Jpeg2000Decoder.Decode(codestream, TestContext.Current.CancellationToken); + var expected = OpenJpegOracle.Decode(codestream, extension); + + decoded.Width.ShouldBe(expected.Width); + decoded.Height.ShouldBe(expected.Height); + decoded.Components.ShouldBe(expected.Components); + + if (tolerance == 0) + { + decoded.Samples.ShouldBe(expected.Samples); + return; + } + + var worst = 0; + for (var i = 0; i < decoded.Samples.Length; i++) + worst = Math.Max(worst, Math.Abs(decoded.Samples[i] - expected.Samples[i])); + worst.ShouldBeLessThanOrEqualTo(tolerance); + } + + internal static byte[] Jp2(params byte[][] boxes) + { + byte[] signature = [0x00, 0x00, 0x00, 0x0C, 0x6A, 0x50, 0x20, 0x20, 0x0D, 0x0A, 0x87, 0x0A]; + var fileType = Box("ftyp", [.. "jp2 "u8, 0, 0, 0, 0, .. "jp2 "u8]); + return [.. signature, .. fileType, .. boxes.SelectMany(b => b)]; + } + + internal static byte[] Box(string type, byte[] body) => + [.. BigEndian32(8 + body.Length), .. Encoding.ASCII.GetBytes(type), .. body]; + + internal static byte[] BigEndian32(int value) + { + var bytes = new byte[4]; + BinaryPrimitives.WriteInt32BigEndian(bytes, value); + return bytes; + } +} diff --git a/tests/Sdcb.SimdCodecs.Tests/OpenJpegOracle.cs b/tests/Sdcb.SimdCodecs.Tests/OpenJpegOracle.cs index 6eb7523..73a2a89 100644 --- a/tests/Sdcb.SimdCodecs.Tests/OpenJpegOracle.cs +++ b/tests/Sdcb.SimdCodecs.Tests/OpenJpegOracle.cs @@ -91,6 +91,75 @@ public static Pnm.Image Decode(byte[] codestream, string extension = ".pgm", par } } + /// + /// for a file whose format opj_decompress + /// must be told by its name: is .jp2 for a JP2 file. + /// + public static Pnm.Image DecodeFile(byte[] file, string inputExtension, string outputExtension) + { + var tools = Resolved.Value; + if (tools.Decompress is null) throw new InvalidOperationException("OpenJPEG oracle is not available."); + + var scratch = Path.Combine(AppContext.BaseDirectory, "jpeg2000-oracle-tmp"); + Directory.CreateDirectory(scratch); + var stem = Path.Combine(scratch, Guid.NewGuid().ToString("n")); + var input = stem + inputExtension; + var output = stem + outputExtension; + + try + { + File.WriteAllBytes(input, file); + var (exitCode, log) = Run(tools.Decompress, ["-i", input, "-o", output], tools.LibraryPath); + if (!File.Exists(output)) + throw new InvalidOperationException( + $"opj_decompress produced no output (exit {exitCode}).{Environment.NewLine}{log}"); + + return Pnm.Read(output); + } + finally + { + TryDelete(input); + TryDelete(output); + } + } + + /// + /// Encodes a binary PNM with opj_compress and returns the raw codestream it wrote, so a + /// test can make the exact coding options it needs (tiles, precincts, progression orders, + /// markers) rather than depend on committed bytes. + /// + /// A P5 or P6 file. + /// opj_compress options, after -i and -o. + public static byte[] Compress(byte[] pnm, params string[] args) + { + var tools = Resolved.Value; + if (tools.Compress is null) throw new InvalidOperationException("OpenJPEG oracle is not available."); + + var scratch = Path.Combine(AppContext.BaseDirectory, "jpeg2000-oracle-tmp"); + Directory.CreateDirectory(scratch); + var stem = Path.Combine(scratch, Guid.NewGuid().ToString("n")); + var input = stem + (pnm[1] == (byte)'6' ? ".ppm" : ".pgm"); + var output = stem + ".j2k"; + + try + { + File.WriteAllBytes(input, pnm); + var all = new List { "-i", input, "-o", output }; + all.AddRange(args); + var (exitCode, log) = Run(tools.Compress, all, tools.LibraryPath); + if (!File.Exists(output)) + throw new InvalidOperationException( + $"opj_compress produced no output (exit {exitCode}).{Environment.NewLine}{log}"); + + return File.ReadAllBytes(output); + } + finally + { + TryDelete(input); + TryDelete(output); + } + } + private static void TryDelete(string path) { try { if (File.Exists(path)) File.Delete(path); } diff --git a/tests/Sdcb.SimdCodecs.Tests/Oracle/jbig2/README.md b/tests/Sdcb.SimdCodecs.Tests/Oracle/jbig2/README.md index 711c197..732ab98 100644 --- a/tests/Sdcb.SimdCodecs.Tests/Oracle/jbig2/README.md +++ b/tests/Sdcb.SimdCodecs.Tests/Oracle/jbig2/README.md @@ -10,6 +10,11 @@ bottom. | **us → jbig2dec** | `jbig2dec` (Artifex, AGPL) | `Jbig2Oracle` + `Jbig2OracleTests` | Yes, else the tests skip | | **libtiff → us** (MMR) | ImageMagick / libtiff, via Magick.NET | `Group4Tiff` + `Jbig2MmrOracleTests` | **No** — Magick.NET is already a package reference | +**Optional local corpus.** The 114 `.jb2` files attached to issue #1 (unknown source and +licence, so never committed or fetched by a test) are checked page by page against jbig2dec by +`Issue1Jbig2CorpusTests` when `SIMDCODECS_ISSUE1_DIR` points at their extracted directory; the +test's remarks give the URLs and the expected result. Run it after any change to the decoder. + They cover different things, which is the point of having all three. - jbig2enc only ever emits **GBTEMPLATE 0 with nominal AT pixels** (asserted by diff --git a/tests/Sdcb.SimdCodecs.Tests/Oracle/jpeg2000/README.md b/tests/Sdcb.SimdCodecs.Tests/Oracle/jpeg2000/README.md index 0ce60b2..2c159d7 100644 --- a/tests/Sdcb.SimdCodecs.Tests/Oracle/jpeg2000/README.md +++ b/tests/Sdcb.SimdCodecs.Tests/Oracle/jpeg2000/README.md @@ -118,3 +118,18 @@ because their expected output is not their source. Their tests decode them with Sources are regenerated by `gen-sources.py` from closed-form functions of `(x, y)` and one fixed LCG — no library PRNG, no floating point — so a regeneration on another machine or Python version reproduces the same bytes. + +## Made at test time: tiles, precincts, progressions, markers, palettes + +`Jpeg2000TilingAndProgressionTests` commits no codestream at all. It writes a picture, has +`opj_compress` code it with the option under test (`-t`, `-d`/`-T` for an odd origin, `-TP`, +`-c`, `-p RLCP|RPCL|PCRL|CPRL`, `-SOP -EPH`, `-M 2|16|32`, `-I`), and compares with +`opj_decompress` on the same bytes. Two `opj_compress` 2.5.4 shapes are kept out because +OpenJPEG cannot round-trip them itself: a tile one sample wide (its own decode is not lossless +there) and `-TP L` on 48x48 tiles of the 157x123 picture (it cannot decode what it wrote). + +## Issue #1's corpus + +The 16 JPEG 2000 files attached to issue #1 have no known licence, so they are not committed or +fetched by a test. `Issue1Jpeg2000CorpusTests` runs them when `SIMDCODECS_ISSUE1_DIR` names the +unzipped attachments (the URLs are in that class's remarks); run it after changing the decoder.