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..f9676f15 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; - return AssembleCanvas(width, height, components, quantTables); + 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."); + + 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 (ss == 0) + if (context.Ss == 0) { - if (ah == 0) + DecodeProgressiveDc(block, component, reader, context.DcTables, context.Ah, context.Al); + return; + } + + var acTableProgressive = GetTable(context.AcTables, component.AcSelector, "AC"); + if (context.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; + } } } @@ -1292,77 +1424,129 @@ 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) { - while (k <= se) - { - var rs = BitReader.Decode(acTable, reader); - var r = rs >> 4; - var s = rs & 0xF; - var newValue = 0; + DecodeAcRefineNewCoefficients(block, acTable, rp, 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, rp); + eobRun--; + } + } - r = 64; // sentinel: skip remaining coefficients (refinement only) below - } - } - else - { - newValue = reader.ReadBit() != 0 ? p1 : m1; - } + /// + /// 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, RefinementParams rp) + { + if (block[k] != 0 && rp.Reader.ReadBit() != 0 && (block[k] & rp.P1) == 0) + { + block[k] += block[k] >= 0 ? rp.P1 : rp.M1; + } + } - while (k <= se) + /// + /// Refines every remaining nonzero coefficient from through + /// '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, RefinementParams rp) + { + while (k <= rp.Se) + { + RefineNonZeroCoefficient(block, k, rp); + 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, RefinementParams rp, ref int k, ref int eobRun) + { + while (k <= rp.Se) + { + var rs = BitReader.Decode(acTable, rp.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 += rp.Reader.ReadBits(r); } - k++; + r = 64; // sentinel: skip remaining coefficients (refinement only) below } } + else + { + newValue = rp.Reader.ReadBit() != 0 ? rp.P1 : rp.M1; + } + + RefineOrPlaceCoefficient(block, ref k, rp, r, newValue); } + } - if (eobRun > 0) + /// + /// 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, RefinementParams rp, int zeroRunLength, int newValue) + { + var r = zeroRunLength; + while (k <= rp.Se) { - while (k <= se) + if (block[k] != 0) + { + RefineNonZeroCoefficient(block, k, rp); + } + 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 +1599,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 +1647,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 +1752,6 @@ private static Surface AssembleCanvas(int width, int height, Component[] compone rowBytes[idx + 3] = 255; } } - - return surface; } } @@ -1612,9 +1839,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 +1930,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 +1969,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/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/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..ebbae256 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs @@ -29,6 +29,19 @@ public class JpegFixtureTests /// private static string PngSuitePath => Path.Combine(AppContext.BaseDirectory, "PngSuite"); + /// + /// Resolves a fixture file within . The file names always + /// originate from this class's own TheoryData literals rather than external input, + /// 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 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) => + Path.Join(baseDirectory, fileName); + /// /// Every JPEG fixture file name, for tests that only need to prove successful loading with /// the expected dimensions. @@ -116,7 +129,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 +145,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 +158,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..e3ca45a6 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/PngSuiteTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/PngSuiteTests.cs @@ -43,6 +43,19 @@ public class PngSuiteTests /// private static string AssetsPath => Path.Combine(AppContext.BaseDirectory, "PngSuite"); + /// + /// Resolves a fixture file within . The file names always + /// originate from this class's own TheoryData literals rather than external input, + /// 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 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) => + Path.Join(baseDirectory, fileName); + /// /// PngSuite files within PngCodec's supported feature set (color type 2 or 6, 8-bit, /// non-interlaced) that must load successfully. @@ -255,7 +268,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 +285,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 +297,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..fc131b35 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffFixtureTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffFixtureTests.cs @@ -31,6 +31,19 @@ public class TiffFixtureTests /// private static string PngSuitePath => Path.Combine(AppContext.BaseDirectory, "PngSuite"); + /// + /// Resolves a fixture file within . The file names always + /// originate from this class's own TheoryData literals rather than external input, + /// 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 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) => + Path.Join(baseDirectory, fileName); + /// /// Every TIFF fixture file name, for tests that only need to prove successful loading with /// the expected dimensions. @@ -106,7 +119,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 +134,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 +161,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 +184,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); 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 +