From 59099c4588a0c6d4d3880a257cd6d2e811c90d6c Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 19 Sep 2026 10:32:15 -0400 Subject: [PATCH 1/4] Fix CodeQL and SonarCloud quality findings - S2583: remove provably-dead 'Missing IHDR chunk' check in PngCodec.Load; add regression test proving IEND-before-IHDR already throws earlier - S3776: extract-method refactors to reduce cognitive complexity in JpegCodec (Decode, DecodeScan, DecodeBlock, DecodeAcRefine, AssembleCanvas, BuildPlanes), PngCodec (Load, DefilterRow), and TiffCodec (Load, EncodePackBits, DecodeLzw) with zero algorithm/behavior change - S107: introduce parameter-object record structs (ScanDecodeContext, ScanHeader, McuGrid, BlockDecodeContext, ComponentPlanes, PlaneDimensions, PlaneSet) to reduce parameter counts on refactored JpegCodec methods - CA2263: use generic Enum.IsDefined overload - xUnit1042: convert TruncatedSegmentPayloadCases to TheoryData - cs/loss-of-precision: use explicit double arithmetic in JpegCodec.BuildBasis - cs/linq/missed-where: hoist single-condition foreach filter into .Where - cs/path-combine: sanitize theory-supplied file names via Path.GetFileName before Path.Combine in JpegFixtureTests, TiffFixtureTests, PngSuiteTests - cs/missed-ternary-operator: exclude **/obj/** and **/bin/** from CodeQL analysis paths to avoid false positives on generated xUnit entry points - cs/local-not-disposed: verified no genuine leaks remain in JpegCodecTests/TiffCodecTests (all locals already properly disposed) Adds new requirement CanvasNet-Codecs-PngCodec-LoadChunkBeforeIhdr with supporting test, design, and verification documentation updates. All 1420 tests passing (1416 baseline + 1 new regression test x 4 target frameworks), 0 failures. dotnet build, reqstream --enforce, reviewmark --enforce, and lint.ps1 all pass clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/codeql-config.yml | 7 + docs/design/canvas-net/codecs/png-codec.md | 11 +- docs/reqstream/canvas-net/codecs.yaml | 5 +- .../canvas-net/codecs/png-codec.yaml | 13 + .../canvas-net/codecs/png-codec.md | 8 + .../Codecs/JpegCodec.cs | 784 ++++++++++++------ .../Codecs/PngCodec.cs | 333 +++++--- .../Codecs/TiffCodec.cs | 380 ++++++--- .../Codecs/JpegCodecTests.cs | 26 +- .../Codecs/JpegFixtureTests.cs | 16 +- .../Codecs/PngCodecTests.cs | 23 + .../Codecs/PngSuiteTests.cs | 16 +- .../Codecs/TiffFixtureTests.cs | 18 +- 13 files changed, 1096 insertions(+), 544 deletions(-) diff --git a/.github/codeql-config.yml b/.github/codeql-config.yml index 22ff818b..f551c5fc 100644 --- a/.github/codeql-config.yml +++ b/.github/codeql-config.yml @@ -2,3 +2,10 @@ # CodeQL configuration for CanvasNet name: "CanvasNet CodeQL Config" + +# Exclude build output directories from analysis - these contain generated +# files (e.g. XunitAutoGeneratedEntryPoint.cs) that trigger false-positive +# static analysis findings and are not part of the reviewed source tree. +paths-ignore: + - "**/obj/**" + - "**/bin/**" diff --git a/docs/design/canvas-net/codecs/png-codec.md b/docs/design/canvas-net/codecs/png-codec.md index e7ab03c5..a64427a3 100644 --- a/docs/design/canvas-net/codecs/png-codec.md +++ b/docs/design/canvas-net/codecs/png-codec.md @@ -102,11 +102,12 @@ standard filter types) and unpacked into the destination `Surface`'s rows via **Throws:** - `ArgumentNullException` — `stream` is null -- `InvalidDataException` — missing PNG signature; missing, duplicate, or malformed `IHDR`; - unsupported bit depth, color type, compression method, filter method, or interlace method; any - chunk's CRC-32 mismatch; a malformed or unsupported zlib header; an Adler-32 checksum mismatch; - an unexpected decompressed data length; an unsupported scanline filter type; or the stream ends - before all header, chunk, or pixel data has been read +- `InvalidDataException` — missing PNG signature; missing, duplicate, or malformed `IHDR`; an + `IDAT` or `IEND` chunk encountered before `IHDR`; unsupported bit depth, color type, compression + method, filter method, or interlace method; any chunk's CRC-32 mismatch; a malformed or + unsupported zlib header; an Adler-32 checksum mismatch; an unexpected decompressed data length; + an unsupported scanline filter type; or the stream ends before all header, chunk, or pixel data + has been read #### Load(string path) diff --git a/docs/reqstream/canvas-net/codecs.yaml b/docs/reqstream/canvas-net/codecs.yaml index 4533df70..0a1b1df3 100644 --- a/docs/reqstream/canvas-net/codecs.yaml +++ b/docs/reqstream/canvas-net/codecs.yaml @@ -196,7 +196,8 @@ sections: title: >- The Codecs subsystem shall reject malformed or unsupported PNG data (bad signature, unsupported color type, unsupported bit depth, interlacing, corrupt chunk CRC-32, corrupt - Adler-32 checksum, or a truncated stream) with InvalidDataException. + Adler-32 checksum, a chunk preceding IHDR, or a truncated stream) with + InvalidDataException. justification: | Callers must be able to distinguish a genuinely malformed or unsupported PNG file from a successful load, and receive a clear, actionable error rather than incorrect pixel data. @@ -208,6 +209,7 @@ sections: - CanvasNet-Codecs-PngCodec-LoadCorruptCrc - CanvasNet-Codecs-PngCodec-AdlerValidation - CanvasNet-Codecs-PngCodec-LoadTruncatedStream + - CanvasNet-Codecs-PngCodec-LoadChunkBeforeIhdr - CanvasNet-Codecs-PngCodec-PngSuiteUnsupported - CanvasNet-Codecs-PngCodec-PngSuiteCorrupt tests: @@ -219,6 +221,7 @@ sections: - PngCodec_Load_CorruptChunkCrc_ThrowsInvalidDataException - PngCodec_Load_CorruptAdlerTrailer_ThrowsInvalidDataException - PngCodec_Load_TruncatedStream_ThrowsInvalidDataException + - PngCodec_Load_IendBeforeIhdr_ThrowsInvalidDataExceptionMentioningIhdr - id: CanvasNet-Codecs-TiffSave title: >- diff --git a/docs/reqstream/canvas-net/codecs/png-codec.yaml b/docs/reqstream/canvas-net/codecs/png-codec.yaml index 697fe63d..3cc52159 100644 --- a/docs/reqstream/canvas-net/codecs/png-codec.yaml +++ b/docs/reqstream/canvas-net/codecs/png-codec.yaml @@ -205,6 +205,19 @@ sections: tests: - PngCodec_Load_TruncatedStream_ThrowsInvalidDataException + - id: CanvasNet-Codecs-PngCodec-LoadChunkBeforeIhdr + title: >- + The PngCodec class shall reject a PNG stream in which an IDAT or IEND chunk appears + before the mandatory IHDR chunk, with InvalidDataException naming IHDR as the cause. + justification: | + IHDR is required by the PNG specification to be the first chunk and supplies the + width, height, and color type every later chunk depends on; encountering IDAT or IEND + first means no header has been parsed, so failing clearly here (rather than decoding + pixel data from default/uninitialized header fields) prevents returning an incorrect + surface. + tests: + - PngCodec_Load_IendBeforeIhdr_ThrowsInvalidDataExceptionMentioningIhdr + - id: CanvasNet-Codecs-PngCodec-PngSuiteSupported title: >- The PngCodec class shall successfully load every PngSuite conformance file within its diff --git a/docs/verification/canvas-net/codecs/png-codec.md b/docs/verification/canvas-net/codecs/png-codec.md index 2a23f420..ae5706d1 100644 --- a/docs/verification/canvas-net/codecs/png-codec.md +++ b/docs/verification/canvas-net/codecs/png-codec.md @@ -180,6 +180,14 @@ Builds a valid signature and `IHDR` chunk but omits every chunk that should foll `IEND`), and asserts `Load` throws `InvalidDataException` when the stream ends while looking for the next chunk. +#### CanvasNet-Codecs-PngCodec-LoadChunkBeforeIhdr: Load Rejects a Chunk Preceding IHDR + +**Test**: `PngCodec_Load_IendBeforeIhdr_ThrowsInvalidDataExceptionMentioningIhdr` + +Builds a valid signature followed directly by a well-formed `IEND` chunk (correct CRC-32), with +no `IHDR` chunk present anywhere in the stream, and asserts `Load` throws +`InvalidDataException` with a message naming `IHDR` as the missing chunk. + #### CanvasNet-Codecs-PngCodec-PngSuiteSupported: PngSuite Files Within Scope Load Successfully **Test**: `PngCodec_Load_PngSuiteSupportedFile_ReturnsCanvas` (`[Theory]` over 30 PngSuite files) diff --git a/src/DemaConsulting.CanvasNet/Codecs/JpegCodec.cs b/src/DemaConsulting.CanvasNet/Codecs/JpegCodec.cs index e9a34e33..a88555cf 100644 --- a/src/DemaConsulting.CanvasNet/Codecs/JpegCodec.cs +++ b/src/DemaConsulting.CanvasNet/Codecs/JpegCodec.cs @@ -618,7 +618,7 @@ private static double[][] BuildBasis() var ck = k == 0 ? 1.0 / Math.Sqrt(2.0) : 1.0; for (var n = 0; n < 8; n++) { - basis[k][n] = ck * Math.Cos((2 * n + 1) * k * Math.PI / 16.0); + basis[k][n] = ck * Math.Cos((2.0 * n + 1) * k * Math.PI / 16.0); } } @@ -755,16 +755,7 @@ public static Surface Decode(byte[] file) throw new InvalidDataException("Not a JPEG file (missing SOI marker)."); } - var quantTables = new Dictionary(); - var dcTables = new Dictionary(); - var acTables = new Dictionary(); - Component[]? components = null; - var width = 0; - var height = 0; - var progressive = false; - var restartInterval = 0; - var sofSeen = false; - var sosSeen = false; + var state = new DecodeState(); var pos = 2; while (true) @@ -778,70 +769,120 @@ public static Surface Decode(byte[] file) break; } - switch (marker) - { - case MarkerDqt: - pos = ReadDqt(file, pos, quantTables); - break; + pos = ProcessSegment(file, pos, marker, state); + } - case MarkerDht: - pos = ReadDht(file, pos, dcTables, acTables); - break; + if (!state.SofSeen || !state.SosSeen || state.Components == null) + { + throw new InvalidDataException("JPEG stream is missing a mandatory SOF or SOS segment."); + } - case MarkerDri: - var driLength = ReadUInt16Be(file, pos); - restartInterval = ReadUInt16Be(file, pos + 2); - pos += driLength; - break; + return AssembleCanvas(state.Width, state.Height, state.Components, state.QuantTables); + } - case MarkerSof0: - case MarkerSof2: - if (sofSeen) - { - throw new InvalidDataException("Multiple SOF markers are not supported."); - } + /// + /// Mutable state threaded through while + /// walks the marker segments of a JPEG stream, accumulating the quantization/Huffman + /// tables and frame parameters needed once the SOS-terminated scan is fully decoded. + /// + private sealed class DecodeState + { + /// Quantization tables keyed by table selector, populated by DQT segments. + public Dictionary QuantTables { get; } = []; - progressive = marker == MarkerSof2; - pos = ReadSof(file, pos, out width, out height, out components); - sofSeen = true; - break; + /// DC Huffman tables keyed by table selector, populated by DHT segments. + public Dictionary DcTables { get; } = []; - case MarkerSos: - if (!sofSeen) - { - throw new InvalidDataException("SOS marker encountered before any SOF marker."); - } + /// AC Huffman tables keyed by table selector, populated by DHT segments. + public Dictionary AcTables { get; } = []; - pos = DecodeScan(file, pos, width, height, components!, dcTables, acTables, progressive, restartInterval); - sosSeen = true; - break; + /// The frame's component descriptors, populated by the SOF segment. + public Component[]? Components { get; set; } - case >= 0xD0 and <= 0xD7: - // Stray restart marker outside entropy-coded data; ignore. - break; + /// The frame width in pixels, populated by the SOF segment. + public int Width { get; set; } - case 0xC1 or 0xC3 or 0xC5 or 0xC6 or 0xC7 or 0xC9 or 0xCA or 0xCB or - 0xCD or 0xCE or 0xCF: - throw new InvalidDataException( - $"Unsupported JPEG SOF marker 0x{marker:X2}; only baseline (SOF0) and progressive (SOF2) are supported."); + /// The frame height in pixels, populated by the SOF segment. + public int Height { get; set; } - case 0xC8 or 0xCC: - throw new InvalidDataException("Arithmetic-coded and JPG-extension JPEG variants are not supported."); + /// Whether the frame uses progressive (SOF2) rather than baseline (SOF0) encoding. + public bool Progressive { get; set; } - default: - // APPn, COM, and any other length-prefixed segment we do not act on: skip. - var length = ReadUInt16Be(file, pos); - pos += length; - break; - } - } + /// The restart interval in MCUs, populated by a DRI segment (0 if none seen). + public int RestartInterval { get; set; } + + /// Whether a SOF segment has been seen yet. + public bool SofSeen { get; set; } + + /// Whether a SOS segment has been seen yet. + public bool SosSeen { get; set; } + } - if (!sofSeen || !sosSeen || components == null) + /// + /// Processes a single marker segment encountered by (DQT, DHT, DRI, + /// SOF0/SOF2, SOS, a stray restart marker, an unsupported SOF variant, or a generically + /// skipped length-prefixed segment such as APPn/COM), updating + /// in place and returning the stream position immediately following the segment. + /// + private static int ProcessSegment(byte[] file, int pos, int marker, DecodeState state) + { + switch (marker) { - throw new InvalidDataException("JPEG stream is missing a mandatory SOF or SOS segment."); - } + case MarkerDqt: + return ReadDqt(file, pos, state.QuantTables); + + case MarkerDht: + return ReadDht(file, pos, state.DcTables, state.AcTables); + + case MarkerDri: + var driLength = ReadUInt16Be(file, pos); + state.RestartInterval = ReadUInt16Be(file, pos + 2); + return pos + driLength; + + case MarkerSof0: + case MarkerSof2: + if (state.SofSeen) + { + throw new InvalidDataException("Multiple SOF markers are not supported."); + } + + state.Progressive = marker == MarkerSof2; + var sofPos = ReadSof(file, pos, out var width, out var height, out var components); + state.Width = width; + state.Height = height; + state.Components = components; + state.SofSeen = true; + return sofPos; + + case MarkerSos: + if (!state.SofSeen) + { + throw new InvalidDataException("SOS marker encountered before any SOF marker."); + } + + var scanContext = new ScanDecodeContext( + state.Components!, state.DcTables, state.AcTables, state.Progressive, state.RestartInterval); + var sosPos = DecodeScan(file, pos, state.Width, state.Height, scanContext); + state.SosSeen = true; + return sosPos; + + case >= 0xD0 and <= 0xD7: + // Stray restart marker outside entropy-coded data; ignore. + return pos; + + case 0xC1 or 0xC3 or 0xC5 or 0xC6 or 0xC7 or 0xC9 or 0xCA or 0xCB or + 0xCD or 0xCE or 0xCF: + throw new InvalidDataException( + $"Unsupported JPEG SOF marker 0x{marker:X2}; only baseline (SOF0) and progressive (SOF2) are supported."); - return AssembleCanvas(width, height, components, quantTables); + case 0xC8 or 0xCC: + throw new InvalidDataException("Arithmetic-coded and JPG-extension JPEG variants are not supported."); + + default: + // APPn, COM, and any other length-prefixed segment we do not act on: skip. + var length = ReadUInt16Be(file, pos); + return pos + length; + } } private static int SkipToMarker(byte[] file, int pos) @@ -1023,16 +1064,60 @@ private static int ReadSof(byte[] file, int pos, out int width, out int height, return end; } - private static int DecodeScan( - byte[] file, - int pos, - int frameWidth, - int frameHeight, - Component[] components, - Dictionary dcTables, - Dictionary acTables, - bool progressive, - int restartInterval) + /// + /// The tables and frame-level parameters shared by every scan within a JPEG stream, + /// grouped into a single parameter so does not need to accept + /// each one individually. + /// + private readonly record struct ScanDecodeContext( + Component[] Components, + Dictionary DcTables, + Dictionary AcTables, + bool Progressive, + int RestartInterval); + + /// + /// The component selectors and spectral-selection/successive-approximation parameters + /// parsed from a single SOS segment, produced by . + /// + private readonly record struct ScanHeader(Component[] ScanComponents, int Ss, int Se, int Ah, int Al); + + /// + /// The MCU-grid dimensions shared by every scan in the frame, derived from the maximum + /// component sampling factors and the frame's pixel dimensions. + /// + private readonly record struct McuGrid(int HMax, int VMax, int McusAcross, int McusDown); + + private static int DecodeScan(byte[] file, int pos, int frameWidth, int frameHeight, ScanDecodeContext context) + { + var (header, p) = ParseScanHeader(file, pos, context.Components, context.Progressive); + + // Determine (and allocate, on first use) the MCU-grid dimensions shared by every scan. + var hMax = context.Components.Max(c => c.H); + var vMax = context.Components.Max(c => c.V); + var mcusAcross = (frameWidth + (8 * hMax) - 1) / (8 * hMax); + var mcusDown = (frameHeight + (8 * vMax) - 1) / (8 * vMax); + var grid = new McuGrid(hMax, vMax, mcusAcross, mcusDown); + + EnsureComponentBlocksAllocated(context.Components, mcusAcross, mcusDown); + + foreach (var component in header.ScanComponents) + { + component.DcPredictor = 0; + } + + var reader = new BitReader(file, p); + + return DecodeScanUnits(reader, header, context, frameWidth, frameHeight, grid); + } + + /// + /// Parses a SOS segment's component selectors (assigning each referenced component's + /// DC/AC Huffman table selectors) and spectral-selection/successive-approximation + /// parameters, validating the segment length and baseline spectral-range constraints. + /// + private static (ScanHeader Header, int Position) ParseScanHeader( + byte[] file, int pos, Component[] components, bool progressive) { var length = ReadUInt16Be(file, pos); var p = pos + 2; @@ -1065,56 +1150,62 @@ private static int DecodeScan( throw new InvalidDataException("Baseline JPEG scan must cover the full spectral range with no successive approximation."); } - // Determine (and allocate, on first use) the MCU-grid dimensions shared by every scan. - var hMax = components.Max(c => c.H); - var vMax = components.Max(c => c.V); - var mcusAcross = (frameWidth + (8 * hMax) - 1) / (8 * hMax); - var mcusDown = (frameHeight + (8 * vMax) - 1) / (8 * vMax); + return (new ScanHeader(scanComponents, ss, se, ah, al), p); + } - foreach (var component in components) + /// + /// Allocates each component's block storage the first time it is referenced by any + /// scan, sized to the shared MCU grid so later scans referencing the same component + /// reuse the same block array. + /// + private static void EnsureComponentBlocksAllocated(Component[] components, int mcusAcross, int mcusDown) + { + foreach (var component in components.Where(component => component.Blocks == null)) { - if (component.Blocks == null) + component.BlocksPerLineMcu = mcusAcross * component.H; + component.BlocksPerColumnMcu = mcusDown * component.V; + var count = component.BlocksPerLineMcu * component.BlocksPerColumnMcu; + component.Blocks = new int[count][]; + for (var i = 0; i < count; i++) { - component.BlocksPerLineMcu = mcusAcross * component.H; - component.BlocksPerColumnMcu = mcusDown * component.V; - var count = component.BlocksPerLineMcu * component.BlocksPerColumnMcu; - component.Blocks = new int[count][]; - for (var i = 0; i < count; i++) - { - component.Blocks[i] = new int[64]; - } + component.Blocks[i] = new int[64]; } } + } - foreach (var component in scanComponents) - { - component.DcPredictor = 0; - } - - var reader = new BitReader(file, p); + /// + /// Decodes every MCU (interleaved scan) or block (non-interleaved scan) in the scan, + /// honoring restart markers at the configured restart interval, and returns the stream + /// position immediately following the last decoded unit. + /// + private static int DecodeScanUnits( + BitReader reader, ScanHeader header, ScanDecodeContext context, int frameWidth, int frameHeight, McuGrid grid) + { var eobRun = 0; - - var interleaved = numComponentsInScan > 1; + var scanComponents = header.ScanComponents; + var interleaved = scanComponents.Length > 1; int totalUnits; int nonInterleavedBlocksPerLine = 0; if (interleaved) { - totalUnits = mcusAcross * mcusDown; + totalUnits = grid.McusAcross * grid.McusDown; } else { var comp = scanComponents[0]; - var compSamplesPerLine = ((frameWidth * comp.H) + hMax - 1) / hMax; - var compSamplesPerColumn = ((frameHeight * comp.V) + vMax - 1) / vMax; + var compSamplesPerLine = ((frameWidth * comp.H) + grid.HMax - 1) / grid.HMax; + var compSamplesPerColumn = ((frameHeight * comp.V) + grid.VMax - 1) / grid.VMax; nonInterleavedBlocksPerLine = (compSamplesPerLine + 7) / 8; var blocksPerColumn = (compSamplesPerColumn + 7) / 8; totalUnits = nonInterleavedBlocksPerLine * blocksPerColumn; } var unitsSinceRestart = 0; + var blockContext = new BlockDecodeContext( + context.DcTables, context.AcTables, context.Progressive, header.Ss, header.Se, header.Ah, header.Al); for (var unit = 0; unit < totalUnits; unit++) { - if (restartInterval > 0 && unitsSinceRestart == restartInterval) + if (context.RestartInterval > 0 && unitsSinceRestart == context.RestartInterval) { reader.Realign(); reader.ExpectRestartMarker(); @@ -1129,22 +1220,7 @@ private static int DecodeScan( if (interleaved) { - var mcuX = unit % mcusAcross; - var mcuY = unit / mcusAcross; - foreach (var component in scanComponents) - { - for (var v = 0; v < component.V; v++) - { - for (var h = 0; h < component.H; h++) - { - var blockCol = (mcuX * component.H) + h; - var blockRow = (mcuY * component.V) + v; - var block = component.Blocks![(blockRow * component.BlocksPerLineMcu) + blockCol]; - DecodeBlock( - block, component, reader, dcTables, acTables, progressive, ss, se, ah, al, ref eobRun); - } - } - } + DecodeInterleavedUnit(unit, grid.McusAcross, scanComponents, reader, blockContext, ref eobRun); } else { @@ -1152,7 +1228,7 @@ private static int DecodeScan( var blockCol = unit % nonInterleavedBlocksPerLine; var blockRow = unit / nonInterleavedBlocksPerLine; var block = comp.Blocks![(blockRow * comp.BlocksPerLineMcu) + blockCol]; - DecodeBlock(block, comp, reader, dcTables, acTables, progressive, ss, se, ah, al, ref eobRun); + DecodeBlock(block, comp, reader, blockContext, ref eobRun); } unitsSinceRestart++; @@ -1161,89 +1237,145 @@ private static int DecodeScan( return reader.Position; } - private static void DecodeBlock( - int[] block, - Component component, + /// + /// Decodes every block of every scan component making up a single interleaved MCU at + /// the given MCU index. + /// + private static void DecodeInterleavedUnit( + int unit, + int mcusAcross, + Component[] scanComponents, BitReader reader, - Dictionary dcTables, - Dictionary acTables, - bool progressive, - int ss, - int se, - int ah, - int al, + BlockDecodeContext blockContext, ref int eobRun) { - if (!progressive) + var mcuX = unit % mcusAcross; + var mcuY = unit / mcusAcross; + foreach (var component in scanComponents) { - var dcTable = GetTable(dcTables, component.DcSelector, "DC"); - var acTable = GetTable(acTables, component.AcSelector, "AC"); - - var t = BitReader.Decode(dcTable, reader); - var diff = reader.Receive(t); - component.DcPredictor += diff; - block[0] = component.DcPredictor; - - var k = 1; - while (k < 64) + for (var v = 0; v < component.V; v++) { - var rs = BitReader.Decode(acTable, reader); - var r = rs >> 4; - var s = rs & 0xF; - if (s == 0) + for (var h = 0; h < component.H; h++) { - if (r != 15) - { - break; - } - - k += 16; - } - else - { - k += r; - if (k > 63) - { - throw new InvalidDataException("Malformed JPEG entropy-coded data: AC coefficient index out of range."); - } - - block[k] = reader.Receive(s); - k++; + var blockCol = (mcuX * component.H) + h; + var blockRow = (mcuY * component.V) + v; + var block = component.Blocks![(blockRow * component.BlocksPerLineMcu) + blockCol]; + DecodeBlock(block, component, reader, blockContext, ref eobRun); } } + } + } + /// + /// The Huffman tables and spectral-selection/successive-approximation parameters + /// shared by every block decoded within a single scan, grouped into a single parameter + /// so does not need to accept each one individually. + /// + private readonly record struct BlockDecodeContext( + Dictionary DcTables, + Dictionary AcTables, + bool Progressive, + int Ss, + int Se, + int Ah, + int Al); + + private static void DecodeBlock(int[] block, Component component, BitReader reader, BlockDecodeContext context, ref int eobRun) + { + if (!context.Progressive) + { + DecodeBaselineBlock(block, component, reader, context.DcTables, context.AcTables); + return; + } + + if (context.Ss == 0) + { + DecodeProgressiveDc(block, component, reader, context.DcTables, context.Ah, context.Al); return; } - if (ss == 0) + var acTableProgressive = GetTable(context.AcTables, component.AcSelector, "AC"); + if (context.Ah == 0) { - if (ah == 0) + DecodeAcFirst(block, acTableProgressive, reader, context.Ss, context.Se, context.Al, ref eobRun); + } + else + { + DecodeAcRefine(block, acTableProgressive, reader, context.Ss, context.Se, context.Al, ref eobRun); + } + } + + /// + /// Decodes a single baseline (sequential DCT) block: the DC coefficient via + /// differential prediction, followed by all non-zero AC coefficients in zigzag order + /// up to the first end-of-block run or index 63. + /// + private static void DecodeBaselineBlock( + int[] block, + Component component, + BitReader reader, + Dictionary dcTables, + Dictionary acTables) + { + var dcTable = GetTable(dcTables, component.DcSelector, "DC"); + var acTable = GetTable(acTables, component.AcSelector, "AC"); + + var t = BitReader.Decode(dcTable, reader); + var diff = reader.Receive(t); + component.DcPredictor += diff; + block[0] = component.DcPredictor; + + var k = 1; + while (k < 64) + { + var rs = BitReader.Decode(acTable, reader); + var r = rs >> 4; + var s = rs & 0xF; + if (s == 0) { - var dcTable = GetTable(dcTables, component.DcSelector, "DC"); - var t = BitReader.Decode(dcTable, reader); - var diff = reader.Receive(t); - component.DcPredictor += diff; - block[0] = component.DcPredictor << al; + if (r != 15) + { + break; + } + + k += 16; } else { - if (reader.ReadBit() != 0) + k += r; + if (k > 63) { - block[0] |= 1 << al; + throw new InvalidDataException("Malformed JPEG entropy-coded data: AC coefficient index out of range."); } - } - return; + block[k] = reader.Receive(s); + k++; + } } + } - var acTableProgressive = GetTable(acTables, component.AcSelector, "AC"); + /// + /// Decodes the DC coefficient of a progressive-scan block: a full Huffman-coded + /// magnitude/diff on the first DC scan (successive approximation high bit), or a + /// single successive-approximation refinement bit on any later DC scan. + /// + private static void DecodeProgressiveDc( + int[] block, Component component, BitReader reader, Dictionary dcTables, int ah, int al) + { if (ah == 0) { - DecodeAcFirst(block, acTableProgressive, reader, ss, se, al, ref eobRun); + var dcTable = GetTable(dcTables, component.DcSelector, "DC"); + var t = BitReader.Decode(dcTable, reader); + var diff = reader.Receive(t); + component.DcPredictor += diff; + block[0] = component.DcPredictor << al; } else { - DecodeAcRefine(block, acTableProgressive, reader, ss, se, al, ref eobRun); + if (reader.ReadBit() != 0) + { + block[0] |= 1 << al; + } } } @@ -1298,71 +1430,117 @@ private static void DecodeAcRefine(int[] block, HuffmanTable acTable, BitReader if (eobRun == 0) { - while (k <= se) - { - var rs = BitReader.Decode(acTable, reader); - var r = rs >> 4; - var s = rs & 0xF; - var newValue = 0; + DecodeAcRefineNewCoefficients(block, acTable, reader, se, p1, m1, ref k, ref eobRun); + } - if (s == 0) - { - if (r < 15) - { - eobRun = 1 << r; - if (r > 0) - { - eobRun += reader.ReadBits(r); - } + if (eobRun > 0) + { + RefineRemainingCoefficients(block, ref k, se, reader, p1, m1); + eobRun--; + } + } - r = 64; // sentinel: skip remaining coefficients (refinement only) below - } - } - else - { - newValue = reader.ReadBit() != 0 ? p1 : m1; - } + /// + /// Applies a successive-approximation refinement bit to a single already-nonzero + /// coefficient, per ITU-T T.81 section G.1.2.3: the bit is only consumed (and the + /// coefficient only nudged toward zero-away) when the coefficient's next refinement + /// bit position is still unset. + /// + private static void RefineNonZeroCoefficient(int[] block, int k, BitReader reader, int p1, int m1) + { + if (block[k] != 0 && reader.ReadBit() != 0 && (block[k] & p1) == 0) + { + block[k] += block[k] >= 0 ? p1 : m1; + } + } - while (k <= se) + /// + /// Refines every remaining nonzero coefficient from through + /// without placing any new coefficients, used while an + /// end-of-band run inherited from an earlier RS pair is still being consumed. + /// + private static void RefineRemainingCoefficients(int[] block, ref int k, int se, BitReader reader, int p1, int m1) + { + while (k <= se) + { + RefineNonZeroCoefficient(block, k, reader, p1, m1); + k++; + } + } + + /// + /// Decodes RS (run-length/size) pairs from the AC refinement scan, each of which + /// either starts a new end-of-band run (terminating this method) or specifies how many + /// zero coefficients to skip before placing one new nonzero coefficient; every + /// already-nonzero coefficient encountered along the way is refined per + /// . + /// + private static void DecodeAcRefineNewCoefficients( + int[] block, HuffmanTable acTable, BitReader reader, int se, int p1, int m1, ref int k, ref int eobRun) + { + while (k <= se) + { + var rs = BitReader.Decode(acTable, reader); + var r = rs >> 4; + var s = rs & 0xF; + var newValue = 0; + + if (s == 0) + { + if (r < 15) { - if (block[k] != 0 && reader.ReadBit() != 0 && (block[k] & p1) == 0) - { - block[k] += block[k] >= 0 ? p1 : m1; - } - else if (block[k] == 0) + eobRun = 1 << r; + if (r > 0) { - if (r == 0) - { - if (newValue != 0) - { - block[k] = newValue; - } - - k++; - break; - } - - r--; + eobRun += reader.ReadBits(r); } - k++; + r = 64; // sentinel: skip remaining coefficients (refinement only) below } } + else + { + newValue = reader.ReadBit() != 0 ? p1 : m1; + } + + RefineOrPlaceCoefficient(block, ref k, se, reader, p1, m1, r, newValue); } + } - if (eobRun > 0) + /// + /// Walks coefficients from through , + /// refining every already-nonzero coefficient, and counting down + /// zero coefficients before placing + /// (if nonzero) into the next zero coefficient slot and + /// stopping. + /// + private static void RefineOrPlaceCoefficient( + int[] block, ref int k, int se, BitReader reader, int p1, int m1, int zeroRunLength, int newValue) + { + var r = zeroRunLength; + while (k <= se) { - while (k <= se) + if (block[k] != 0) + { + RefineNonZeroCoefficient(block, k, reader, p1, m1); + } + else { - if (block[k] != 0 && reader.ReadBit() != 0 && (block[k] & p1) == 0) + if (r == 0) { - block[k] += block[k] >= 0 ? p1 : m1; + if (newValue != 0) + { + block[k] = newValue; + } + + k++; + break; } - k++; + r--; } - eobRun--; + k++; } } @@ -1415,11 +1593,38 @@ private static double[][] Dequantize(int[] zigzagCoeffs, int[] quantZigzag) return natural; } + /// + /// The reconstructed spatial-domain sample plane for each frame component, alongside + /// each plane's row stride (which may exceed the frame width when the component's + /// MCU-aligned block grid is wider than the actual image). + /// + private readonly record struct ComponentPlanes(byte[][] Planes, int[] Strides); + private static Surface AssembleCanvas(int width, int height, Component[] components, Dictionary quantTables) { var hMax = components.Max(c => c.H); var vMax = components.Max(c => c.V); + var planes = ReconstructComponentPlanes(components, quantTables); + + var surface = new Surface(width, height); + + if (components.Length == 1) + { + WriteGrayscaleSurface(surface, planes.Planes[0], planes.Strides[0], width, height); + return surface; + } + + WriteColorSurface(surface, components, planes, width, height, hMax, vMax); + return surface; + } + + /// + /// Dequantizes and inverse-DCTs every block of every frame component, assembling each + /// component's blocks into a single MCU-aligned spatial-domain sample plane. + /// + private static ComponentPlanes ReconstructComponentPlanes(Component[] components, Dictionary quantTables) + { var planes = new byte[components.Length][]; var planeStrides = new int[components.Length]; @@ -1436,60 +1641,78 @@ private static Surface AssembleCanvas(int width, int height, Component[] compone var plane = new byte[planeWidth * planeHeight]; planeStrides[ci] = planeWidth; - for (var blockRow = 0; blockRow < component.BlocksPerColumnMcu; blockRow++) - { - for (var blockCol = 0; blockCol < component.BlocksPerLineMcu; blockCol++) - { - var block = component.Blocks![(blockRow * component.BlocksPerLineMcu) + blockCol]; - var natural = Dequantize(block, quant); - var spatial = Idct2D(natural); - - for (var y = 0; y < 8; y++) - { - var rowOffset = ((blockRow * 8) + y) * planeWidth + (blockCol * 8); - for (var x = 0; x < 8; x++) - { - plane[rowOffset + x] = ClampToByte(spatial[y][x] + 128.0); - } - } - } - } + ReconstructComponentBlocks(component, quant, plane, planeWidth); planes[ci] = plane; } - var surface = new Surface(width, height); + return new ComponentPlanes(planes, planeStrides); + } - if (components.Length == 1) + /// + /// Dequantizes, inverse-DCTs, and level-shifts every 8x8 block of a single component + /// into its destination position within the component's spatial-domain sample plane. + /// + private static void ReconstructComponentBlocks(Component component, int[] quant, byte[] plane, int planeWidth) + { + for (var blockRow = 0; blockRow < component.BlocksPerColumnMcu; blockRow++) { - var plane = planes[0]; - var stride = planeStrides[0]; - for (var y = 0; y < height; y++) + for (var blockCol = 0; blockCol < component.BlocksPerLineMcu; blockCol++) { - var rowBytes = surface.GetRowSpanBytes(y); - var srcRow = y * stride; - for (var x = 0; x < width; x++) + var block = component.Blocks![(blockRow * component.BlocksPerLineMcu) + blockCol]; + var natural = Dequantize(block, quant); + var spatial = Idct2D(natural); + + for (var y = 0; y < 8; y++) { - var v = plane[srcRow + x]; - var idx = x * 4; - rowBytes[idx] = v; - rowBytes[idx + 1] = v; - rowBytes[idx + 2] = v; - rowBytes[idx + 3] = 255; + var rowOffset = ((blockRow * 8) + y) * planeWidth + (blockCol * 8); + for (var x = 0; x < 8; x++) + { + plane[rowOffset + x] = ClampToByte(spatial[y][x] + 128.0); + } } } + } + } - return surface; + /// + /// Writes a single-component (grayscale) plane into the destination surface, expanding + /// each sample to an opaque R == G == B pixel. + /// + private static void WriteGrayscaleSurface(Surface surface, byte[] plane, int stride, int width, int height) + { + for (var y = 0; y < height; y++) + { + var rowBytes = surface.GetRowSpanBytes(y); + var srcRow = y * stride; + for (var x = 0; x < width; x++) + { + var v = plane[srcRow + x]; + var idx = x * 4; + rowBytes[idx] = v; + rowBytes[idx + 1] = v; + rowBytes[idx + 2] = v; + rowBytes[idx + 3] = 255; + } } + } + /// + /// Writes a three-component (Y/Cb/Cr) set of planes into the destination surface, + /// upsampling any subsampled chroma planes to full resolution and converting each row + /// to opaque RGB. + /// + private static void WriteColorSurface( + Surface surface, Component[] components, ComponentPlanes planes, int width, int height, int hMax, int vMax) + { var cbComp = components[1]; var crComp = components[2]; - var yPlane = planes[0]; - var cbPlane = planes[1]; - var crPlane = planes[2]; - var yStride = planeStrides[0]; - var cbStride = planeStrides[1]; - var crStride = planeStrides[2]; + var yPlane = planes.Planes[0]; + var cbPlane = planes.Planes[1]; + var crPlane = planes.Planes[2]; + var yStride = planes.Strides[0]; + var cbStride = planes.Strides[1]; + var crStride = planes.Strides[2]; var yRow = new byte[width]; var cbRow = new byte[width]; @@ -1523,8 +1746,6 @@ private static Surface AssembleCanvas(int width, int height, Component[] compone rowBytes[idx + 3] = 255; } } - - return surface; } } @@ -1612,9 +1833,11 @@ public static void Encode(Surface surface, Stream stream, int quality) var paddedWidth = mcusAcross * 16; var paddedHeight = mcusDown * 16; - BuildPlanes( - surface, width, height, paddedWidth, paddedHeight, - out var yPlane, out var cbPlane, out var crPlane, out var chromaWidth); + var planes = BuildPlanes(surface, new PlaneDimensions(width, height, paddedWidth, paddedHeight)); + var yPlane = planes.Y; + var cbPlane = planes.Cb; + var crPlane = planes.Cr; + var chromaWidth = planes.ChromaWidth; WriteSoi(stream); WriteDqt(stream, 0, lumaQuantZigZag); @@ -1701,10 +1924,27 @@ private static Dictionary BuildEncodeTable(byte[] bits, return result; } - private static void BuildPlanes( - Surface surface, int width, int height, int paddedWidth, int paddedHeight, - out byte[] yPlane, out byte[] cbPlane, out byte[] crPlane, out int chromaWidth) + /// + /// The source image dimensions together with the MCU-padded dimensions used for + /// chroma subsampling, grouped into a single parameter so + /// does not need to accept each one individually. + /// + private readonly record struct PlaneDimensions(int Width, int Height, int PaddedWidth, int PaddedHeight); + + /// + /// The Y/Cb/Cr sample planes produced by : a full-resolution + /// luma plane and 2x2 box-downsampled, MCU-padded chroma planes, alongside the chroma + /// planes' shared row stride. + /// + private readonly record struct PlaneSet(byte[] Y, byte[] Cb, byte[] Cr, int ChromaWidth); + + private static PlaneSet BuildPlanes(Surface surface, PlaneDimensions dimensions) { + var width = dimensions.Width; + var height = dimensions.Height; + var paddedWidth = dimensions.PaddedWidth; + var paddedHeight = dimensions.PaddedHeight; + var fullY = new byte[width * height]; var fullCb = new byte[width * height]; var fullCr = new byte[width * height]; @@ -1723,15 +1963,15 @@ private static void BuildPlanes( } } - yPlane = PadReplicate(fullY, width, height, paddedWidth, paddedHeight); + var yPlane = PadReplicate(fullY, width, height, paddedWidth, paddedHeight); var paddedCb = PadReplicate(fullCb, width, height, paddedWidth, paddedHeight); var paddedCr = PadReplicate(fullCr, width, height, paddedWidth, paddedHeight); - chromaWidth = paddedWidth / 2; - var chromaHeight = paddedHeight / 2; - cbPlane = DownsampleBox2X2(paddedCb, paddedWidth, paddedHeight); - crPlane = DownsampleBox2X2(paddedCr, paddedWidth, paddedHeight); - _ = chromaHeight; + var chromaWidth = paddedWidth / 2; + var cbPlane = DownsampleBox2X2(paddedCb, paddedWidth, paddedHeight); + var crPlane = DownsampleBox2X2(paddedCr, paddedWidth, paddedHeight); + + return new PlaneSet(yPlane, cbPlane, crPlane, chromaWidth); } private static byte[] PadReplicate(byte[] source, int width, int height, int paddedWidth, int paddedHeight) diff --git a/src/DemaConsulting.CanvasNet/Codecs/PngCodec.cs b/src/DemaConsulting.CanvasNet/Codecs/PngCodec.cs index 32096c33..44623545 100644 --- a/src/DemaConsulting.CanvasNet/Codecs/PngCodec.cs +++ b/src/DemaConsulting.CanvasNet/Codecs/PngCodec.cs @@ -161,6 +161,60 @@ public static Surface Load(Stream stream) // Validate the fixed 8-byte PNG signature before attempting to interpret anything else // as chunk data + ValidateSignature(stream); + + var header = ReadChunks(stream, out var idatData); + + var channels = header.ColorType == (int)PngColorType.Rgba ? 4 : 3; + var rowBytes = header.Width * channels; + var rawData = ZlibDecompress(idatData); + var expectedRawLength = (long)(rowBytes + 1) * header.Height; + if (rawData.LongLength != expectedRawLength) + { + throw new InvalidDataException( + "PNG scanline data has an unexpected length (corrupt or truncated image data)."); + } + + return DecodeScanlines(rawData, header.Width, header.Height, channels); + } + + /// + /// Loads a from a PNG file at the specified path. + /// + /// The path of the PNG file to load. Must not be null or empty. + /// + /// A new containing the decoded pixels; see + /// for the decoding contract. + /// + /// Thrown when is null. + /// Thrown when is an empty string. + /// + /// Thrown for the same malformed/unsupported-format conditions as . + /// + /// + /// File-system exceptions (for example , + /// , , + /// or ) raised while opening propagate + /// uncaught to the caller. + /// + public static Surface Load(string path) + { + ArgumentNullException.ThrowIfNull(path); + if (path.Length == 0) + { + throw new ArgumentException("Path must not be empty.", nameof(path)); + } + + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read); + return Load(stream); + } + + /// + /// Validates that begins with the fixed 8-byte PNG signature, + /// consuming exactly those 8 bytes. + /// + private static void ValidateSignature(Stream stream) + { var signature = ReadExactly(stream, Signature.Length, "PNG signature"); for (var i = 0; i < Signature.Length; i++) { @@ -169,89 +223,131 @@ public static Surface Load(Stream stream) throw new InvalidDataException("Not a PNG file (missing PNG signature)."); } } + } + + /// + /// The width, height, and color type parsed from a PNG's IHDR chunk, produced by + /// once the chunk stream has been fully consumed through + /// IEND. + /// + private readonly record struct PngHeader(int Width, int Height, int ColorType); + + /// + /// Tracks whether the mandatory IHDR chunk has been seen and accumulates the + /// header's field values as chunks are read, threaded through + /// while walks the chunk stream. + /// + private sealed class ChunkReadState + { + /// Whether the mandatory IHDR chunk has been seen yet. + public bool IhdrSeen { get; set; } + + /// Whether the mandatory IEND chunk has been seen yet. + public bool IendSeen { get; set; } + + /// The image width in pixels, populated by the IHDR chunk. + public int Width { get; set; } + + /// The image height in pixels, populated by the IHDR chunk. + public int Height { get; set; } + + /// The PNG color type, populated by the IHDR chunk. + public int ColorType { get; set; } + } - var ihdrSeen = false; - var iendSeen = false; - var width = 0; - var height = 0; - var colorType = 0; + /// + /// Reads and validates every chunk from until (and including) + /// IEND, accumulating IDAT payload bytes into + /// and returning the parsed IHDR fields. + /// + private static PngHeader ReadChunks(Stream stream, out byte[] idatData) + { + var state = new ChunkReadState(); using var idatStream = new MemoryStream(); // Read chunks until IEND is encountered; ReadExactly throws InvalidDataException if the // stream ends before IEND is found, which correctly rejects a truncated stream - while (!iendSeen) + while (!state.IendSeen) { - var lengthBytes = ReadExactly(stream, 4, "chunk length"); - var length = ReadUInt32Be(lengthBytes, 0); - if (length > int.MaxValue) - { - throw new InvalidDataException("Chunk length exceeds the supported range."); - } + ProcessChunk(stream, idatStream, state); + } + + idatData = idatStream.ToArray(); + return new PngHeader(state.Width, state.Height, state.ColorType); + } + + /// + /// Reads, CRC-validates, and dispatches a single chunk: IHDR populates + /// 's header fields, IDAT data is appended to + /// , IEND marks the chunk stream complete, and any + /// other chunk type is validated but otherwise skipped. + /// + private static void ProcessChunk(Stream stream, MemoryStream idatStream, ChunkReadState state) + { + var lengthBytes = ReadExactly(stream, 4, "chunk length"); + var length = ReadUInt32Be(lengthBytes, 0); + if (length > int.MaxValue) + { + throw new InvalidDataException("Chunk length exceeds the supported range."); + } - var typeBytes = ReadExactly(stream, 4, "chunk type"); - var data = length == 0 ? [] : ReadExactly(stream, (int)length, "chunk data"); - var crcBytes = ReadExactly(stream, 4, "chunk CRC"); - var expectedCrc = ReadUInt32Be(crcBytes, 0); - - // The CRC-32 covers the chunk type and data, but not the length field itself - var crcInput = new byte[4 + data.Length]; - typeBytes.CopyTo(crcInput, 0); - data.CopyTo(crcInput, 4); - var actualCrc = ComputeCrc32(crcInput); - if (actualCrc != expectedCrc) + var typeBytes = ReadExactly(stream, 4, "chunk type"); + var data = length == 0 ? [] : ReadExactly(stream, (int)length, "chunk data"); + var crcBytes = ReadExactly(stream, 4, "chunk CRC"); + var expectedCrc = ReadUInt32Be(crcBytes, 0); + + // The CRC-32 covers the chunk type and data, but not the length field itself + var crcInput = new byte[4 + data.Length]; + typeBytes.CopyTo(crcInput, 0); + data.CopyTo(crcInput, 4); + var actualCrc = ComputeCrc32(crcInput); + if (actualCrc != expectedCrc) + { + throw new InvalidDataException("Corrupt PNG chunk (CRC-32 mismatch)."); + } + + if (ChunkTypeIs(typeBytes, "IHDR")) + { + if (state.IhdrSeen) { - throw new InvalidDataException("Corrupt PNG chunk (CRC-32 mismatch)."); + throw new InvalidDataException("Duplicate IHDR chunk."); } - if (ChunkTypeIs(typeBytes, "IHDR")) + (state.Width, state.Height, state.ColorType) = ParseIhdr(data); + state.IhdrSeen = true; + } + else if (ChunkTypeIs(typeBytes, "IDAT")) + { + if (!state.IhdrSeen) { - if (ihdrSeen) - { - throw new InvalidDataException("Duplicate IHDR chunk."); - } - - (width, height, colorType) = ParseIhdr(data); - ihdrSeen = true; + throw new InvalidDataException("IDAT chunk encountered before IHDR."); } - else if (ChunkTypeIs(typeBytes, "IDAT")) - { - if (!ihdrSeen) - { - throw new InvalidDataException("IDAT chunk encountered before IHDR."); - } - idatStream.Write(data, 0, data.Length); - } - else if (ChunkTypeIs(typeBytes, "IEND")) + idatStream.Write(data, 0, data.Length); + } + else if (ChunkTypeIs(typeBytes, "IEND")) + { + if (!state.IhdrSeen) { - if (!ihdrSeen) - { - throw new InvalidDataException("IEND chunk encountered before IHDR."); - } - - iendSeen = true; + throw new InvalidDataException("IEND chunk encountered before IHDR."); } - // Any other chunk type (for example "tEXt", "pHYs", "gAMA") is an ancillary chunk - // this codec does not need; its CRC-32 has already been validated above, and its - // data is simply not accumulated anywhere, effectively skipping it + state.IendSeen = true; } - if (!ihdrSeen) - { - throw new InvalidDataException("Missing IHDR chunk."); - } + // Any other chunk type (for example "tEXt", "pHYs", "gAMA") is an ancillary chunk + // this codec does not need; its CRC-32 has already been validated above, and its + // data is simply not accumulated anywhere, effectively skipping it + } - var channels = colorType == (int)PngColorType.Rgba ? 4 : 3; + /// + /// Defilters and unpacks every scanline of decompressed PNG raw data into a new + /// , reconstructing each row from the previous row per the PNG + /// filtering specification. + /// + private static Surface DecodeScanlines(byte[] rawData, int width, int height, int channels) + { var rowBytes = width * channels; - var rawData = ZlibDecompress(idatStream.ToArray()); - var expectedRawLength = (long)(rowBytes + 1) * height; - if (rawData.LongLength != expectedRawLength) - { - throw new InvalidDataException( - "PNG scanline data has an unexpected length (corrupt or truncated image data)."); - } - var surface = new Surface(width, height); var previousRow = new byte[rowBytes]; var currentRow = new byte[rowBytes]; @@ -275,37 +371,6 @@ public static Surface Load(Stream stream) return surface; } - /// - /// Loads a from a PNG file at the specified path. - /// - /// The path of the PNG file to load. Must not be null or empty. - /// - /// A new containing the decoded pixels; see - /// for the decoding contract. - /// - /// Thrown when is null. - /// Thrown when is an empty string. - /// - /// Thrown for the same malformed/unsupported-format conditions as . - /// - /// - /// File-system exceptions (for example , - /// , , - /// or ) raised while opening propagate - /// uncaught to the caller. - /// - public static Surface Load(string path) - { - ArgumentNullException.ThrowIfNull(path); - if (path.Length == 0) - { - throw new ArgumentException("Path must not be empty.", nameof(path)); - } - - using var stream = new FileStream(path, FileMode.Open, FileAccess.Read); - return Load(stream); - } - /// /// Saves a to a stream as a PNG image. /// @@ -566,41 +631,19 @@ private static void DefilterRow( break; case 1: // Sub - for (var i = 0; i < filtered.Length; i++) - { - int left = i >= bpp ? output[i - bpp] : 0; - output[i] = (byte)(filtered[i] + left); - } - + DefilterSub(filtered, output, bpp); break; case 2: // Up - for (var i = 0; i < filtered.Length; i++) - { - output[i] = (byte)(filtered[i] + previousRow[i]); - } - + DefilterUp(filtered, previousRow, output); break; case 3: // Average - for (var i = 0; i < filtered.Length; i++) - { - int left = i >= bpp ? output[i - bpp] : 0; - int up = previousRow[i]; - output[i] = (byte)(filtered[i] + (left + up) / 2); - } - + DefilterAverage(filtered, previousRow, output, bpp); break; case 4: // Paeth - for (var i = 0; i < filtered.Length; i++) - { - int left = i >= bpp ? output[i - bpp] : 0; - int up = previousRow[i]; - int upperLeft = i >= bpp ? previousRow[i - bpp] : 0; - output[i] = (byte)(filtered[i] + PaethPredictor(left, up, upperLeft)); - } - + DefilterPaeth(filtered, previousRow, output, bpp); break; default: @@ -609,6 +652,56 @@ private static void DefilterRow( } } + /// Reconstructs a scanline filtered with PNG filter type 1 (Sub). + private static void DefilterSub(ReadOnlySpan filtered, Span output, int bpp) + { + for (var i = 0; i < filtered.Length; i++) + { + int left = i >= bpp ? output[i - bpp] : 0; + output[i] = (byte)(filtered[i] + left); + } + } + + /// Reconstructs a scanline filtered with PNG filter type 2 (Up). + private static void DefilterUp(ReadOnlySpan filtered, ReadOnlySpan previousRow, Span output) + { + for (var i = 0; i < filtered.Length; i++) + { + output[i] = (byte)(filtered[i] + previousRow[i]); + } + } + + /// Reconstructs a scanline filtered with PNG filter type 3 (Average). + private static void DefilterAverage( + ReadOnlySpan filtered, + ReadOnlySpan previousRow, + Span output, + int bpp) + { + for (var i = 0; i < filtered.Length; i++) + { + int left = i >= bpp ? output[i - bpp] : 0; + int up = previousRow[i]; + output[i] = (byte)(filtered[i] + (left + up) / 2); + } + } + + /// Reconstructs a scanline filtered with PNG filter type 4 (Paeth). + private static void DefilterPaeth( + ReadOnlySpan filtered, + ReadOnlySpan previousRow, + Span output, + int bpp) + { + for (var i = 0; i < filtered.Length; i++) + { + int left = i >= bpp ? output[i - bpp] : 0; + int up = previousRow[i]; + int upperLeft = i >= bpp ? previousRow[i - bpp] : 0; + output[i] = (byte)(filtered[i] + PaethPredictor(left, up, upperLeft)); + } + } + /// /// Computes the PNG Paeth predictor value for a pixel from its left, upper, and /// upper-left neighbor byte values. diff --git a/src/DemaConsulting.CanvasNet/Codecs/TiffCodec.cs b/src/DemaConsulting.CanvasNet/Codecs/TiffCodec.cs index d96b7394..e42c7ced 100644 --- a/src/DemaConsulting.CanvasNet/Codecs/TiffCodec.cs +++ b/src/DemaConsulting.CanvasNet/Codecs/TiffCodec.cs @@ -216,7 +216,57 @@ public static Surface Load(Stream stream) ArgumentNullException.ThrowIfNull(stream); var file = ReadAllBytes(stream); + var (bigEndian, ifdOffset) = ParseTiffHeader(file); + var tags = ParseIfd(file, ifdOffset, bigEndian); + + if (tags.ContainsKey(TagTileWidth) || tags.ContainsKey(TagTileLength)) + { + throw new InvalidDataException("Tiled TIFF images are not supported; only strip-based images are supported."); + } + + var info = ReadTiffImageInfo(file, tags, bigEndian); + + return DecodeStrips(file, tags, info, bigEndian); + } + + + /// + /// Loads a from a TIFF file at the specified path. + /// + /// The path of the TIFF file to load. Must not be null or empty. + /// + /// A new containing the decoded pixels; see + /// for the decoding contract. + /// + /// Thrown when is null. + /// Thrown when is an empty string. + /// + /// Thrown for the same malformed/unsupported-format conditions as . + /// + /// + /// File-system exceptions (for example , + /// , , + /// or ) raised while opening propagate + /// uncaught to the caller. + /// + public static Surface Load(string path) + { + ArgumentNullException.ThrowIfNull(path); + if (path.Length == 0) + { + throw new ArgumentException("Path must not be empty.", nameof(path)); + } + + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read); + return Load(stream); + } + /// + /// Reads and validates the 8-byte TIFF file header (byte-order mark and magic number), + /// returning the detected endianness and the offset of the first Image File Directory. + /// + private static (bool BigEndian, uint IfdOffset) ParseTiffHeader(byte[] file) + { if (file.Length < 8) { throw new InvalidDataException("Unexpected end of stream while reading the TIFF header."); @@ -243,13 +293,29 @@ public static Surface Load(Stream stream) } var ifdOffset = ReadUInt32(file, 4, bigEndian); - var tags = ParseIfd(file, ifdOffset, bigEndian); + return (bigEndian, ifdOffset); + } - if (tags.ContainsKey(TagTileWidth) || tags.ContainsKey(TagTileLength)) - { - throw new InvalidDataException("Tiled TIFF images are not supported; only strip-based images are supported."); - } + /// + /// The subset of TIFF IFD tag values needed to decode strip-based pixel data, parsed and + /// validated up front by . + /// + private readonly record struct TiffImageInfo( + int Width, + int Height, + int SamplesPerPixel, + TiffCompression Compression, + int Photometric, + int PlanarConfiguration, + int Predictor); + /// + /// Reads and validates the image-level TIFF tags (dimensions, bits per sample, samples + /// per pixel, compression, photometric interpretation, planar configuration, and + /// predictor), throwing for any unsupported value. + /// + private static TiffImageInfo ReadTiffImageInfo(byte[] file, Dictionary tags, bool bigEndian) + { var width = (int)RequireTagValues(file, tags, TagImageWidth, "ImageWidth", bigEndian)[0]; var height = (int)RequireTagValues(file, tags, TagImageLength, "ImageLength", bigEndian)[0]; if (width <= 0 || height <= 0) @@ -270,7 +336,7 @@ public static Surface Load(Stream stream) : bitsPerSample.Length; var compressionValue = (int)RequireTagValues(file, tags, TagCompression, "Compression", bigEndian)[0]; - if (!Enum.IsDefined(typeof(TiffCompression), compressionValue)) + if (!Enum.IsDefined((TiffCompression)compressionValue)) { throw new InvalidDataException( $"Unsupported TIFF compression {compressionValue}; only None (1), LZW (5), Deflate (8), and PackBits (32773) are supported."); @@ -303,6 +369,23 @@ public static Surface Load(Stream stream) $"Unsupported TIFF predictor {predictor}; only None (1) and horizontal differencing (2) are supported."); } + ValidateSamplesPerPixel(file, tags, photometric, samplesPerPixel, bigEndian); + + return new TiffImageInfo(width, height, samplesPerPixel, compression, photometric, planarConfiguration, predictor); + } + + /// + /// Validates that is a supported value for the given + /// interpretation, including the RGBA + /// ExtraSamples-tag requirement for 4-sample RGB images. + /// + private static void ValidateSamplesPerPixel( + byte[] file, + Dictionary tags, + int photometric, + int samplesPerPixel, + bool bigEndian) + { if (photometric == PhotometricRgb) { if (samplesPerPixel == 4) @@ -325,7 +408,21 @@ public static Surface Load(Stream stream) throw new InvalidDataException( $"Unsupported TIFF samples per pixel {samplesPerPixel} for Grayscale photometric interpretation; only 1 is supported."); } + } + /// + /// The strip-layout tag values needed to iterate a TIFF image's strips: how many rows + /// each strip holds, and the byte width of one decompressed, unpacked pixel row. + /// + private readonly record struct StripLayout(int RowsPerStrip, int RowBytes); + + /// + /// Reads the StripOffsets/RowsPerStrip/StripByteCounts tags, then + /// decodes every strip in order into a new , verifying afterward + /// that the strips cover the full declared image height. + /// + private static Surface DecodeStrips(byte[] file, Dictionary tags, TiffImageInfo info, bool bigEndian) + { var stripOffsets = RequireTagValues(file, tags, TagStripOffsets, "StripOffsets", bigEndian); var rowsPerStrip = (int)RequireTagValues(file, tags, TagRowsPerStrip, "RowsPerStrip", bigEndian)[0]; var stripByteCounts = RequireTagValues(file, tags, TagStripByteCounts, "StripByteCounts", bigEndian); @@ -339,53 +436,23 @@ public static Surface Load(Stream stream) throw new InvalidDataException("StripOffsets and StripByteCounts entry counts do not match."); } - var surface = new Surface(width, height); - var rowBytes = width * samplesPerPixel; + var surface = new Surface(info.Width, info.Height); + var layout = new StripLayout(rowsPerStrip, info.Width * info.SamplesPerPixel); var destinationRow = 0; for (var stripIndex = 0; stripIndex < stripOffsets.Length; stripIndex++) { - var stripOffset = checked((int)stripOffsets[stripIndex]); - var stripByteCount = checked((int)stripByteCounts[stripIndex]); - CheckBounds(file, stripOffset, stripByteCount, "strip data"); - var stripBytes = file.AsSpan(stripOffset, stripByteCount).ToArray(); - - var decompressed = compression switch - { - TiffCompression.None => stripBytes, - TiffCompression.PackBits => DecodePackBits(stripBytes), - TiffCompression.Lzw => DecodeLzw(stripBytes), - TiffCompression.Deflate => ZlibDecompress(stripBytes), - _ => throw new InvalidDataException($"Unsupported TIFF compression {compression}.") - }; - - var rowsInStrip = Math.Min(rowsPerStrip, height - destinationRow); - if (rowsInStrip <= 0) - { - continue; - } - - var expectedLength = (long)rowBytes * rowsInStrip; - if (decompressed.LongLength < expectedLength) - { - throw new InvalidDataException( - "Decompressed TIFF strip data is shorter than expected (corrupt or truncated image data)."); - } - - for (var row = 0; row < rowsInStrip; row++) - { - var rowSpan = decompressed.AsSpan(row * rowBytes, rowBytes); - if (predictor == PredictorHorizontal) - { - RemoveHorizontalPredictor(rowSpan, samplesPerPixel); - } - - UnpackRow(rowSpan, surface.GetRowSpanBytes(destinationRow), width, samplesPerPixel, photometric); - destinationRow++; - } + destinationRow = DecodeStrip( + file, + stripOffsets[stripIndex], + stripByteCounts[stripIndex], + info, + layout, + surface, + destinationRow); } - if (destinationRow != height) + if (destinationRow != info.Height) { throw new InvalidDataException("TIFF strips do not cover the full declared image height."); } @@ -394,34 +461,59 @@ public static Surface Load(Stream stream) } /// - /// Loads a from a TIFF file at the specified path. + /// Decompresses one TIFF strip and unpacks its rows (reversing the horizontal predictor + /// first, if applicable) into starting at + /// , returning the updated destination row index. /// - /// The path of the TIFF file to load. Must not be null or empty. - /// - /// A new containing the decoded pixels; see - /// for the decoding contract. - /// - /// Thrown when is null. - /// Thrown when is an empty string. - /// - /// Thrown for the same malformed/unsupported-format conditions as . - /// - /// - /// File-system exceptions (for example , - /// , , - /// or ) raised while opening propagate - /// uncaught to the caller. - /// - public static Surface Load(string path) + private static int DecodeStrip( + byte[] file, + uint stripOffsetValue, + uint stripByteCountValue, + TiffImageInfo info, + StripLayout layout, + Surface surface, + int destinationRow) { - ArgumentNullException.ThrowIfNull(path); - if (path.Length == 0) + var stripOffset = checked((int)stripOffsetValue); + var stripByteCount = checked((int)stripByteCountValue); + CheckBounds(file, stripOffset, stripByteCount, "strip data"); + var stripBytes = file.AsSpan(stripOffset, stripByteCount).ToArray(); + + var decompressed = info.Compression switch { - throw new ArgumentException("Path must not be empty.", nameof(path)); + TiffCompression.None => stripBytes, + TiffCompression.PackBits => DecodePackBits(stripBytes), + TiffCompression.Lzw => DecodeLzw(stripBytes), + TiffCompression.Deflate => ZlibDecompress(stripBytes), + _ => throw new InvalidDataException($"Unsupported TIFF compression {info.Compression}.") + }; + + var rowsInStrip = Math.Min(layout.RowsPerStrip, info.Height - destinationRow); + if (rowsInStrip <= 0) + { + return destinationRow; } - using var stream = new FileStream(path, FileMode.Open, FileAccess.Read); - return Load(stream); + var expectedLength = (long)layout.RowBytes * rowsInStrip; + if (decompressed.LongLength < expectedLength) + { + throw new InvalidDataException( + "Decompressed TIFF strip data is shorter than expected (corrupt or truncated image data)."); + } + + for (var row = 0; row < rowsInStrip; row++) + { + var rowSpan = decompressed.AsSpan(row * layout.RowBytes, layout.RowBytes); + if (info.Predictor == PredictorHorizontal) + { + RemoveHorizontalPredictor(rowSpan, info.SamplesPerPixel); + } + + UnpackRow(rowSpan, surface.GetRowSpanBytes(destinationRow), info.Width, info.SamplesPerPixel, info.Photometric); + destinationRow++; + } + + return destinationRow; } /// @@ -465,7 +557,7 @@ public static void Save(Surface surface, Stream stream, TiffCompression compress { ArgumentNullException.ThrowIfNull(surface); ArgumentNullException.ThrowIfNull(stream); - if (!Enum.IsDefined(typeof(TiffCompression), compression)) + if (!Enum.IsDefined(compression)) { throw new ArgumentOutOfRangeException( nameof(compression), compression, "Compression must be a defined TiffCompression value."); @@ -838,11 +930,7 @@ private static byte[] EncodePackBits(byte[] data) var i = 0; while (i < data.Length) { - var runLength = 1; - while (i + runLength < data.Length && runLength < 128 && data[i + runLength] == data[i]) - { - runLength++; - } + var runLength = FindPackBitsRunLength(data, i); if (runLength >= 2) { @@ -853,24 +941,7 @@ private static byte[] EncodePackBits(byte[] data) else { var start = i; - var length = 0; - while (i < data.Length && length < 128) - { - var lookaheadRun = 1; - while (i + lookaheadRun < data.Length && lookaheadRun < 128 && data[i + lookaheadRun] == data[i]) - { - lookaheadRun++; - } - - if (lookaheadRun >= 2) - { - break; - } - - i++; - length++; - } - + var length = FindPackBitsLiteralLength(data, ref i); output.WriteByte((byte)(length - 1)); output.Write(data, start, length); } @@ -879,6 +950,44 @@ private static byte[] EncodePackBits(byte[] data) return output.ToArray(); } + /// + /// Counts how many bytes starting at repeat the value at + /// , capped at the PackBits maximum run length of 128. + /// + private static int FindPackBitsRunLength(byte[] data, int start) + { + var runLength = 1; + while (start + runLength < data.Length && runLength < 128 && data[start + runLength] == data[start]) + { + runLength++; + } + + return runLength; + } + + /// + /// Advances past a run of non-repeating ("literal") bytes, stopping + /// as soon as a repeat run of 2 or more is found or the PackBits maximum literal length of + /// 128 is reached, and returns the number of literal bytes found. + /// + private static int FindPackBitsLiteralLength(byte[] data, ref int i) + { + var length = 0; + while (i < data.Length && length < 128) + { + var lookaheadRun = FindPackBitsRunLength(data, i); + if (lookaheadRun >= 2) + { + break; + } + + i++; + length++; + } + + return length; + } + /// /// Encodes a byte stream using the TIFF-flavor LZW algorithm (TIFF 6.0 Section 13): /// variable-width 9-12 bit codes, MSB-first bit packing, clear code 256, end-of-information @@ -973,40 +1082,12 @@ private static byte[] DecodeLzw(byte[] data) continue; } - byte[] entry; - if (code < 256) - { - entry = [(byte)code]; - } - else if (code - LzwFirstCode < table.Count) - { - entry = table[code - LzwFirstCode]; - } - else if (code - LzwFirstCode == table.Count && previousEntry is not null) - { - entry = new byte[previousEntry.Length + 1]; - previousEntry.CopyTo(entry, 0); - entry[^1] = previousEntry[0]; - } - else - { - throw new InvalidDataException("Invalid TIFF LZW code sequence."); - } - + var entry = ResolveLzwEntry(code, table, previousEntry); output.Write(entry, 0, entry.Length); if (previousEntry is not null) { - var newEntry = new byte[previousEntry.Length + 1]; - previousEntry.CopyTo(newEntry, 0); - newEntry[^1] = entry[0]; - table.Add(newEntry); - - var nextCode = LzwFirstCode + table.Count; - if (nextCode is 511 or 1023 or 2047) - { - codeSize++; - } + codeSize = AddLzwTableEntry(table, previousEntry, entry, codeSize); } previousEntry = entry; @@ -1015,6 +1096,57 @@ private static byte[] DecodeLzw(byte[] data) return output.ToArray(); } + /// + /// Resolves the byte sequence for a single decoded LZW : a literal + /// byte value for codes below 256, an existing table entry for already-known codes, or + /// the classic LZW "KwKwK" reconstruction (previous entry plus its own first byte) for + /// the one code that is always exactly one past the current table end. + /// + /// Thrown when is invalid. + private static byte[] ResolveLzwEntry(int code, List table, byte[]? previousEntry) + { + if (code < 256) + { + return [(byte)code]; + } + + if (code - LzwFirstCode < table.Count) + { + return table[code - LzwFirstCode]; + } + + if (code - LzwFirstCode == table.Count && previousEntry is not null) + { + var entry = new byte[previousEntry.Length + 1]; + previousEntry.CopyTo(entry, 0); + entry[^1] = previousEntry[0]; + return entry; + } + + throw new InvalidDataException("Invalid TIFF LZW code sequence."); + } + + /// + /// Appends a new table entry formed from plus the first + /// byte of , then widens if the table + /// has just grown past a code-width boundary, returning the (possibly updated) code size. + /// + private static int AddLzwTableEntry(List table, byte[] previousEntry, byte[] entry, int codeSize) + { + var newEntry = new byte[previousEntry.Length + 1]; + previousEntry.CopyTo(newEntry, 0); + newEntry[^1] = entry[0]; + table.Add(newEntry); + + var nextCode = LzwFirstCode + table.Count; + if (nextCode is 511 or 1023 or 2047) + { + codeSize++; + } + + return codeSize; + } + /// /// Packs variable-width (9-12 bit) LZW codes into bytes MSB-first, as required by the /// TIFF specification (the reverse bit order from the GIF LZW variant). diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegCodecTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegCodecTests.cs index 7f342774..4efd7af2 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegCodecTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegCodecTests.cs @@ -484,21 +484,21 @@ public void JpegCodec_Load_TruncatedEntropyData_ThrowsInvalidDataException() /// , each of /// which declares a segment length claiming more payload bytes than are actually present. /// - public static IEnumerable TruncatedSegmentPayloadCases() + public static TheoryData TruncatedSegmentPayloadCases() { - // DQT segment claims 0x45 payload bytes but supplies none. - yield return [new byte[] { MarkerPrefix, MarkerSoi, MarkerPrefix, MarkerDqt, 0x00, 0x45 }]; + var cases = new TheoryData + { + // DQT segment claims 0x45 payload bytes but supplies none. + new byte[] { MarkerPrefix, MarkerSoi, MarkerPrefix, MarkerDqt, 0x00, 0x45 }, - // DHT segment claims 0x45 payload bytes but supplies none. - yield return [new byte[] { MarkerPrefix, MarkerSoi, MarkerPrefix, MarkerDht, 0x00, 0x45 }]; + // DHT segment claims 0x45 payload bytes but supplies none. + new byte[] { MarkerPrefix, MarkerSoi, MarkerPrefix, MarkerDht, 0x00, 0x45 }, - // SOF0 segment claims 0x20 payload bytes but supplies none. - yield return [new byte[] { MarkerPrefix, MarkerSoi, MarkerPrefix, MarkerSof0, 0x00, 0x20 }]; + // SOF0 segment claims 0x20 payload bytes but supplies none. + new byte[] { MarkerPrefix, MarkerSoi, MarkerPrefix, MarkerSof0, 0x00, 0x20 }, - // SOS segment (preceded by well-formed DQT/DHT/SOF0 segments) claims 0x20 payload bytes - // but supplies none. - yield return - [ + // SOS segment (preceded by well-formed DQT/DHT/SOF0 segments) claims 0x20 payload + // bytes but supplies none. BuildJpeg( [ BuildMinimalDqtSegment(), @@ -508,7 +508,9 @@ public static IEnumerable TruncatedSegmentPayloadCases() ], entropyData: null, includeEoi: false) - ]; + }; + + return cases; } /// diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs index 053d8e53..7d41eb46 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs @@ -29,6 +29,16 @@ public class JpegFixtureTests /// private static string PngSuitePath => Path.Combine(AppContext.BaseDirectory, "PngSuite"); + /// + /// Resolves a fixture file within , stripping any + /// directory component from first. The file names always + /// originate from this class's own TheoryData literals rather than external input, + /// so this is a static-analysis hardening (guards against path traversal via + /// ) rather than a behavior change. + /// + private static string ResolveFixturePath(string baseDirectory, string fileName) => + Path.Combine(baseDirectory, Path.GetFileName(fileName)); + /// /// Every JPEG fixture file name, for tests that only need to prove successful loading with /// the expected dimensions. @@ -116,7 +126,7 @@ public void JpegFixtures_SourcePngDimensions_Are32x32() [MemberData(nameof(AllFixtureFiles))] public void JpegCodec_Load_Fixture_ReturnsCanvasWithExpectedDimensions(string fileName) { - var surface = JpegCodec.Load(Path.Combine(AssetsPath, fileName)); + var surface = JpegCodec.Load(ResolveFixturePath(AssetsPath, fileName)); Assert.Equal(32, surface.Width); Assert.Equal(32, surface.Height); @@ -132,7 +142,7 @@ public void JpegCodec_Load_Fixture_ReturnsCanvasWithExpectedDimensions(string fi public void JpegCodec_Load_ColorFixture_MatchesSourcePngWithinTolerance(string fileName, int tolerancePerChannel) { var expected = PngCodec.Load(Path.Combine(PngSuitePath, "basn2c08.png")); - var actual = JpegCodec.Load(Path.Combine(AssetsPath, fileName)); + var actual = JpegCodec.Load(ResolveFixturePath(AssetsPath, fileName)); AssertPixelsApproximatelyEqual(expected, actual, tolerancePerChannel); } @@ -145,7 +155,7 @@ public void JpegCodec_Load_ColorFixture_MatchesSourcePngWithinTolerance(string f [MemberData(nameof(GrayscaleFixtureFiles))] public void JpegCodec_Load_GrayscaleFixture_HasEqualRgbChannels(string fileName) { - var surface = JpegCodec.Load(Path.Combine(AssetsPath, fileName)); + var surface = JpegCodec.Load(ResolveFixturePath(AssetsPath, fileName)); Assert.Equal(32, surface.Width); Assert.Equal(32, surface.Height); diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/PngCodecTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/PngCodecTests.cs index 46674b88..af2667c1 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/PngCodecTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/PngCodecTests.cs @@ -334,6 +334,29 @@ public void PngCodec_Load_BadSignature_ThrowsInvalidDataException() Assert.Throws(() => PngCodec.Load(stream)); } + /// + /// Proves that Load rejects a stream containing a valid signature and a structurally + /// valid IEND chunk (correct CRC-32) but no IHDR chunk, with + /// naming IHDR as the missing chunk, rather + /// than proceeding to decode pixel data using default/uninitialized header fields. + /// + [Fact] + public void PngCodec_Load_IendBeforeIhdr_ThrowsInvalidDataExceptionMentioningIhdr() + { + // Arrange: a valid signature followed directly by a well-formed IEND chunk, with no + // IHDR chunk present anywhere in the stream + using var stream = new MemoryStream(); + stream.Write(Signature, 0, Signature.Length); + var iend = BuildChunk("IEND", []); + stream.Write(iend, 0, iend.Length); + stream.Position = 0; + + // Act & Assert: the missing IHDR chunk must be rejected, with the exception message + // identifying IHDR as the cause + var exception = Assert.Throws(() => PngCodec.Load(stream)); + Assert.Contains("IHDR", exception.Message); + } + /// /// Proves that Load rejects a grayscale (color type 0) IHDR with InvalidDataException. /// diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/PngSuiteTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/PngSuiteTests.cs index 03e6afec..b7314b53 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/PngSuiteTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/PngSuiteTests.cs @@ -43,6 +43,16 @@ public class PngSuiteTests /// private static string AssetsPath => Path.Combine(AppContext.BaseDirectory, "PngSuite"); + /// + /// Resolves a fixture file within , stripping any + /// directory component from first. The file names always + /// originate from this class's own TheoryData literals rather than external input, + /// so this is a static-analysis hardening (guards against path traversal via + /// ) rather than a behavior change. + /// + private static string ResolveFixturePath(string baseDirectory, string fileName) => + Path.Combine(baseDirectory, Path.GetFileName(fileName)); + /// /// PngSuite files within PngCodec's supported feature set (color type 2 or 6, 8-bit, /// non-interlaced) that must load successfully. @@ -255,7 +265,7 @@ public class PngSuiteTests public void PngCodec_Load_PngSuiteSupportedFile_ReturnsCanvas(string fileName) { // Act: load the PngSuite file - var surface = PngCodec.Load(Path.Combine(AssetsPath, fileName)); + var surface = PngCodec.Load(ResolveFixturePath(AssetsPath, fileName)); // Assert: a non-empty surface was produced Assert.True(surface.Width > 0); @@ -272,7 +282,7 @@ public void PngCodec_Load_PngSuiteSupportedFile_ReturnsCanvas(string fileName) public void PngCodec_Load_PngSuiteUnsupportedFile_ThrowsInvalidDataException(string fileName) { // Act & Assert: the unsupported feature must be rejected - Assert.Throws(() => PngCodec.Load(Path.Combine(AssetsPath, fileName))); + Assert.Throws(() => PngCodec.Load(ResolveFixturePath(AssetsPath, fileName))); } /// @@ -284,6 +294,6 @@ public void PngCodec_Load_PngSuiteUnsupportedFile_ThrowsInvalidDataException(str public void PngCodec_Load_PngSuiteCorruptFile_ThrowsInvalidDataException(string fileName) { // Act & Assert: the corrupt file must be rejected - Assert.Throws(() => PngCodec.Load(Path.Combine(AssetsPath, fileName))); + Assert.Throws(() => PngCodec.Load(ResolveFixturePath(AssetsPath, fileName))); } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffFixtureTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffFixtureTests.cs index b57400e8..b3d25791 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffFixtureTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffFixtureTests.cs @@ -31,6 +31,16 @@ public class TiffFixtureTests /// private static string PngSuitePath => Path.Combine(AppContext.BaseDirectory, "PngSuite"); + /// + /// Resolves a fixture file within , stripping any + /// directory component from first. The file names always + /// originate from this class's own TheoryData literals rather than external input, + /// so this is a static-analysis hardening (guards against path traversal via + /// ) rather than a behavior change. + /// + private static string ResolveFixturePath(string baseDirectory, string fileName) => + Path.Combine(baseDirectory, Path.GetFileName(fileName)); + /// /// Every TIFF fixture file name, for tests that only need to prove successful loading with /// the expected dimensions. @@ -106,7 +116,7 @@ public void TiffFixtures_SourcePngDimensions_Are32x32() [MemberData(nameof(AllFixtureFiles))] public void TiffCodec_Load_Fixture_ReturnsCanvasWithExpectedDimensions(string fileName) { - var surface = TiffCodec.Load(Path.Combine(AssetsPath, fileName)); + var surface = TiffCodec.Load(ResolveFixturePath(AssetsPath, fileName)); Assert.Equal(32, surface.Width); Assert.Equal(32, surface.Height); @@ -121,7 +131,7 @@ public void TiffCodec_Load_Fixture_ReturnsCanvasWithExpectedDimensions(string fi public void TiffCodec_Load_RgbFixture_MatchesSourcePngWithOpaqueAlpha(string fileName) { var expected = PngCodec.Load(Path.Combine(PngSuitePath, "basn2c08.png")); - var actual = TiffCodec.Load(Path.Combine(AssetsPath, fileName)); + var actual = TiffCodec.Load(ResolveFixturePath(AssetsPath, fileName)); Assert.Equal(expected.Width, actual.Width); Assert.Equal(expected.Height, actual.Height); @@ -148,7 +158,7 @@ public void TiffCodec_Load_RgbFixture_MatchesSourcePngWithOpaqueAlpha(string fil public void TiffCodec_Load_RgbaFixture_MatchesSourcePngExactly(string fileName) { var expected = PngCodec.Load(Path.Combine(PngSuitePath, "basn6a08.png")); - var actual = TiffCodec.Load(Path.Combine(AssetsPath, fileName)); + var actual = TiffCodec.Load(ResolveFixturePath(AssetsPath, fileName)); Assert.Equal(expected.Width, actual.Width); Assert.Equal(expected.Height, actual.Height); @@ -171,7 +181,7 @@ public void TiffCodec_Load_RgbaFixture_MatchesSourcePngExactly(string fileName) [MemberData(nameof(GrayscaleFixtureFiles))] public void TiffCodec_Load_GrayscaleFixture_HasEqualRgbChannels(string fileName) { - var surface = TiffCodec.Load(Path.Combine(AssetsPath, fileName)); + var surface = TiffCodec.Load(ResolveFixturePath(AssetsPath, fileName)); Assert.Equal(32, surface.Width); Assert.Equal(32, surface.Height); From 5f2c2ed4947afaa55bd8e8d7b0d79e2b66d1441a Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 19 Sep 2026 10:47:06 -0400 Subject: [PATCH 2/4] Use Path.Join instead of Path.GetFileName for cs/path-combine fix Replace the previously-committed Path.Combine(baseDirectory, Path.GetFileName(fileName)) sanitization approach with Path.Join (baseDirectory, fileName) in the ResolveFixturePath helpers across JpegFixtureTests.cs, PngSuiteTests.cs, and TiffFixtureTests.cs. Path.Join lacks Path.Combine's rooted-path-discard behavior, so it doesn't trigger CodeQL's cs/path-combine rule at all. Path injection is not a concern since every fileName is a hardcoded literal from TheoryData. Path.Join is unavailable on .NET Framework, and this test project genuinely targets net481 on Windows, so the fix uses conditional compilation: net481 keeps the original Path.GetFileName-sanitized Path.Combine call (never scanned by CodeQL, which only analyzes net8.0/net9.0/net10.0 on Linux), while all modern target frameworks use Path.Join. Added NETFRAMEWORK to .cspell.yaml (verified necessary: lint.ps1 fails on the unrecognized preprocessor symbol without it). All 1420 tests still passing, 0 failures. dotnet build, reqstream --enforce, reviewmark --enforce, and lint.ps1 all pass clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .cspell.yaml | 1 + .../Codecs/JpegFixtureTests.cs | 15 +++++++++++---- .../Codecs/PngSuiteTests.cs | 15 +++++++++++---- .../Codecs/TiffFixtureTests.cs | 15 +++++++++++---- 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/.cspell.yaml b/.cspell.yaml index d5e567f7..b231d77b 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -57,6 +57,7 @@ words: - mcus - misdecoded - nameof + - NETFRAMEWORK - quantizer - subsampled - unstuffed diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs index 7d41eb46..ac2f91ca 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs @@ -30,14 +30,21 @@ public class JpegFixtureTests private static string PngSuitePath => Path.Combine(AppContext.BaseDirectory, "PngSuite"); /// - /// Resolves a fixture file within , stripping any - /// directory component from first. The file names always + /// Resolves a fixture file within . The file names always /// originate from this class's own TheoryData literals rather than external input, - /// so this is a static-analysis hardening (guards against path traversal via - /// ) rather than a behavior change. + /// so path-injection is not a concern here; Path.Join is + /// used instead of purely to avoid CodeQL's + /// cs/path-combine rule, since Path.Join does not discard + /// when looks rooted. + /// Path.Join is unavailable on .NET Framework, so the net481 target falls back to + /// . /// private static string ResolveFixturePath(string baseDirectory, string fileName) => +#if NETFRAMEWORK Path.Combine(baseDirectory, Path.GetFileName(fileName)); +#else + Path.Join(baseDirectory, fileName); +#endif /// /// Every JPEG fixture file name, for tests that only need to prove successful loading with diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/PngSuiteTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/PngSuiteTests.cs index b7314b53..2c625d04 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/PngSuiteTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/PngSuiteTests.cs @@ -44,14 +44,21 @@ public class PngSuiteTests private static string AssetsPath => Path.Combine(AppContext.BaseDirectory, "PngSuite"); /// - /// Resolves a fixture file within , stripping any - /// directory component from first. The file names always + /// Resolves a fixture file within . The file names always /// originate from this class's own TheoryData literals rather than external input, - /// so this is a static-analysis hardening (guards against path traversal via - /// ) rather than a behavior change. + /// so path-injection is not a concern here; Path.Join is + /// used instead of purely to avoid CodeQL's + /// cs/path-combine rule, since Path.Join does not discard + /// when looks rooted. + /// Path.Join is unavailable on .NET Framework, so the net481 target falls back to + /// . /// private static string ResolveFixturePath(string baseDirectory, string fileName) => +#if NETFRAMEWORK Path.Combine(baseDirectory, Path.GetFileName(fileName)); +#else + Path.Join(baseDirectory, fileName); +#endif /// /// PngSuite files within PngCodec's supported feature set (color type 2 or 6, 8-bit, diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffFixtureTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffFixtureTests.cs index b3d25791..b48fcc2a 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffFixtureTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffFixtureTests.cs @@ -32,14 +32,21 @@ public class TiffFixtureTests private static string PngSuitePath => Path.Combine(AppContext.BaseDirectory, "PngSuite"); /// - /// Resolves a fixture file within , stripping any - /// directory component from first. The file names always + /// Resolves a fixture file within . The file names always /// originate from this class's own TheoryData literals rather than external input, - /// so this is a static-analysis hardening (guards against path traversal via - /// ) rather than a behavior change. + /// so path-injection is not a concern here; Path.Join is + /// used instead of purely to avoid CodeQL's + /// cs/path-combine rule, since Path.Join does not discard + /// when looks rooted. + /// Path.Join is unavailable on .NET Framework, so the net481 target falls back to + /// . /// private static string ResolveFixturePath(string baseDirectory, string fileName) => +#if NETFRAMEWORK Path.Combine(baseDirectory, Path.GetFileName(fileName)); +#else + Path.Join(baseDirectory, fileName); +#endif /// /// Every TIFF fixture file name, for tests that only need to prove successful loading with From ab829906ee8722f4cb3c846b2c8e998148bec92d Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 19 Sep 2026 11:06:32 -0400 Subject: [PATCH 3/4] Use Polyfill package for Path.Join on net481 instead of #if branching Add a Polyfill package reference (version 11.3.0, matching the main library) to the test project so Path.Join(string, string) resolves uniformly on net481, replacing the prior #if NETFRAMEWORK / #else dual-branch ResolveFixturePath implementation with a single unconditional Path.Join(baseDirectory, fileName) call in JpegFixtureTests.cs, PngSuiteTests.cs, and TiffFixtureTests.cs. Add PolyUseEmbeddedAttribute=true to the main library's csproj to resolve a genuine CS0121 ambiguous-call error that otherwise occurs once both the library and test project reference Polyfill (the library's InternalsVisibleTo exposes its embedded polyfill types to the test assembly, colliding with the test project's own embedded copy). Verified by reproducing the CS0121 error with the setting removed and confirming it disappears when restored. Removed the now-unused NETFRAMEWORK entry from .cspell.yaml since the conditional-compilation branch it guarded no longer exists anywhere in the repo. All four target frameworks (net481, net8.0, net9.0, net10.0) build with 0 warnings/0 errors; net481 test execution explicitly confirmed passing. 1420/1420 tests passing overall, 0 failures. dotnet build, reqstream --enforce, reviewmark --enforce, and lint.ps1 all pass clean. Zero behavioral change: Path.Join and the prior Path.Combine(baseDirectory, Path.GetFileName(fileName)) fallback produce identical results for every flat, non-rooted fixture filename literal used across all three test files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .cspell.yaml | 1 - .../DemaConsulting.CanvasNet.csproj | 1 + .../Codecs/JpegFixtureTests.cs | 8 ++------ .../Codecs/PngSuiteTests.cs | 8 ++------ .../Codecs/TiffFixtureTests.cs | 8 ++------ .../DemaConsulting.CanvasNet.Tests.csproj | 1 + 6 files changed, 8 insertions(+), 19 deletions(-) diff --git a/.cspell.yaml b/.cspell.yaml index b231d77b..d5e567f7 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -57,7 +57,6 @@ words: - mcus - misdecoded - nameof - - NETFRAMEWORK - quantizer - subsampled - unstuffed diff --git a/src/DemaConsulting.CanvasNet/DemaConsulting.CanvasNet.csproj b/src/DemaConsulting.CanvasNet/DemaConsulting.CanvasNet.csproj index a521325b..97d2ce33 100644 --- a/src/DemaConsulting.CanvasNet/DemaConsulting.CanvasNet.csproj +++ b/src/DemaConsulting.CanvasNet/DemaConsulting.CanvasNet.csproj @@ -38,6 +38,7 @@ true + true true diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs index ac2f91ca..ebbae256 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs @@ -36,15 +36,11 @@ public class JpegFixtureTests /// used instead of purely to avoid CodeQL's /// cs/path-combine rule, since Path.Join does not discard /// when looks rooted. - /// Path.Join is unavailable on .NET Framework, so the net481 target falls back to - /// . + /// Path.Join is natively available on the modern .NET targets, and is polyfilled + /// onto net481 by the Polyfill package referenced by this project. /// private static string ResolveFixturePath(string baseDirectory, string fileName) => -#if NETFRAMEWORK - Path.Combine(baseDirectory, Path.GetFileName(fileName)); -#else Path.Join(baseDirectory, fileName); -#endif /// /// Every JPEG fixture file name, for tests that only need to prove successful loading with diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/PngSuiteTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/PngSuiteTests.cs index 2c625d04..e3ca45a6 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/PngSuiteTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/PngSuiteTests.cs @@ -50,15 +50,11 @@ public class PngSuiteTests /// used instead of purely to avoid CodeQL's /// cs/path-combine rule, since Path.Join does not discard /// when looks rooted. - /// Path.Join is unavailable on .NET Framework, so the net481 target falls back to - /// . + /// Path.Join is natively available on the modern .NET targets, and is polyfilled + /// onto net481 by the Polyfill package referenced by this project. /// private static string ResolveFixturePath(string baseDirectory, string fileName) => -#if NETFRAMEWORK - Path.Combine(baseDirectory, Path.GetFileName(fileName)); -#else Path.Join(baseDirectory, fileName); -#endif /// /// PngSuite files within PngCodec's supported feature set (color type 2 or 6, 8-bit, diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffFixtureTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffFixtureTests.cs index b48fcc2a..fc131b35 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffFixtureTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffFixtureTests.cs @@ -38,15 +38,11 @@ public class TiffFixtureTests /// used instead of purely to avoid CodeQL's /// cs/path-combine rule, since Path.Join does not discard /// when looks rooted. - /// Path.Join is unavailable on .NET Framework, so the net481 target falls back to - /// . + /// Path.Join is natively available on the modern .NET targets, and is polyfilled + /// onto net481 by the Polyfill package referenced by this project. /// private static string ResolveFixturePath(string baseDirectory, string fileName) => -#if NETFRAMEWORK - Path.Combine(baseDirectory, Path.GetFileName(fileName)); -#else Path.Join(baseDirectory, fileName); -#endif /// /// Every TIFF fixture file name, for tests that only need to prove successful loading with diff --git a/test/DemaConsulting.CanvasNet.Tests/DemaConsulting.CanvasNet.Tests.csproj b/test/DemaConsulting.CanvasNet.Tests/DemaConsulting.CanvasNet.Tests.csproj index ef9d949a..bca7d5c3 100644 --- a/test/DemaConsulting.CanvasNet.Tests/DemaConsulting.CanvasNet.Tests.csproj +++ b/test/DemaConsulting.CanvasNet.Tests/DemaConsulting.CanvasNet.Tests.csproj @@ -39,6 +39,7 @@ compile; runtime; build; native; contentfiles; analyzers; buildtransitive + From d076b44778f6c6cb88f290d5bbe8dc9a3c38ace9 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 19 Sep 2026 11:37:15 -0400 Subject: [PATCH 4/4] Fix remaining SonarCloud S107 findings in JpegCodec AC-refinement helpers Introduces a RefinementParams record struct grouping the bit reader, end-of-band index, and positive/negative refinement bit values shared by RefineNonZeroCoefficient, RefineRemainingCoefficients, DecodeAcRefineNewCoefficients, and RefineOrPlaceCoefficient, bringing DecodeAcRefineNewCoefficients and RefineOrPlaceCoefficient down from 8 parameters to 5, matching the pattern already used elsewhere in this file. Pure refactor, no behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Codecs/JpegCodec.cs | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/src/DemaConsulting.CanvasNet/Codecs/JpegCodec.cs b/src/DemaConsulting.CanvasNet/Codecs/JpegCodec.cs index a88555cf..f9676f15 100644 --- a/src/DemaConsulting.CanvasNet/Codecs/JpegCodec.cs +++ b/src/DemaConsulting.CanvasNet/Codecs/JpegCodec.cs @@ -1424,46 +1424,52 @@ private static void DecodeAcFirst(int[] block, HuffmanTable acTable, BitReader r private static void DecodeAcRefine(int[] block, HuffmanTable acTable, BitReader reader, int ss, int se, int al, ref int eobRun) { - var p1 = 1 << al; - var m1 = -1 << al; + var rp = new RefinementParams(reader, se, 1 << al, -1 << al); var k = ss; if (eobRun == 0) { - DecodeAcRefineNewCoefficients(block, acTable, reader, se, p1, m1, ref k, ref eobRun); + DecodeAcRefineNewCoefficients(block, acTable, rp, ref k, ref eobRun); } if (eobRun > 0) { - RefineRemainingCoefficients(block, ref k, se, reader, p1, m1); + RefineRemainingCoefficients(block, ref k, rp); eobRun--; } } + /// + /// Groups the successive-approximation refinement state (ITU-T T.81 section G.1.2.3) + /// shared by the AC-refinement coefficient walkers: the bit reader, the end-of-band + /// index, and the positive/negative refinement bit values. + /// + private readonly record struct RefinementParams(BitReader Reader, int Se, int P1, int M1); + /// /// Applies a successive-approximation refinement bit to a single already-nonzero /// coefficient, per ITU-T T.81 section G.1.2.3: the bit is only consumed (and the /// coefficient only nudged toward zero-away) when the coefficient's next refinement /// bit position is still unset. /// - private static void RefineNonZeroCoefficient(int[] block, int k, BitReader reader, int p1, int m1) + private static void RefineNonZeroCoefficient(int[] block, int k, RefinementParams rp) { - if (block[k] != 0 && reader.ReadBit() != 0 && (block[k] & p1) == 0) + if (block[k] != 0 && rp.Reader.ReadBit() != 0 && (block[k] & rp.P1) == 0) { - block[k] += block[k] >= 0 ? p1 : m1; + block[k] += block[k] >= 0 ? rp.P1 : rp.M1; } } /// /// Refines every remaining nonzero coefficient from through - /// without placing any new coefficients, used while an - /// end-of-band run inherited from an earlier RS pair is still being consumed. + /// 's end-of-band index without placing any new coefficients, used + /// while an end-of-band run inherited from an earlier RS pair is still being consumed. /// - private static void RefineRemainingCoefficients(int[] block, ref int k, int se, BitReader reader, int p1, int m1) + private static void RefineRemainingCoefficients(int[] block, ref int k, RefinementParams rp) { - while (k <= se) + while (k <= rp.Se) { - RefineNonZeroCoefficient(block, k, reader, p1, m1); + RefineNonZeroCoefficient(block, k, rp); k++; } } @@ -1476,11 +1482,11 @@ private static void RefineRemainingCoefficients(int[] block, ref int k, int se, /// . /// private static void DecodeAcRefineNewCoefficients( - int[] block, HuffmanTable acTable, BitReader reader, int se, int p1, int m1, ref int k, ref int eobRun) + int[] block, HuffmanTable acTable, RefinementParams rp, ref int k, ref int eobRun) { - while (k <= se) + while (k <= rp.Se) { - var rs = BitReader.Decode(acTable, reader); + var rs = BitReader.Decode(acTable, rp.Reader); var r = rs >> 4; var s = rs & 0xF; var newValue = 0; @@ -1492,7 +1498,7 @@ private static void DecodeAcRefineNewCoefficients( eobRun = 1 << r; if (r > 0) { - eobRun += reader.ReadBits(r); + eobRun += rp.Reader.ReadBits(r); } r = 64; // sentinel: skip remaining coefficients (refinement only) below @@ -1500,29 +1506,29 @@ private static void DecodeAcRefineNewCoefficients( } else { - newValue = reader.ReadBit() != 0 ? p1 : m1; + newValue = rp.Reader.ReadBit() != 0 ? rp.P1 : rp.M1; } - RefineOrPlaceCoefficient(block, ref k, se, reader, p1, m1, r, newValue); + RefineOrPlaceCoefficient(block, ref k, rp, r, newValue); } } /// - /// Walks coefficients from through , - /// refining every already-nonzero coefficient, and counting down + /// Walks coefficients from through 's + /// end-of-band index, refining every already-nonzero coefficient, and counting down /// zero coefficients before placing /// (if nonzero) into the next zero coefficient slot and /// stopping. /// private static void RefineOrPlaceCoefficient( - int[] block, ref int k, int se, BitReader reader, int p1, int m1, int zeroRunLength, int newValue) + int[] block, ref int k, RefinementParams rp, int zeroRunLength, int newValue) { var r = zeroRunLength; - while (k <= se) + while (k <= rp.Se) { if (block[k] != 0) { - RefineNonZeroCoefficient(block, k, reader, p1, m1); + RefineNonZeroCoefficient(block, k, rp); } else {