From 4f634b57a377991d01a07ca284243aa49fddb72a Mon Sep 17 00:00:00 2001 From: sdcb Date: Wed, 4 Nov 2026 23:44:24 +0800 Subject: [PATCH 1/2] JPEG 2000: decode tiles, tile-parts, precincts, every progression order, SOP/EPH and JP2 palettes Issue #1's 16 JPEG 2000 files failed at the header: 13 for multiple tiles, 3 for a JP2 palette, and behind those every one used a non-LRCP progression, tile-parts, and some precincts, SOP/EPH and segmentation symbols. All 16 now match opj_decompress 2.5.4: the 5/3 ones sample for sample, the 9/7 ones within 1. - Tiles (T.800 B-7) are decoded one at a time into one output, each against its own sample budget; a tile's parts are joined in TPsot order. A header declaring more tiles than its tile-parts is refused before any geometry. - Precincts (B.6) carry their own tag trees; packets are read in LRCP, RLCP, RPCL, PCRL or CPRL order, the position orders by sorting precincts on the reference-grid point where B.12.1.3's walk first lands in them. - SOP and EPH markers are skipped; context reset, predictable termination and segmentation symbols are decoded (bypass, TERMALL and vertically causal are still refused). - A lone high-pass sample (F.3.7) is halved instead of refused; odd tile origins reach it. - JP2 pclr + cmap are applied; ReadInfo and the facade report the mapped channels. cdef is still refused. Tests: Jpeg2000TilingAndProgressionTests makes codestreams live with opj_compress for each feature; Issue1Jpeg2000CorpusTests runs the issue's files from SIMDCODECS_ISSUE1_DIR (not committed: unknown licence). Co-Authored-By: Claude Opus 5.5 --- docs/known-gaps/jpeg2000.md | 18 +- src/Sdcb.SimdCodecs.Jpeg2000/BlockDecoder.cs | 51 ++- .../CodestreamReader.cs | 70 ++-- src/Sdcb.SimdCodecs.Jpeg2000/Geometry.cs | 238 +++++++++++--- .../InverseWavelet.cs | 37 ++- src/Sdcb.SimdCodecs.Jpeg2000/Jp2Reader.cs | 191 ++++++++++- .../Jpeg2000Decoder.cs | 299 ++++++++++++++---- .../Jpeg2000ImageDecoder.cs | 19 +- src/Sdcb.SimdCodecs.Jpeg2000/Markers.cs | 22 ++ .../PacketBitReader.cs | 6 +- src/Sdcb.SimdCodecs.Jpeg2000/Tier2.cs | 204 +++++++++--- .../Issue1Jpeg2000CorpusTests.cs | 98 ++++++ .../Sdcb.SimdCodecs.Tests/Jpeg2000Jp2Tests.cs | 49 ++- .../Jpeg2000ResourceLimitTests.cs | 48 ++- .../Jpeg2000TilingAndProgressionTests.cs | 209 ++++++++++++ tests/Sdcb.SimdCodecs.Tests/OpenJpegOracle.cs | 69 ++++ .../Oracle/jpeg2000/README.md | 15 + 17 files changed, 1384 insertions(+), 259 deletions(-) create mode 100644 tests/Sdcb.SimdCodecs.Tests/Issue1Jpeg2000CorpusTests.cs create mode 100644 tests/Sdcb.SimdCodecs.Tests/Jpeg2000TilingAndProgressionTests.cs 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.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/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/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/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. From 979abcdd20d35df81fdf38216a07c9d0d8ce81bb Mon Sep 17 00:00:00 2001 From: sdcb Date: Thu, 5 Nov 2026 00:01:41 +0800 Subject: [PATCH 2/2] JBIG2: decode every file of issue #1 that jbig2dec decodes The files attached to issue #1 decoded 68 of 114. Now 112 match jbig2dec page for page, pixel for pixel; the other two (042_13, 042_14) are rejected as malformed by jbig2dec and by this decoder at the same place. - HENABLESKIP halftone regions: the SKIP bitmap of T.88 6.6.5.1, and USESKIP in the generic region decoder (6.2.5.7). - Pattern dictionaries with GRAYMAX above 255 (a four-byte field), bounded by the collective bitmap's size before anything is allocated. - MMR pattern dictionaries and MMR halftone bitplanes; MmrDecoder reports the bytes it consumed, EOFB included. - Generic regions of unknown data length (7.2.7): the FF AC / 00 00 end sequence and row count, in sequential and embedded streams. - Huffman coding: the standard tables B.1-B.15, custom table segments (7.4.13), Huffman symbol dictionaries (uncompressed and MMR height-class collective bitmaps, refine/aggregate symbols) and Huffman text regions with their symbol ID table (7.4.3.1.7). - TPGRON in refinement regions: the SLTP contexts were wrong. 0x0010 / 0x0004, settled against jbig2dec (a sweep of all 1024 GRTEMPLATE 1 values leaves 0x0004 alone; 0x0010 decodes 042_24 as jbig2dec does). - Every page of a multi-page file: new public Jbig2Decoder.DecodeFile(file, pageNumber) and TryReadFileInfo(file, out width, out height, out pages). Tests: Jbig2ExtendedOracleTests checks each feature with hand-assembled streams against jbig2dec (Huffman with standard and custom tables, MMR collective bitmaps from libtiff), Jbig2HuffmanTests the tables, and the refinement oracle cases now run with TPGRON on. Issue1Jbig2CorpusTests runs the issue's files when SIMDCODECS_ISSUE1_DIR points at them; they are not committed because their licence is unknown. Co-Authored-By: Claude Opus 5.5 --- docs/known-gaps.md | 2 +- docs/known-gaps/jbig2.md | 17 +- .../GenericRegionDecoder.cs | 28 +- src/Sdcb.SimdCodecs.Jbig2/HalftoneDecoder.cs | 97 ++- src/Sdcb.SimdCodecs.Jbig2/HuffmanTable.cs | 305 +++++++++ src/Sdcb.SimdCodecs.Jbig2/Jbig2Decoder.cs | 236 +++++-- .../Jbig2ImageDecoder.cs | 2 +- src/Sdcb.SimdCodecs.Jbig2/Jbig2Segment.cs | 74 ++- src/Sdcb.SimdCodecs.Jbig2/MmrDecoder.cs | 18 +- .../PublicAPI.Unshipped.txt | 2 + .../RefinementRegionDecoder.cs | 43 +- .../SymbolDictionaryDecoder.cs | 295 +++++++-- .../TextRegionDecoder.cs | 200 +++++- .../Issue1Jbig2CorpusTests.cs | 148 +++++ .../Jbig2DecoderTests.cs | 11 +- .../Jbig2ExtendedOracleTests.cs | 579 ++++++++++++++++++ .../Sdcb.SimdCodecs.Tests/Jbig2FacadeTests.cs | 4 +- .../Jbig2HalftoneTests.cs | 67 +- .../Jbig2HuffmanTests.cs | 118 ++++ tests/Sdcb.SimdCodecs.Tests/Jbig2Oracle.cs | 32 +- .../Jbig2RefinementTests.cs | 43 +- .../Jbig2SegmentTests.cs | 27 +- .../Jbig2TextRegionTests.cs | 32 - .../Oracle/jbig2/README.md | 5 + 24 files changed, 2120 insertions(+), 265 deletions(-) create mode 100644 src/Sdcb.SimdCodecs.Jbig2/HuffmanTable.cs create mode 100644 tests/Sdcb.SimdCodecs.Tests/Issue1Jbig2CorpusTests.cs create mode 100644 tests/Sdcb.SimdCodecs.Tests/Jbig2ExtendedOracleTests.cs create mode 100644 tests/Sdcb.SimdCodecs.Tests/Jbig2HuffmanTests.cs 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/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/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/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/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