diff --git a/lint.ps1 b/lint.ps1 index 89b344dc..a2bf2703 100644 --- a/lint.ps1 +++ b/lint.ps1 @@ -136,8 +136,63 @@ dotnet restore > $null if ($LASTEXITCODE -ne 0) { $lintError = $true; $skipDotnetFormat = $true } if (-not $skipDotnetFormat) { - dotnet format --verify-no-changes --no-restore - if ($LASTEXITCODE -ne 0) { $lintError = $true } + # NOTE: `dotnet format --verify-no-changes` has a known cross-platform exit-code + # bug (e.g. dotnet/sdk#41422) where it can report a non-zero exit code even when + # zero files require formatting, inconsistently between operating systems. To get + # a reliable check, format in place and use `git diff` as the source of truth. + # + # A plain `git diff` after formatting would also include any pre-existing + # uncommitted *.cs changes unrelated to formatting (e.g. a developer's + # in-progress edits), causing false failures. Stash those away first so the + # formatter runs against a clean tree and the diff reflects only its own + # changes, then restore the original working tree afterward regardless of + # outcome, leaving no side effects from running this check. + # + # `git status --porcelain` (rather than `git diff`) is used to detect + # pre-existing changes so that untracked *.cs files are also caught and + # stashed - otherwise an untracked file reformatted by `dotnet format` + # would be left modified with no diff ever having been checked against it. + # `--index` on the pop restores the original staged/unstaged split rather + # than flattening everything into the working tree. + $statusBeforeFormat = git status --porcelain -- '*.cs' + $hasPreexistingCsChanges = [bool]$statusBeforeFormat + if ($hasPreexistingCsChanges) { + git stash push --include-untracked --quiet --message "lint.ps1: pre-existing *.cs changes" -- '*.cs' + if ($LASTEXITCODE -ne 0) { + $lintError = $true + $skipDotnetFormat = $true + Write-Host "Failed to stash pre-existing *.cs changes; skipping dotnet format check." + $hasPreexistingCsChanges = $false + } + } +} + +if (-not $skipDotnetFormat) { + dotnet format --no-restore + if ($LASTEXITCODE -ne 0) { + $lintError = $true + } + else { + git diff --exit-code -- '*.cs' + if ($LASTEXITCODE -ne 0) { + $lintError = $true + Write-Host "dotnet format made changes; run fix.ps1 locally and commit the results." + } + } + + # Restore the working tree to its original state: discard any in-place + # formatting changes made purely for this check, then reapply the + # developer's original uncommitted *.cs changes (if any were stashed). + # `--index` restores the original staged/unstaged split rather than + # dropping everything into the working tree unstaged. + git checkout -- '*.cs' 2>$null + if ($hasPreexistingCsChanges) { + git stash pop --index --quiet + if ($LASTEXITCODE -ne 0) { + $lintError = $true + Write-Host "Failed to restore stashed *.cs changes; check 'git stash list' and resolve manually." + } + } } # [PROJECT-SPECIFIC] Add additional format verification checks here. diff --git a/src/DemaConsulting.CanvasNet/Canvas/Rgba32.cs b/src/DemaConsulting.CanvasNet/Canvas/Rgba32.cs index 7b74ee95..1c53d97f 100644 --- a/src/DemaConsulting.CanvasNet/Canvas/Rgba32.cs +++ b/src/DemaConsulting.CanvasNet/Canvas/Rgba32.cs @@ -238,6 +238,11 @@ private static bool TryParseCore(string s, out Rgba32 result, out string? reason } /// Returns whether is a valid ASCII hexadecimal digit. - private static bool IsHexDigit(char c) => - (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + /// + /// Delegates to rather than a hand-written combination + /// of range comparisons - it is exactly equivalent (true for '0'-'9', + /// 'a'-'f', and 'A'-'F') while avoiding a hard-to-scan, multi-operator + /// boolean expression. + /// + private static bool IsHexDigit(char c) => char.IsAsciiHexDigit(c); } diff --git a/src/DemaConsulting.CanvasNet/Canvas/Surface.cs b/src/DemaConsulting.CanvasNet/Canvas/Surface.cs index ce9b2186..21f4502d 100644 --- a/src/DemaConsulting.CanvasNet/Canvas/Surface.cs +++ b/src/DemaConsulting.CanvasNet/Canvas/Surface.cs @@ -768,7 +768,7 @@ public void CompositeOverSpan(int y, int x, ReadOnlySpan coverage, Rgba32 using var fg = new RowChannelBuffers(count); using var work = new CompositeWorkBuffers(count); - CompositeOverSpanCore(y, x, coverage, color, bg, fg, work, count); + CompositeOverSpanCore(y, x, coverage, color, bg, fg, work); } /// @@ -825,7 +825,7 @@ internal void CompositeOverSpan(int y, int x, ReadOnlySpan coverage, Rgba nameof(coverage), coverage.Length, "Coverage length must not exceed the workspace's capacity."); } - CompositeOverSpanCore(y, x, coverage, color, workspace.Bg, workspace.Fg, workspace.Work, count); + CompositeOverSpanCore(y, x, coverage, color, workspace.Bg, workspace.Fg, workspace.Work); } /// @@ -886,7 +886,7 @@ public void CompositeOverSpan(int y, int x, ReadOnlySpan coverage, ReadOn using var fg = new RowChannelBuffers(count); using var work = new CompositeWorkBuffers(count); - CompositeOverSpanCore(y, x, coverage, colors, bg, fg, work, count); + CompositeOverSpanCore(y, x, coverage, colors, bg, fg, work); } /// @@ -945,7 +945,7 @@ internal void CompositeOverSpan(int y, int x, ReadOnlySpan coverage, Read nameof(coverage), coverage.Length, "Coverage length must not exceed the workspace's capacity."); } - CompositeOverSpanCore(y, x, coverage, colors, workspace.Bg, workspace.Fg, workspace.Work, count); + CompositeOverSpanCore(y, x, coverage, colors, workspace.Bg, workspace.Fg, workspace.Work); } /// @@ -988,8 +988,9 @@ private void ValidateCompositeOverSpanArgs(int y, int x, int coverageLength) /// private void CompositeOverSpanCore( int y, int x, ReadOnlySpan coverage, Rgba32 color, RowChannelBuffers bg, RowChannelBuffers fg, - CompositeWorkBuffers work, int count) + CompositeWorkBuffers work) { + var count = coverage.Length; for (var i = 0; i < count; i++) { fg.RBytes[i] = color.R; @@ -999,19 +1000,20 @@ private void CompositeOverSpanCore( fg.ABytes[i] = (byte)Math.Clamp(MathF.Round(scaledAlpha, MidpointRounding.AwayFromZero), 0f, 255f); } - CompositeOverSpanCoreShared(y, x, coverage, bg, fg, work, count); + CompositeOverSpanCoreShared(y, x, coverage, bg, fg, work); } /// - /// Per-pixel-color counterpart of : + /// Per-pixel-color counterpart of : /// populates from one per pixel in /// , rather than broadcasting a single constant color, then /// shares the exact same blend body. /// private void CompositeOverSpanCore( int y, int x, ReadOnlySpan coverage, ReadOnlySpan colors, RowChannelBuffers bg, RowChannelBuffers fg, - CompositeWorkBuffers work, int count) + CompositeWorkBuffers work) { + var count = coverage.Length; for (var i = 0; i < count; i++) { var color = colors[i]; @@ -1022,7 +1024,7 @@ private void CompositeOverSpanCore( fg.ABytes[i] = (byte)Math.Clamp(MathF.Round(scaledAlpha, MidpointRounding.AwayFromZero), 0f, 255f); } - CompositeOverSpanCoreShared(y, x, coverage, bg, fg, work, count); + CompositeOverSpanCoreShared(y, x, coverage, bg, fg, work); } /// @@ -1032,12 +1034,16 @@ private void CompositeOverSpanCore( /// pipeline, and writes the result back into the surface /// via . must already be populated (its /// byte channels only - this method widens it to float itself) by the caller before this - /// method is invoked. + /// method is invoked. 's length is always the row's active + /// pixel count - it is not passed separately since every caller derives it from the very + /// same span before renting // + /// to that exact size. /// private void CompositeOverSpanCoreShared( int y, int x, ReadOnlySpan coverage, RowChannelBuffers bg, RowChannelBuffers fg, - CompositeWorkBuffers work, int count) + CompositeWorkBuffers work) { + var count = coverage.Length; var bgRow = GetRowSpanBytes(y).Slice(x * BytesPerPixel, count * BytesPerPixel); DeinterleaveRow(bgRow, bg, count); @@ -1197,9 +1203,9 @@ private static void CompositeOverRow(RowChannelBuffers bg, RowChannelBuffers fg, TensorPrimitives.Multiply(bgAn, oneMinusFgAn, outAn); TensorPrimitives.Add(outAn, fgAn, outAn); - CompositeOverChannel(bg.RFloat, fg.RFloat, bgAn, fgAn, oneMinusFgAn, outAn, work.Term, work.OutR, count); - CompositeOverChannel(bg.GFloat, fg.GFloat, bgAn, fgAn, oneMinusFgAn, outAn, work.Term, work.OutG, count); - CompositeOverChannel(bg.BFloat, fg.BFloat, bgAn, fgAn, oneMinusFgAn, outAn, work.Term, work.OutB, count); + CompositeOverChannel(bg.RFloat, fg.RFloat, work, work.OutR, count); + CompositeOverChannel(bg.GFloat, fg.GFloat, work, work.OutG, count); + CompositeOverChannel(bg.BFloat, fg.BFloat, work, work.OutB, count); // Only now scale the normalized outA up to [0, 255] for narrowing - every channel above // divided by the still-normalized outAn. @@ -1208,17 +1214,22 @@ private static void CompositeOverRow(RowChannelBuffers bg, RowChannelBuffers fg, /// /// Computes (fgC * fgA + bgC * bgA * (1 - fgA)) / outA for one [0, 255]- - /// scaled color channel of a whole row, where the alpha terms (, - /// , ) are [0, 1]-scaled. + /// scaled color channel of a whole row, reading the alpha terms (BgAn/FgAn/ + /// OneMinusFgA/OutA, all [0, 1]-scaled) and the Term scratch + /// buffer directly from - the same + /// instance already + /// populated them into, rather than re-passing each of its fields as its own parameter. /// - private static void CompositeOverChannel( - float[] bgC, float[] fgC, Span bgAn, Span fgAn, Span oneMinusFgAn, Span outAn, - float[] termScratch, float[] outC, int count) + private static void CompositeOverChannel(float[] bgC, float[] fgC, CompositeWorkBuffers work, float[] outC, int count) { + var bgAn = work.BgAn.AsSpan(0, count); + var fgAn = work.FgAn.AsSpan(0, count); + var oneMinusFgAn = work.OneMinusFgA.AsSpan(0, count); + var outAn = work.OutA.AsSpan(0, count); var bgCSpan = bgC.AsSpan(0, count); var fgCSpan = fgC.AsSpan(0, count); var outCSpan = outC.AsSpan(0, count); - var termSpan = termScratch.AsSpan(0, count); + var termSpan = work.Term.AsSpan(0, count); // outC = fgC * fgA TensorPrimitives.Multiply(fgCSpan, fgAn, outCSpan); diff --git a/src/DemaConsulting.CanvasNet/Codecs/GifCodec.cs b/src/DemaConsulting.CanvasNet/Codecs/GifCodec.cs index 0098a026..d3024dae 100644 --- a/src/DemaConsulting.CanvasNet/Codecs/GifCodec.cs +++ b/src/DemaConsulting.CanvasNet/Codecs/GifCodec.cs @@ -290,27 +290,16 @@ public static Surface Load(Stream stream) } result = new Surface(canvasWidth, canvasHeight); - for (var row = 0; row < imgHeight; row++) - { - var rowSpan = result.GetRowSpan(top + row); - var rowOffset = row * imgWidth; - for (var col = 0; col < imgWidth; col++) - { - var index = indices[rowOffset + col]; - if (index >= activeColorTable.Length) - { - throw new InvalidDataException( - $"GIF pixel index {index} is out of range for its color table " + - $"({activeColorTable.Length} entries)."); - } - - var color = activeColorTable[index]; - var alpha = (byte)(pendingTransparencyFlag && index == pendingTransparentIndex - ? 0 - : 255); - rowSpan[left + col] = new Rgba32(color.R, color.G, color.B, alpha); - } - } + BlitIndexedFrame( + result, + indices, + activeColorTable, + left, + top, + imgWidth, + imgHeight, + pendingTransparencyFlag, + pendingTransparentIndex); } pendingTransparencyFlag = false; @@ -365,6 +354,60 @@ public static Surface Load(string path) return Load(stream); } + /// + /// Resolves one frame's decoded palette indices to RGBA pixels via + /// , writing them into at the + /// frame's declared placement, honoring the frame's transparent color index if any. + /// + /// + /// Isolated from as its own self-contained blit step - resolving + /// an indexed frame's pixels is a distinct, independently testable operation from the + /// surrounding GIF block/stream parsing that produces its inputs. + /// + /// The surface to write pixels into. Must already be sized to contain the frame's placement. + /// The frame's decoded palette indices, one per pixel, in row-major order. + /// The color table (local or global) to resolve each index against. + /// The frame's left placement offset within . + /// The frame's top placement offset within . + /// The frame's width, in pixels. + /// The frame's height, in pixels. + /// Whether should be rendered fully transparent. + /// The palette index treated as transparent when is set. + /// + /// Thrown when a decoded index has no corresponding entry in . + /// + private static void BlitIndexedFrame( + Surface destination, + byte[] indices, + Rgba32[] colorTable, + int left, + int top, + int width, + int height, + bool transparencyFlag, + byte transparentIndex) + { + for (var row = 0; row < height; row++) + { + var rowSpan = destination.GetRowSpan(top + row); + var rowOffset = row * width; + for (var col = 0; col < width; col++) + { + var index = indices[rowOffset + col]; + if (index >= colorTable.Length) + { + throw new InvalidDataException( + $"GIF pixel index {index} is out of range for its color table " + + $"({colorTable.Length} entries)."); + } + + var color = colorTable[index]; + var alpha = (byte)(transparencyFlag && index == transparentIndex ? 0 : 255); + rowSpan[left + col] = new Rgba32(color.R, color.G, color.B, alpha); + } + } + } + /// /// Reads a GIF file's Logical Screen Descriptor and reports its declared dimensions, /// without reading any color table, block, or pixel data. diff --git a/src/DemaConsulting.CanvasNet/Codecs/PngCodec.cs b/src/DemaConsulting.CanvasNet/Codecs/PngCodec.cs index 397416f5..4037711f 100644 --- a/src/DemaConsulting.CanvasNet/Codecs/PngCodec.cs +++ b/src/DemaConsulting.CanvasNet/Codecs/PngCodec.cs @@ -357,10 +357,7 @@ public static Surface Load(Stream stream) return DecodeScanlines( rawData, - header.Width, - header.Height, - header.ColorType, - header.BitDepth, + header, rowBytes, bpp, samplesPerPixel, @@ -660,92 +657,11 @@ private static void ProcessChunk(Stream stream, MemoryStream idatStream, ChunkRe } else if (ChunkTypeIs(typeBytes, "PLTE")) { - if (state.PlteSeen) - { - throw new InvalidDataException("Duplicate PLTE chunk."); - } - - if (state.IdatSeen) - { - throw new InvalidDataException("PLTE chunk encountered after the first IDAT chunk."); - } - - // The PNG specification requires PLTE to precede tRNS whenever both are present, - // regardless of color type: a tRNS chunk that has already been accepted means a PLTE - // chunk arriving afterward is out of order, even for color types (2 and 6) where - // PLTE is merely an optional suggested palette rather than mandatory - if (state.TrnsSeen) - { - throw new InvalidDataException("PLTE chunk must precede tRNS chunk."); - } - - // ValidateChunkLengthBeforeAllocation already bounds the declared length to at most - // 256 entries (768 bytes), so only the multiple-of-3/non-empty shape remains to check - if (data.Length % 3 != 0 || data.Length == 0) - { - throw new InvalidDataException( - $"Invalid PNG PLTE chunk length {data.Length}; expected a positive multiple of 3."); - } - - // The PNG specification forbids a PLTE chunk for the two grayscale color types (0 and - // 4): grayscale samples are never resolved through a palette, so a PLTE chunk on such - // a file cannot represent anything a conforming decoder is permitted to use - if (state.ColorType is ColorTypeGrayscale or ColorTypeGrayscaleAlpha) - { - throw new InvalidDataException( - $"PLTE chunk is not permitted for grayscale PNG color type {state.ColorType}."); - } - - if (state.ColorType == ColorTypePalette) - { - var maxEntries = 1 << state.BitDepth; - var entryCount = data.Length / 3; - if (entryCount > maxEntries) - { - throw new InvalidDataException( - $"PNG PLTE chunk declares {entryCount} palette entries, which exceeds the maximum of " + - $"{maxEntries} entries permitted for a palette (color type 3) image at bit depth " + - $"{state.BitDepth}."); - } - } - - state.PlteData = data; - state.PlteSeen = true; + ProcessPlteChunk(data, state); } else if (ChunkTypeIs(typeBytes, "tRNS")) { - if (state.TrnsSeen) - { - throw new InvalidDataException("Duplicate tRNS chunk."); - } - - if (state.IdatSeen) - { - throw new InvalidDataException("tRNS chunk encountered after the first IDAT chunk."); - } - - // The PNG specification forbids a tRNS chunk for the two color types that already - // carry a full per-pixel alpha channel (grayscale-with-alpha and Truecolor-with-alpha): - // there is nothing for a single-key-color transparency chunk to add for those formats, - // so its presence indicates a malformed, non-conforming file rather than a feature to - // silently ignore - if (state.ColorType is ColorTypeGrayscaleAlpha or ColorTypeTruecolorAlpha) - { - throw new InvalidDataException( - $"tRNS chunk is not permitted for PNG color type {state.ColorType}, which already " + - "carries a full per-pixel alpha channel."); - } - - // For indexed-color (palette) images, tRNS's per-palette-entry alpha values are - // meaningless without the PLTE chunk they index into, so the specification requires - // tRNS to appear after PLTE, not merely after IHDR and before the first IDAT - if (state.ColorType == ColorTypePalette && !state.PlteSeen) - { - throw new InvalidDataException("tRNS chunk for indexed-color PNG must follow PLTE."); - } - - state.TrnsData = data; - state.TrnsSeen = true; + ProcessTrnsChunk(data, state); } else if (ChunkTypeIs(typeBytes, "IDAT")) { @@ -772,6 +688,132 @@ private static void ProcessChunk(Stream stream, MemoryStream idatStream, ChunkRe // all, so no further action is required. } + /// + /// Validates and records a PLTE chunk's payload into , + /// enforcing every content-dependent PLTE rule that requires the chunk's actual payload + /// (ordering relative to tRNS/IDAT, entry-count shape, and color-type/bit-depth + /// compatibility). + /// + /// + /// Isolated from as its own self-contained validation step, + /// independently nameable and testable from the surrounding chunk-type dispatch and from + /// 's equivalent tRNS validation. + /// + /// The chunk's payload bytes. + /// The in-progress chunk-read state, updated with the accepted palette data. + /// + /// Thrown for a duplicate PLTE chunk, a PLTE chunk following tRNS or the first IDAT, an + /// invalid entry-count shape, a PLTE chunk on a grayscale color type, or a PLTE chunk + /// declaring more entries than its bit depth permits. + /// + private static void ProcessPlteChunk(byte[] data, ChunkReadState state) + { + if (state.PlteSeen) + { + throw new InvalidDataException("Duplicate PLTE chunk."); + } + + if (state.IdatSeen) + { + throw new InvalidDataException("PLTE chunk encountered after the first IDAT chunk."); + } + + // The PNG specification requires PLTE to precede tRNS whenever both are present, + // regardless of color type: a tRNS chunk that has already been accepted means a PLTE + // chunk arriving afterward is out of order, even for color types (2 and 6) where + // PLTE is merely an optional suggested palette rather than mandatory + if (state.TrnsSeen) + { + throw new InvalidDataException("PLTE chunk must precede tRNS chunk."); + } + + // ValidateChunkLengthBeforeAllocation already bounds the declared length to at most + // 256 entries (768 bytes), so only the multiple-of-3/non-empty shape remains to check + if (data.Length % 3 != 0 || data.Length == 0) + { + throw new InvalidDataException( + $"Invalid PNG PLTE chunk length {data.Length}; expected a positive multiple of 3."); + } + + // The PNG specification forbids a PLTE chunk for the two grayscale color types (0 and + // 4): grayscale samples are never resolved through a palette, so a PLTE chunk on such + // a file cannot represent anything a conforming decoder is permitted to use + if (state.ColorType is ColorTypeGrayscale or ColorTypeGrayscaleAlpha) + { + throw new InvalidDataException( + $"PLTE chunk is not permitted for grayscale PNG color type {state.ColorType}."); + } + + if (state.ColorType == ColorTypePalette) + { + var maxEntries = 1 << state.BitDepth; + var entryCount = data.Length / 3; + if (entryCount > maxEntries) + { + throw new InvalidDataException( + $"PNG PLTE chunk declares {entryCount} palette entries, which exceeds the maximum of " + + $"{maxEntries} entries permitted for a palette (color type 3) image at bit depth " + + $"{state.BitDepth}."); + } + } + + state.PlteData = data; + state.PlteSeen = true; + } + + /// + /// Validates and records a tRNS chunk's payload into , + /// enforcing every content-dependent tRNS rule that requires the chunk's actual payload + /// (ordering relative to PLTE/IDAT and color-type compatibility). + /// + /// + /// Isolated from as its own self-contained validation step, + /// independently nameable and testable from the surrounding chunk-type dispatch and from + /// 's equivalent PLTE validation. + /// + /// The chunk's payload bytes. + /// The in-progress chunk-read state, updated with the accepted transparency data. + /// + /// Thrown for a duplicate tRNS chunk, a tRNS chunk following the first IDAT, a tRNS chunk + /// on a color type that already carries full per-pixel alpha, or an indexed-color tRNS + /// chunk preceding PLTE. + /// + private static void ProcessTrnsChunk(byte[] data, ChunkReadState state) + { + if (state.TrnsSeen) + { + throw new InvalidDataException("Duplicate tRNS chunk."); + } + + if (state.IdatSeen) + { + throw new InvalidDataException("tRNS chunk encountered after the first IDAT chunk."); + } + + // The PNG specification forbids a tRNS chunk for the two color types that already + // carry a full per-pixel alpha channel (grayscale-with-alpha and Truecolor-with-alpha): + // there is nothing for a single-key-color transparency chunk to add for those formats, + // so its presence indicates a malformed, non-conforming file rather than a feature to + // silently ignore + if (state.ColorType is ColorTypeGrayscaleAlpha or ColorTypeTruecolorAlpha) + { + throw new InvalidDataException( + $"tRNS chunk is not permitted for PNG color type {state.ColorType}, which already " + + "carries a full per-pixel alpha channel."); + } + + // For indexed-color (palette) images, tRNS's per-palette-entry alpha values are + // meaningless without the PLTE chunk they index into, so the specification requires + // tRNS to appear after PLTE, not merely after IHDR and before the first IDAT + if (state.ColorType == ColorTypePalette && !state.PlteSeen) + { + throw new InvalidDataException("tRNS chunk for indexed-color PNG must follow PLTE."); + } + + state.TrnsData = data; + state.TrnsSeen = true; + } + /// /// This codec's single authoritative gate for every chunk-ordering/identity rule that /// does not depend on a chunk's payload content, invoked by @@ -1248,30 +1290,25 @@ private static (byte[] TypeBytes, byte[] Data) ReadIhdrChunkFrame(Stream stream) /// Defilters and decodes every scanline of decompressed PNG raw data into a new /// , reconstructing each row from the previous row per the PNG /// filtering specification, then mapping each row's samples to RGBA per - /// . + /// 's color type. /// /// The decompressed, filtered scanline bytes (one filter-type byte plus per row). - /// The image width, in pixels. - /// The image height, in pixels. - /// The PNG color type. - /// The PNG bit depth (1, 2, 4, 8, or 16). + /// The parsed IHDR fields (width, height, color type, and bit depth). /// The number of packed pixel bytes per row (excluding the filter-type byte). /// The number of whole bytes per pixel, used by the defilter algorithms (at least 1). - /// The number of samples per pixel for . + /// The number of samples per pixel for 's color type. /// The raw PLTE chunk data (RGB triples), or null if absent. /// The validated tRNS chunk data for this color type, or null if absent/not applicable. private static Surface DecodeScanlines( byte[] rawData, - int width, - int height, - int colorType, - int bitDepth, + PngHeader header, int rowBytes, int bpp, int samplesPerPixel, byte[]? palette, byte[]? trns) { + var (width, height, colorType, bitDepth, _) = header; var surface = new Surface(width, height); var previousRow = new byte[rowBytes]; var currentRow = new byte[rowBytes]; @@ -1374,106 +1411,160 @@ private static void MapSamplesToRgba( byte[]? trns, Span destination) { - var maxSample = (1 << bitDepth) - 1; - switch (colorType) { case ColorTypeGrayscale: - { - var trnsGray = trns != null ? ReadUInt16Be(trns, 0) : -1; - for (var x = 0; x < width; x++) - { - var raw = samples[x]; - var isTransparent = raw == trnsGray; - var gray = bitDepth == 16 ? (byte)(raw >> 8) : (byte)(raw * 255 / maxSample); - var d = x * 4; - destination[d] = gray; - destination[d + 1] = gray; - destination[d + 2] = gray; - destination[d + 3] = (byte)(isTransparent ? 0 : 255); - } - - break; - } + MapGrayscaleSamples(samples, width, bitDepth, trns, destination); + break; case ColorTypeTruecolor: - { - var hasTrns = trns != null; - var trnsR = hasTrns ? ReadUInt16Be(trns!, 0) : -1; - var trnsG = hasTrns ? ReadUInt16Be(trns!, 2) : -1; - var trnsB = hasTrns ? ReadUInt16Be(trns!, 4) : -1; - for (var x = 0; x < width; x++) - { - var s = x * 3; - var r = samples[s]; - var g = samples[s + 1]; - var b = samples[s + 2]; - var isTransparent = r == trnsR && g == trnsG && b == trnsB; - var d = x * 4; - destination[d] = bitDepth == 16 ? (byte)(r >> 8) : (byte)r; - destination[d + 1] = bitDepth == 16 ? (byte)(g >> 8) : (byte)g; - destination[d + 2] = bitDepth == 16 ? (byte)(b >> 8) : (byte)b; - destination[d + 3] = (byte)(isTransparent ? 0 : 255); - } - - break; - } + MapTruecolorSamples(samples, width, bitDepth, trns, destination); + break; case ColorTypePalette: - { - var entries = palette!.Length / 3; - for (var x = 0; x < width; x++) - { - var index = samples[x]; - if (index >= entries) - { - throw new InvalidDataException( - $"PNG palette index {index} is out of range for a {entries}-entry PLTE chunk."); - } - - var p = index * 3; - var d = x * 4; - destination[d] = palette[p]; - destination[d + 1] = palette[p + 1]; - destination[d + 2] = palette[p + 2]; - destination[d + 3] = trns != null && index < trns.Length ? trns[index] : (byte)255; - } - - break; - } + MapPaletteSamples(samples, width, palette!, trns, destination); + break; case ColorTypeGrayscaleAlpha: - { - for (var x = 0; x < width; x++) - { - var s = x * 2; - var gray = samples[s]; - var alpha = samples[s + 1]; - var d = x * 4; - var grayByte = bitDepth == 16 ? (byte)(gray >> 8) : (byte)gray; - destination[d] = grayByte; - destination[d + 1] = grayByte; - destination[d + 2] = grayByte; - destination[d + 3] = bitDepth == 16 ? (byte)(alpha >> 8) : (byte)alpha; - } - - break; - } + MapGrayscaleAlphaSamples(samples, width, bitDepth, destination); + break; case ColorTypeTruecolorAlpha: - { - for (var x = 0; x < width; x++) - { - var s = x * 4; - var d = x * 4; - destination[d] = bitDepth == 16 ? (byte)(samples[s] >> 8) : (byte)samples[s]; - destination[d + 1] = bitDepth == 16 ? (byte)(samples[s + 1] >> 8) : (byte)samples[s + 1]; - destination[d + 2] = bitDepth == 16 ? (byte)(samples[s + 2] >> 8) : (byte)samples[s + 2]; - destination[d + 3] = bitDepth == 16 ? (byte)(samples[s + 3] >> 8) : (byte)samples[s + 3]; - } - - break; - } + MapTruecolorAlphaSamples(samples, width, bitDepth, destination); + break; + } + } + + /// + /// Maps one row's grayscale samples (color type 0) to RGBA, resolving the single-key-color + /// transparency chunk (if any) against the raw, not-yet-downshifted + /// sample value. + /// + /// + /// Isolated from as its own color-type-specific mapping + /// step - each PNG color type maps its samples to RGBA via an independent, self-contained + /// per-pixel formula, so extracting one per color type keeps every formula independently + /// nameable and testable rather than folding all five into one large switch body. + /// + private static void MapGrayscaleSamples(ReadOnlySpan samples, int width, int bitDepth, byte[]? trns, Span destination) + { + var maxSample = (1 << bitDepth) - 1; + var trnsGray = trns != null ? ReadUInt16Be(trns, 0) : -1; + for (var x = 0; x < width; x++) + { + var raw = samples[x]; + var isTransparent = raw == trnsGray; + var gray = bitDepth == 16 ? (byte)(raw >> 8) : (byte)(raw * 255 / maxSample); + var d = x * 4; + destination[d] = gray; + destination[d + 1] = gray; + destination[d + 2] = gray; + destination[d + 3] = (byte)(isTransparent ? 0 : 255); + } + } + + /// + /// Maps one row's Truecolor samples (color type 2) to RGBA, resolving the single-key-color + /// transparency chunk (if any) against the raw, not-yet-downshifted + /// RGB sample triple. + /// + /// + /// See 's remarks for why each color type has its own + /// extracted mapping method. + /// + private static void MapTruecolorSamples(ReadOnlySpan samples, int width, int bitDepth, byte[]? trns, Span destination) + { + var hasTrns = trns != null; + var trnsR = hasTrns ? ReadUInt16Be(trns!, 0) : -1; + var trnsG = hasTrns ? ReadUInt16Be(trns!, 2) : -1; + var trnsB = hasTrns ? ReadUInt16Be(trns!, 4) : -1; + for (var x = 0; x < width; x++) + { + var s = x * 3; + var r = samples[s]; + var g = samples[s + 1]; + var b = samples[s + 2]; + var isTransparent = r == trnsR && g == trnsG && b == trnsB; + var d = x * 4; + destination[d] = bitDepth == 16 ? (byte)(r >> 8) : (byte)r; + destination[d + 1] = bitDepth == 16 ? (byte)(g >> 8) : (byte)g; + destination[d + 2] = bitDepth == 16 ? (byte)(b >> 8) : (byte)b; + destination[d + 3] = (byte)(isTransparent ? 0 : 255); + } + } + + /// + /// Maps one row's palette indices (color type 3) to RGBA via , + /// resolving each index's per-entry alpha from (if any). + /// + /// + /// See 's remarks for why each color type has its own + /// extracted mapping method. + /// + /// + /// Thrown when a sample's palette index has no corresponding entry. + /// + private static void MapPaletteSamples(ReadOnlySpan samples, int width, byte[] palette, byte[]? trns, Span destination) + { + var entries = palette.Length / 3; + for (var x = 0; x < width; x++) + { + var index = samples[x]; + if (index >= entries) + { + throw new InvalidDataException( + $"PNG palette index {index} is out of range for a {entries}-entry PLTE chunk."); + } + + var p = index * 3; + var d = x * 4; + destination[d] = palette[p]; + destination[d + 1] = palette[p + 1]; + destination[d + 2] = palette[p + 2]; + destination[d + 3] = trns != null && index < trns.Length ? trns[index] : (byte)255; + } + } + + /// + /// Maps one row's grayscale-with-alpha samples (color type 4) to RGBA. + /// + /// + /// See 's remarks for why each color type has its own + /// extracted mapping method. + /// + private static void MapGrayscaleAlphaSamples(ReadOnlySpan samples, int width, int bitDepth, Span destination) + { + for (var x = 0; x < width; x++) + { + var s = x * 2; + var gray = samples[s]; + var alpha = samples[s + 1]; + var d = x * 4; + var grayByte = bitDepth == 16 ? (byte)(gray >> 8) : (byte)gray; + destination[d] = grayByte; + destination[d + 1] = grayByte; + destination[d + 2] = grayByte; + destination[d + 3] = bitDepth == 16 ? (byte)(alpha >> 8) : (byte)alpha; + } + } + + /// + /// Maps one row's Truecolor-with-alpha samples (color type 6) to RGBA. + /// + /// + /// See 's remarks for why each color type has its own + /// extracted mapping method. + /// + private static void MapTruecolorAlphaSamples(ReadOnlySpan samples, int width, int bitDepth, Span destination) + { + for (var x = 0; x < width; x++) + { + var s = x * 4; + var d = x * 4; + destination[d] = bitDepth == 16 ? (byte)(samples[s] >> 8) : (byte)samples[s]; + destination[d + 1] = bitDepth == 16 ? (byte)(samples[s + 1] >> 8) : (byte)samples[s + 1]; + destination[d + 2] = bitDepth == 16 ? (byte)(samples[s + 2] >> 8) : (byte)samples[s + 2]; + destination[d + 3] = bitDepth == 16 ? (byte)(samples[s + 3] >> 8) : (byte)samples[s + 3]; } } diff --git a/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs b/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs index d760d68c..15270cfa 100644 --- a/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs +++ b/src/DemaConsulting.CanvasNet/Codecs/SvgCodec.cs @@ -942,7 +942,7 @@ private sealed record RenderContext( /// codec implements no scripting/animation support that could redefine a gradient's /// stops mid-render. /// - public Dictionary> GradientStopCache { get; } = new(); + public Dictionary> GradientStopCache { get; } = []; } // ================================================================================================ @@ -1274,7 +1274,7 @@ _ when string.Equals(dashArrayAttr.Trim(), "none", StringComparison.OrdinalIgnor /// /// The attribute's raw, non-"none" value. /// The parsed dash array, or for an effectively-solid stroke. - private static IReadOnlyList? ParseDashArray(string raw) + private static List? ParseDashArray(string raw) { var numbers = ParseNumberList(raw); return numbers.Count == 0 || numbers.Exists(v => v < 0f) || numbers.TrueForAll(v => v == 0f) @@ -1497,7 +1497,7 @@ private static bool TryReadTransformFunction(string raw, ref int position, out M /// Thrown when is not recognized, or /// does not have one of the argument counts that function accepts. /// - private static Matrix3x2 BuildTransformFunction(string name, IReadOnlyList arguments) => name switch + private static Matrix3x2 BuildTransformFunction(string name, List arguments) => name switch { "translate" => BuildTranslate(arguments), "scale" => BuildScale(arguments), @@ -1518,7 +1518,7 @@ private static bool TryReadTransformFunction(string raw, ref int position, out M /// The function's parsed arguments (one or two numbers). /// The translation matrix. /// Thrown when the argument count is not one or two. - private static Matrix3x2 BuildTranslate(IReadOnlyList arguments) => arguments.Count switch + private static Matrix3x2 BuildTranslate(List arguments) => arguments.Count switch { 1 => Matrix3x2.CreateTranslation(arguments[0], 0f), 2 => Matrix3x2.CreateTranslation(arguments[0], arguments[1]), @@ -1529,7 +1529,7 @@ private static bool TryReadTransformFunction(string raw, ref int position, out M /// The function's parsed arguments (one or two numbers). /// The scale matrix. /// Thrown when the argument count is not one or two. - private static Matrix3x2 BuildScale(IReadOnlyList arguments) => arguments.Count switch + private static Matrix3x2 BuildScale(List arguments) => arguments.Count switch { 1 => Matrix3x2.CreateScale(arguments[0], arguments[0]), 2 => Matrix3x2.CreateScale(arguments[0], arguments[1]), @@ -1540,7 +1540,7 @@ private static bool TryReadTransformFunction(string raw, ref int position, out M /// The function's parsed arguments (one or three numbers, angle in degrees). /// The rotation matrix, about the origin or about (cx, cy). /// Thrown when the argument count is not one or three. - private static Matrix3x2 BuildRotate(IReadOnlyList arguments) + private static Matrix3x2 BuildRotate(List arguments) { switch (arguments.Count) { @@ -2784,21 +2784,8 @@ private static float EstimateUniformScale(Matrix3x2 transform) => /// The local-space dash array, or for a solid stroke. /// The local-to-pixel-space scale factor. /// The scaled dash array, or if is . - private static IReadOnlyList? ScaleDashArray(IReadOnlyList? dashArray, float scale) - { - if (dashArray == null) - { - return null; - } - - var scaled = new float[dashArray.Count]; - for (var i = 0; i < dashArray.Count; i++) - { - scaled[i] = dashArray[i] * scale; - } - - return scaled; - } + private static float[]? ScaleDashArray(IReadOnlyList? dashArray, float scale) => + dashArray?.Select(value => value * scale).ToArray(); // ================================================================================================ // Paint and color resolution @@ -3403,16 +3390,8 @@ private static Rgba32 ParseStopColor(XElement stopElement) /// The source stops. /// The multiplier to apply to each stop's existing alpha. /// A new list of stops with their colors' alpha scaled. - private static List ApplyAlphaToStops(List stops, float multiplier) - { - var result = new List(stops.Count); - foreach (var stop in stops) - { - result.Add(new GradientStop(stop.Offset, ApplyAlpha(stop.Color, multiplier))); - } - - return result; - } + private static List ApplyAlphaToStops(List stops, float multiplier) => + stops.Select(stop => new GradientStop(stop.Offset, ApplyAlpha(stop.Color, multiplier))).ToList(); /// Parses a spreadMethod keyword. /// The attribute's raw value, or if absent. diff --git a/src/DemaConsulting.CanvasNet/Drawing/DashSplitter.cs b/src/DemaConsulting.CanvasNet/Drawing/DashSplitter.cs index 25946d91..89ba1259 100644 --- a/src/DemaConsulting.CanvasNet/Drawing/DashSplitter.cs +++ b/src/DemaConsulting.CanvasNet/Drawing/DashSplitter.cs @@ -251,13 +251,7 @@ private static float[] NormalizeDashArray(IReadOnlyList dashArray) { if (dashArray.Count % 2 == 0) { - var even = new float[dashArray.Count]; - for (var i = 0; i < dashArray.Count; i++) - { - even[i] = dashArray[i]; - } - - return even; + return dashArray.ToArray(); } var duplicated = new float[dashArray.Count * 2]; @@ -324,7 +318,7 @@ private static double GetPatternLength(IReadOnlyList pattern) /// its while (position < totalLength) loop forever. /// /// - private static double[] BuildCumulativeLengths(IReadOnlyList points, bool isClosed) + private static double[] BuildCumulativeLengths(List points, bool isClosed) { var edgeCount = isClosed ? points.Count : points.Count - 1; var cumulative = new double[edgeCount + 1]; @@ -547,31 +541,7 @@ private static (int Index, float RemainingInDash) LocatePhaseBackward( budgetExceeded = false; var intervals = new List<(double Start, double End)>(); - // Cheap, O(pattern.Count) pre-flight estimates: short-circuit straight to the - // cap-exceeded fallback for hopeless inputs (e.g. a huge-but-finite totalLength paired - // with a fine dash span, or a pattern whose "on" entries dominate its transitions) without - // ever entering the loop below - see this method's remarks for both cost models and their - // known, bounded slack. - var patternLength = GetPatternLength(pattern); - var positiveEntryCount = pattern.Count(entry => entry > 0f); - var estimatedIterations = 2d * positiveEntryCount * (totalLength / patternLength); - if (estimatedIterations > MaxOnIntervalIterations) - { - budgetExceeded = true; - return intervals; - } - - var onEntryCount = 0; - for (var i = 0; i < pattern.Count; i += 2) - { - if (pattern[i] > 0f) - { - onEntryCount++; - } - } - - var estimatedOnIntervalCount = onEntryCount * (totalLength / patternLength); - if (estimatedOnIntervalCount > MaxOnIntervalCount) + if (EstimatesExceedBudget(pattern, totalLength, out var patternLength)) { budgetExceeded = true; return intervals; @@ -642,6 +612,56 @@ private static (int Index, float RemainingInDash) LocatePhaseBackward( return intervals; } + /// + /// Cheap, O(pattern.Count) pre-flight cost estimate deciding whether + /// 's walking loop should even begin: short-circuits to the + /// cap-exceeded fallback for hopeless inputs (e.g. a huge-but-finite + /// paired with a fine dash span, or a pattern whose "on" entries dominate its transitions) + /// without ever entering the loop. + /// + /// + /// Isolated from as its own self-contained, independently + /// testable estimate - it is pure arithmetic over and + /// , with no dependency on the loop's own walking state. + /// Combines two independent estimates against two independent caps + /// ( and ) - see + /// 's own remarks for why both are necessary and for their + /// known, bounded slack. This is a heuristic estimate closely tracking the loop's real + /// cost model, not an exact prediction or a strict mathematical upper bound; the loop's + /// own running counters remain the authoritative backstop for any input either estimate + /// under-counts. + /// + /// The normalized dash pattern. + /// The polyline's total arc length. + /// Receives the pattern's total cycle length. + /// + /// if either estimate exceeds its cap and the walking loop should + /// be skipped entirely. + /// + private static bool EstimatesExceedBudget(IReadOnlyList pattern, double totalLength, out double patternLength) + { + patternLength = GetPatternLength(pattern); + + var positiveEntryCount = pattern.Count(entry => entry > 0f); + var estimatedIterations = 2d * positiveEntryCount * (totalLength / patternLength); + if (estimatedIterations > MaxOnIntervalIterations) + { + return true; + } + + var onEntryCount = 0; + for (var i = 0; i < pattern.Count; i += 2) + { + if (pattern[i] > 0f) + { + onEntryCount++; + } + } + + var estimatedOnIntervalCount = onEntryCount * (totalLength / patternLength); + return estimatedOnIntervalCount > MaxOnIntervalCount; + } + /// /// Advances to the next positive-length dash entry. /// @@ -670,9 +690,9 @@ private static void AdvanceDash(IReadOnlyList pattern, ref int dashIndex, /// number of polyline edges plus the number of intervals rather than their product. /// private static List ExtractIntervalPolyline( - IReadOnlyList points, + List points, bool isClosed, - IReadOnlyList cumulativeLengths, + double[] cumulativeLengths, double startDistance, double endDistance, ref int vertexCursor, @@ -681,7 +701,7 @@ private static List ExtractIntervalPolyline( var segment = new List(); AddPointIfDistinct(segment, GetPointAtDistance(points, isClosed, cumulativeLengths, startDistance, ref pointCursor)); - var edgeCount = cumulativeLengths.Count - 1; + var edgeCount = cumulativeLengths.Length - 1; // Advance past any vertex boundaries at or before startDistance (including the implicit // index 0 boundary, whose cumulative length is always zero). @@ -731,9 +751,9 @@ private static List ExtractIntervalPolyline( /// /// private static Vector2 GetPointAtDistance( - IReadOnlyList points, + List points, bool isClosed, - IReadOnlyList cumulativeLengths, + double[] cumulativeLengths, double distance, ref int edgeCursor) { @@ -748,7 +768,7 @@ private static Vector2 GetPointAtDistance( return isClosed ? points[0] : points[^1]; } - var edgeCount = cumulativeLengths.Count - 1; + var edgeCount = cumulativeLengths.Length - 1; while (edgeCursor < edgeCount - 1 && distance > cumulativeLengths[edgeCursor + 1]) { edgeCursor++; diff --git a/src/DemaConsulting.CanvasNet/Drawing/Gradient.cs b/src/DemaConsulting.CanvasNet/Drawing/Gradient.cs index f5b10522..2ff79626 100644 --- a/src/DemaConsulting.CanvasNet/Drawing/Gradient.cs +++ b/src/DemaConsulting.CanvasNet/Drawing/Gradient.cs @@ -132,7 +132,7 @@ private static bool IsFinite(Matrix3x2 matrix) => /// /// Produces an immutable, stable-sorted-by-offset defensive copy of . /// - private static IReadOnlyList CopyStableSorted(IReadOnlyList stops) + private static ReadOnlyCollection CopyStableSorted(IReadOnlyList stops) { // OrderBy is a documented-stable sort (unlike Array.Sort/List.Sort), which is required // to preserve caller-supplied relative order among stops sharing the same Offset - see diff --git a/src/DemaConsulting.CanvasNet/Drawing/PathFiller.cs b/src/DemaConsulting.CanvasNet/Drawing/PathFiller.cs index 38e88446..3cb228ef 100644 --- a/src/DemaConsulting.CanvasNet/Drawing/PathFiller.cs +++ b/src/DemaConsulting.CanvasNet/Drawing/PathFiller.cs @@ -192,7 +192,7 @@ private static bool TryFlattenForFill( Surface surface, Path path, float flattenTolerance, - out IReadOnlyList> polygons, + out List> polygons, out Rect clipBounds) { polygons = EdgeFlattener.Flatten(path, flattenTolerance); diff --git a/src/DemaConsulting.CanvasNet/Drawing/PathStroker.cs b/src/DemaConsulting.CanvasNet/Drawing/PathStroker.cs index 347ec095..7175ea11 100644 --- a/src/DemaConsulting.CanvasNet/Drawing/PathStroker.cs +++ b/src/DemaConsulting.CanvasNet/Drawing/PathStroker.cs @@ -1,3 +1,5 @@ +using System.Linq; +using System.Numerics; using DemaConsulting.CanvasNet.Geometry; using Path = DemaConsulting.CanvasNet.Geometry.Path; @@ -77,20 +79,8 @@ public static Path Stroke(Path path, StrokeStyle style, float flattenTolerance = style, flattenTolerance); - foreach (var outline in outlines) + foreach (var outline in outlines.Where(outline => AppendOutlinePolygon(builder, outline))) { - if (outline.Count < 3) - { - continue; - } - - builder.MoveTo(outline[0]); - for (var i = 1; i < outline.Count; i++) - { - builder.LineTo(outline[i]); - } - - builder.Close(); polygonCount++; } } @@ -98,4 +88,33 @@ public static Path Stroke(Path path, StrokeStyle style, float flattenTolerance = return polygonCount == 0 ? Path.Empty : builder.Build(); } + + /// + /// Appends one stroke outliner polygon to as a closed subpath, + /// skipping any degenerate polygon with fewer than 3 vertices. + /// + /// + /// Isolated from as its own self-contained, independently testable + /// step - turning one outline's vertex list into path commands is a distinct concept from + /// the surrounding flatten/dash/outline pipeline that produces the outline. + /// + /// The path builder to append to. + /// The outline's vertices, in order. + /// if a subpath was appended; if was degenerate. + private static bool AppendOutlinePolygon(PathBuilder builder, IReadOnlyList outline) + { + if (outline.Count < 3) + { + return false; + } + + builder.MoveTo(outline[0]); + for (var i = 1; i < outline.Count; i++) + { + builder.LineTo(outline[i]); + } + + builder.Close(); + return true; + } } diff --git a/src/DemaConsulting.CanvasNet/Drawing/ScanlineRasterizer.cs b/src/DemaConsulting.CanvasNet/Drawing/ScanlineRasterizer.cs index 7ed77265..a0159442 100644 --- a/src/DemaConsulting.CanvasNet/Drawing/ScanlineRasterizer.cs +++ b/src/DemaConsulting.CanvasNet/Drawing/ScanlineRasterizer.cs @@ -328,47 +328,8 @@ public bool MoveNext(out int y, out ReadOnlySpan rowCoverage) var rowTop = (float)y; var rowBottom = rowTop + 1f; - // Remove every edge bucketed to expire at this row (see the bucketing below): - // this touches exactly the edges that actually expire on this row, never the - // whole active list, so - unlike a full - // "activeEdges.RemoveAll(edge => edge.BottomY <= rowTop)" scan of every still- - // active edge on every row - this bookkeeping never re-examines an edge that still - // has rows left to contribute to. - var expiringHere = _expiringEdgeIds[y - _clipMinY]; - if (expiringHere != null) - { - foreach (var expiredId in expiringHere) - { - RemoveActiveEdge(expiredId, _activeEdges, _activeEdgeIds, _activeEdgePositions); - } - } - - while (_nextEdgeIndex < _edges.Count && _edges[_nextEdgeIndex].TopY < rowBottom) - { - // Each edge is identified by its own fixed position in the sorted edge table - // ("_edges"), which never changes and is never reused, so it is a stable id to - // bucket by even though its position within the unordered "_activeEdges" list - // itself can move (see RemoveActiveEdge's swap-remove). - var edgeId = _nextEdgeIndex; - var edge = _edges[edgeId]; - _activeEdgePositions[edgeId] = _activeEdges.Count; - _activeEdges.Add(edge); - _activeEdgeIds.Add(edgeId); - - // Bucket this edge's removal at the earliest row it can actually be observed - // as expired. Removal always happens at the *start* of a row, before this - // row's own additions, so an edge just added this row cannot be examined for - // expiry until at least the next row - hence the "y + 1" floor alongside the - // edge's own BottomY. - var expireRow = Math.Max((int)MathF.Ceiling(edge.BottomY), y + 1); - if (expireRow < _clipMaxY) - { - var bucket = _expiringEdgeIds[expireRow - _clipMinY] ??= []; - bucket.Add(edgeId); - } - - _nextEdgeIndex++; - } + ExpireEdgesForRow(y); + ActivateEdgesThroughRow(y, rowBottom); if (_activeEdges.Count == 0) { @@ -381,27 +342,7 @@ public bool MoveNext(out int y, out ReadOnlySpan rowCoverage) continue; } - // Accumulate every active edge's row-restricted slice into the shared cell - // arrays - a single O(edges) pass, with no sorting and no pairing of edges into - // "inside gaps". - Array.Clear(_cover); - Array.Clear(_area); - foreach (var edge in _rowEdges) - { - AccumulateRowEdge(edge, _clipMinX, _clipMaxX, _cover, _area); - } - - // Single left-to-right sweep: "accumulatedCover" is the running winding total. At - // each column, "accumulatedCover" is first advanced by this column's own - // "cover[x]", then the updated running total plus this column's own partial-edge - // geometry ("area[x]") is resolved to a [0, 1] coverage fraction per fill rule. - var accumulatedCover = 0f; - for (var i = 0; i < Width; i++) - { - accumulatedCover += _cover[i]; - var total = accumulatedCover + _area[i]; - _rowCoverage[i] = ResolveCoverage(total, _fillRule); - } + AccumulateRowCoverage(); rowCoverage = _rowCoverage; return true; @@ -412,6 +353,103 @@ public bool MoveNext(out int y, out ReadOnlySpan rowCoverage) return false; } + /// + /// Removes every active edge bucketed to expire at row (see the + /// bucketing performed by ): this touches exactly + /// the edges that actually expire on this row, never the whole active list, so - + /// unlike a full activeEdges.RemoveAll(edge => edge.BottomY <= rowTop) + /// scan of every still-active edge on every row - this bookkeeping never re-examines + /// an edge that still has rows left to contribute to. + /// + /// + /// Isolated from as its own self-contained row-sweep phase, + /// independently nameable from edge activation and coverage accumulation. + /// + private void ExpireEdgesForRow(int y) + { + var expiringHere = _expiringEdgeIds[y - _clipMinY]; + if (expiringHere == null) + { + return; + } + + foreach (var expiredId in expiringHere) + { + RemoveActiveEdge(expiredId, _activeEdges, _activeEdgeIds, _activeEdgePositions); + } + } + + /// + /// Activates every edge in the sorted edge table whose top y falls before + /// , bucketing each newly-activated edge's future removal + /// at the earliest row it can actually be observed as expired. + /// + /// + /// Isolated from as its own self-contained row-sweep phase, + /// independently nameable from edge expiration and coverage accumulation. Removal + /// always happens at the start of a row, before this row's own additions, so + /// an edge just added this row cannot be examined for expiry until at least the next + /// row - hence the y + 1 floor alongside the edge's own BottomY. + /// + private void ActivateEdgesThroughRow(int y, float rowBottom) + { + while (_nextEdgeIndex < _edges.Count && _edges[_nextEdgeIndex].TopY < rowBottom) + { + // Each edge is identified by its own fixed position in the sorted edge table + // ("_edges"), which never changes and is never reused, so it is a stable id to + // bucket by even though its position within the unordered "_activeEdges" list + // itself can move (see RemoveActiveEdge's swap-remove). + var edgeId = _nextEdgeIndex; + var edge = _edges[edgeId]; + _activeEdgePositions[edgeId] = _activeEdges.Count; + _activeEdges.Add(edge); + _activeEdgeIds.Add(edgeId); + + var expireRow = Math.Max((int)MathF.Ceiling(edge.BottomY), y + 1); + if (expireRow < _clipMaxY) + { + var bucket = _expiringEdgeIds[expireRow - _clipMinY] ??= []; + bucket.Add(edgeId); + } + + _nextEdgeIndex++; + } + } + + /// + /// Accumulates every active, row-restricted edge into the shared cover/area + /// cell arrays, then sweeps left to right exactly once to resolve . + /// + /// + /// Isolated from as its own self-contained row-sweep phase, + /// independently nameable from edge expiration/activation - see this class's + /// type-level remarks for the cell-based accumulation technique. + /// + private void AccumulateRowCoverage() + { + // Accumulate every active edge's row-restricted slice into the shared cell + // arrays - a single O(edges) pass, with no sorting and no pairing of edges into + // "inside gaps". + Array.Clear(_cover); + Array.Clear(_area); + foreach (var edge in _rowEdges) + { + AccumulateRowEdge(edge, _clipMinX, _clipMaxX, _cover, _area); + } + + // Single left-to-right sweep: "accumulatedCover" is the running winding total. At + // each column, "accumulatedCover" is first advanced by this column's own + // "cover[x]", then the updated running total plus this column's own partial-edge + // geometry ("area[x]") is resolved to a [0, 1] coverage fraction per fill rule. + var accumulatedCover = 0f; + for (var i = 0; i < Width; i++) + { + accumulatedCover += _cover[i]; + var total = accumulatedCover + _area[i]; + _rowCoverage[i] = ResolveCoverage(total, _fillRule); + } + } + /// /// Removes the active-list entry identified by (its fixed /// index in the sorted edge table) from / private const float NearZeroDistance = 1e-6f; + /// + /// The offsetting parameters shared by every join built for one side ( + /// of +1/-1) of a stroked polyline: which to honor, + /// how far to offset (), and how finely to tessellate any round + /// join/cap arcs (). + /// + /// + /// Bundles the four arguments that , , + /// and their / callers always pass + /// together, unchanged, for the entire side of a polyline being offset. + /// + private readonly record struct StrokeSideGeometry(float SideSign, StrokeStyle Style, float HalfWidth, float FlattenTolerance); + + /// + /// The tessellation parameters shared by every arc-appending helper: the arc's + /// , the flattening bounding each + /// segment's sagitta, and whether the arc's start/end point should itself be emitted + /// (/) or left for the caller to add. + /// + /// + /// Bundles the four arguments , , + /// and all share identically - only the arc's angular span itself + /// differs between the three. + /// + private readonly record struct ArcTessellation(float Radius, float FlattenTolerance, bool IncludeStart, bool IncludeEnd); + /// /// Converts one flattened path segment into its closed outline polygon(s). /// @@ -72,14 +99,20 @@ internal static List> Outline( /// private static List SimplifyPoints(IReadOnlyList points, bool isClosed) { - var simplified = new List(points.Count); - foreach (var point in points) + // Tracks the last point that survived the filter below, so each candidate point is kept + // only when it differs from the previous KEPT point - not merely the previous SOURCE + // point - reproducing the original loop's "simplified[^1] != point" comparison exactly. + Vector2? lastKept = null; + var simplified = points.Where(point => { - if (simplified.Count == 0 || simplified[^1] != point) + if (lastKept == point) { - simplified.Add(point); + return false; } - } + + lastKept = point; + return true; + }).ToList(); if (isClosed && simplified.Count > 1 && simplified[0] == simplified[^1]) { @@ -246,10 +279,7 @@ private static List> CreateOpenStrokePolygons( leftEnd - end, rightEnd - end, tangent * halfWidth, - halfWidth, - flattenTolerance, - includeStart: false, - includeEnd: true); + new ArcTessellation(halfWidth, flattenTolerance, IncludeStart: false, IncludeEnd: true)); AppendReversed(rightSide, polygon, skipFirst: true); } @@ -270,10 +300,7 @@ private static List> CreateOpenStrokePolygons( rightStart - start, leftStart - start, -tangent * halfWidth, - halfWidth, - flattenTolerance, - includeStart: false, - includeEnd: false); + new ArcTessellation(halfWidth, flattenTolerance, IncludeStart: false, IncludeEnd: false)); } RemoveTrailingDuplicateOfFirst(polygon); @@ -371,7 +398,26 @@ private static List> CreateClosedStrokePolygons( // product to Infinity or (Infinity * 0) to NaN. var outerArea = ComputeSignedArea(outerRing); var innerArea = ComputeSignedArea(innerRing); - if (outerArea != 0.0 && innerArea != 0.0 && Math.Sign(outerArea) == Math.Sign(innerArea)) + + // Areas are computed (not caller-supplied literal) values, so guard the "is this ring + // degenerate" check with a small tolerance rather than an exact-zero comparison - a + // ring that is only nearly collinear could otherwise land at a tiny nonzero double + // instead of exactly 0.0, and still needs to be treated as having no meaningful sign. + // The tolerance must scale with the rings' own area magnitude rather than use a fixed + // absolute cutoff: shoelace-formula rounding error grows with the coordinate magnitude, + // and a fixed absolute cutoff would either wrongly treat a tiny but legitimate stroke + // (e.g. a micron-scale closed path) as degenerate, or fail to catch genuine rounding + // noise on a very large one. The X and Y bounding-box spans are multiplied together + // (rather than combined via a single max-extent squared) so a skinny ring - large along + // one axis, tiny along the other - gets a tolerance matching its own area order of + // magnitude instead of one inflated by its unrelated long axis; each span is itself + // translation-invariant, so a ring far from the origin is not penalized either. + const double relativeAreaTolerance = 1e-9; + var ringAreaScale = Math.Max(RingAreaScale(outerRing), RingAreaScale(innerRing)); + var areaNearZeroTolerance = ringAreaScale * relativeAreaTolerance; + if (Math.Abs(outerArea) > areaNearZeroTolerance + && Math.Abs(innerArea) > areaNearZeroTolerance + && Math.Sign(outerArea) == Math.Sign(innerArea)) { innerRing.Reverse(); } @@ -426,7 +472,7 @@ private static (Vector2 Tangent, Vector2 Normal)[] BuildSegmentFrames(IReadOnlyL /// private static List BuildOpenSide( IReadOnlyList points, - IReadOnlyList<(Vector2 Tangent, Vector2 Normal)> frames, + (Vector2 Tangent, Vector2 Normal)[] frames, float sideSign, StrokeStyle style, float halfWidth, @@ -435,9 +481,10 @@ private static List BuildOpenSide( var side = new List(points.Count * 2); AddPointIfDistinct(side, GetOpenEndpoint(points[0], frames[0].Tangent, frames[0].Normal, sideSign, style.Cap, -1f, halfWidth)); + var sideGeometry = new StrokeSideGeometry(sideSign, style, halfWidth, flattenTolerance); for (var i = 1; i < points.Count - 1; i++) { - AppendOpenJoin(side, points[i], frames[i - 1], frames[i], sideSign, style, halfWidth, flattenTolerance); + AppendOpenJoin(side, points[i], frames[i - 1], frames[i], sideGeometry); } AddPointIfDistinct(side, GetOpenEndpoint(points[^1], frames[^1].Tangent, frames[^1].Normal, sideSign, style.Cap, +1f, halfWidth)); @@ -474,13 +521,10 @@ private static void AppendOpenJoin( Vector2 vertex, (Vector2 Tangent, Vector2 Normal) previousFrame, (Vector2 Tangent, Vector2 Normal) nextFrame, - float sideSign, - StrokeStyle style, - float halfWidth, - float flattenTolerance) + StrokeSideGeometry geometry) { - var previousPoint = vertex + sideSign * previousFrame.Normal * halfWidth; - var nextPoint = vertex + sideSign * nextFrame.Normal * halfWidth; + var previousPoint = vertex + geometry.SideSign * previousFrame.Normal * geometry.HalfWidth; + var nextPoint = vertex + geometry.SideSign * nextFrame.Normal * geometry.HalfWidth; var turn = Cross(previousFrame.Tangent, nextFrame.Tangent); var dot = Vector2.Dot(previousFrame.Tangent, nextFrame.Tangent); if (MathF.Abs(turn) <= NearZeroDistance && dot > 0f) @@ -489,7 +533,7 @@ private static void AppendOpenJoin( return; } - var isConvexOnThisSide = turn * sideSign < 0f; + var isConvexOnThisSide = turn * geometry.SideSign < 0f; if (!isConvexOnThisSide) { AddPointIfDistinct(side, previousPoint); @@ -497,17 +541,7 @@ private static void AppendOpenJoin( return; } - AppendStyledJoin( - side, - vertex, - previousFrame.Tangent, - nextFrame.Tangent, - previousFrame.Normal, - nextFrame.Normal, - sideSign, - style, - halfWidth, - flattenTolerance); + AppendStyledJoin(side, vertex, previousFrame, nextFrame, geometry); } /// @@ -545,6 +579,7 @@ private static List BuildClosedSide( { var frames = BuildSegmentFrames(points, isClosed: true); var ring = new List(points.Count * 2); + var geometry = new StrokeSideGeometry(sideSign, style, halfWidth, flattenTolerance); // Tracks, for each source vertex, the ring index of the single plain offset point emitted // for it (see the remarks below), or -1 if that vertex instead emitted a styled join @@ -569,14 +604,9 @@ private static List BuildClosedSide( AppendStyledJoin( ring, points[i], - previousFrame.Tangent, - nextFrame.Tangent, - previousFrame.Normal, - nextFrame.Normal, - sideSign, - style, - halfWidth, - flattenTolerance, + previousFrame, + nextFrame, + geometry, forceExactIntersection: !isConvexOnThisSide); // Only a locally concave (forceExactIntersection) vertex that emitted exactly one @@ -619,16 +649,11 @@ private static List BuildClosedSide( /// /// Appends the requested styled join between two offset segments. /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// + /// The point list to append the join's vertices to. + /// The source polyline vertex the join is centered on. + /// The incoming segment's tangent/normal frame. + /// The outgoing segment's tangent/normal frame. + /// The side/style/half-width/tolerance this join is built with. /// /// When , ignores and always emits /// the geometrically exact intersection of the two offset edges (falling back to the @@ -640,22 +665,17 @@ private static List BuildClosedSide( private static void AppendStyledJoin( List target, Vector2 vertex, - Vector2 previousTangent, - Vector2 nextTangent, - Vector2 previousNormal, - Vector2 nextNormal, - float sideSign, - StrokeStyle style, - float halfWidth, - float flattenTolerance, + (Vector2 Tangent, Vector2 Normal) previousFrame, + (Vector2 Tangent, Vector2 Normal) nextFrame, + StrokeSideGeometry geometry, bool forceExactIntersection = false) { - var previousPoint = vertex + sideSign * previousNormal * halfWidth; - var nextPoint = vertex + sideSign * nextNormal * halfWidth; + var previousPoint = vertex + geometry.SideSign * previousFrame.Normal * geometry.HalfWidth; + var nextPoint = vertex + geometry.SideSign * nextFrame.Normal * geometry.HalfWidth; if (forceExactIntersection) { - if (TryIntersectLines(previousPoint, previousTangent, nextPoint, nextTangent, out var intersection)) + if (TryIntersectLines(previousPoint, previousFrame.Tangent, nextPoint, nextFrame.Tangent, out var intersection)) { AddPointIfDistinct(target, intersection); } @@ -668,7 +688,7 @@ private static void AppendStyledJoin( return; } - switch (style.Join) + switch (geometry.Style.Join) { case LineJoin.Round: AddPointIfDistinct(target, previousPoint); @@ -677,10 +697,7 @@ private static void AppendStyledJoin( vertex, previousPoint - vertex, nextPoint - vertex, - halfWidth, - flattenTolerance, - includeStart: false, - includeEnd: true); + new ArcTessellation(geometry.HalfWidth, geometry.FlattenTolerance, IncludeStart: false, IncludeEnd: true)); break; case LineJoin.Bevel: @@ -689,7 +706,7 @@ private static void AppendStyledJoin( break; default: - if (TryCreateMiter(vertex, previousPoint, nextPoint, previousTangent, nextTangent, style.Width, style.MiterLimit, out var miter)) + if (TryCreateMiter(vertex, (previousPoint, previousFrame.Tangent), (nextPoint, nextFrame.Tangent), geometry.Style.Width, geometry.Style.MiterLimit, out var miter)) { AddPointIfDistinct(target, miter); } @@ -719,16 +736,14 @@ private static void AppendStyledJoin( /// private static bool TryCreateMiter( Vector2 vertex, - Vector2 previousPoint, - Vector2 nextPoint, - Vector2 previousTangent, - Vector2 nextTangent, + (Vector2 Point, Vector2 Direction) previousLine, + (Vector2 Point, Vector2 Direction) nextLine, float strokeWidth, float miterLimit, out Vector2 miterPoint) { miterPoint = default; - if (!TryIntersectLines(previousPoint, previousTangent, nextPoint, nextTangent, out var intersection)) + if (!TryIntersectLines(previousLine.Point, previousLine.Direction, nextLine.Point, nextLine.Direction, out var intersection)) { return false; } @@ -775,15 +790,12 @@ private static void AppendArcShortest( Vector2 center, Vector2 startVector, Vector2 endVector, - float radius, - float flattenTolerance, - bool includeStart, - bool includeEnd) + ArcTessellation arc) { var startAngle = MathF.Atan2(startVector.Y, startVector.X); var endAngle = MathF.Atan2(endVector.Y, endVector.X); var sweep = NormalizeSignedAngle(endAngle - startAngle); - AppendArc(target, center, startAngle, sweep, radius, flattenTolerance, includeStart, includeEnd); + AppendArc(target, center, startAngle, sweep, arc); } /// @@ -796,10 +808,7 @@ private static void AppendArcThrough( Vector2 startVector, Vector2 endVector, Vector2 throughVector, - float radius, - float flattenTolerance, - bool includeStart, - bool includeEnd) + ArcTessellation arc) { var startAngle = MathF.Atan2(startVector.Y, startVector.X); var endAngle = MathF.Atan2(endVector.Y, endVector.X); @@ -810,7 +819,7 @@ private static void AppendArcThrough( sweep = sweep > 0f ? sweep - 2f * MathF.PI : sweep + 2f * MathF.PI; } - AppendArc(target, center, startAngle, sweep, radius, flattenTolerance, includeStart, includeEnd); + AppendArc(target, center, startAngle, sweep, arc); } /// @@ -821,26 +830,23 @@ private static void AppendArc( Vector2 center, float startAngle, float sweep, - float radius, - float flattenTolerance, - bool includeStart, - bool includeEnd) + ArcTessellation arc) { - var segmentCount = GetArcSegmentCount(radius, MathF.Abs(sweep), flattenTolerance); + var segmentCount = GetArcSegmentCount(arc.Radius, MathF.Abs(sweep), arc.FlattenTolerance); for (var i = 0; i <= segmentCount; i++) { - if (i == 0 && !includeStart) + if (i == 0 && !arc.IncludeStart) { continue; } - if (i == segmentCount && !includeEnd) + if (i == segmentCount && !arc.IncludeEnd) { continue; } var angle = startAngle + sweep * i / segmentCount; - var point = center + new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * radius; + var point = center + new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * arc.Radius; AddPointIfDistinct(target, point); } } @@ -921,7 +927,7 @@ private static List CreateCirclePolygon(Vector2 center, float radius, f /// /// Appends in reverse order to . /// - private static void AppendReversed(IReadOnlyList source, List target, bool skipFirst) + private static void AppendReversed(List source, List target, bool skipFirst) { var endIndex = skipFirst ? source.Count - 2 : source.Count - 1; for (var i = endIndex; i >= 0; i--) @@ -1080,4 +1086,35 @@ private static double ComputeSignedArea(IReadOnlyList points) return area / 2.0; } + + /// + /// Computes the product of a ring's X and Y bounding-box spans, as a proxy for its area + /// magnitude. + /// + /// + /// Used to derive an area-relative tolerance for near-zero area comparisons: + /// shoelace-formula rounding error grows with the coordinate magnitude, so a fixed + /// absolute tolerance would misclassify degeneracy at both very small and very large + /// scales. Multiplying the two independent axis spans (rather than squaring a single + /// combined extent) keeps the estimate accurate for skinny rings - large along one axis, + /// tiny along the other - instead of inflating the tolerance to match the longer axis. + /// Each span is itself translation-invariant, so a ring far from the origin is not + /// penalized for its absolute position either. + /// + private static double RingAreaScale(IReadOnlyList points) + { + var minX = double.PositiveInfinity; + var maxX = double.NegativeInfinity; + var minY = double.PositiveInfinity; + var maxY = double.NegativeInfinity; + foreach (var point in points) + { + minX = Math.Min(minX, point.X); + maxX = Math.Max(maxX, point.X); + minY = Math.Min(minY, point.Y); + maxY = Math.Max(maxY, point.Y); + } + + return (maxX - minX) * (maxY - minY); + } } diff --git a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs index d49abc6e..ff274c68 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/CmapTable.cs @@ -283,43 +283,65 @@ private static int ClassifyPriority(int platformId, int encodingId, int format) return null; } - return codepoint => + return codepoint => LookupFormat4(codepoint, segCount, endCodes, startCodes, idDeltas, idRangeOffsets, idRangeOffsetOffset, glyphIdArrayOffset, tableEnd, data); + } + + /// + /// Resolves one codepoint via a parsed format-4 subtable's segment arrays, performing the + /// linear segment scan and indirect glyph-index array lookup the format defines. + /// + /// + /// Isolated from as its own self-contained lookup step - + /// resolving a codepoint against already-validated segment arrays is independent of, and + /// independently testable from, the parsing/validation that produced those arrays. + /// + /// The resolved glyph index, or 0 (".notdef") if unmapped or malformed. + private static int LookupFormat4( + int codepoint, + int segCount, + int[] endCodes, + int[] startCodes, + short[] idDeltas, + ushort[] idRangeOffsets, + int idRangeOffsetOffset, + int glyphIdArrayOffset, + int tableEnd, + byte[] data) + { + if (codepoint is < 0 or > 0xFFFF) + { + return 0; + } + + for (var i = 0; i < segCount; i++) { - if (codepoint is < 0 or > 0xFFFF) + if (codepoint > endCodes[i]) + { + continue; + } + + if (codepoint < startCodes[i]) { return 0; } - for (var i = 0; i < segCount; i++) + if (idRangeOffsets[i] == 0) { - if (codepoint > endCodes[i]) - { - continue; - } - - if (codepoint < startCodes[i]) - { - return 0; - } - - if (idRangeOffsets[i] == 0) - { - return (codepoint + idDeltas[i]) & 0xFFFF; - } - - var glyphIndexAddress = ComputeFormat4GlyphIndexAddress( - idRangeOffsetOffset, i, idRangeOffsets[i], codepoint, startCodes[i], glyphIdArrayOffset, tableEnd); - if (glyphIndexAddress == null) - { - return 0; - } - - var glyphId = SfntContainer.ReadUInt16(data, (int)glyphIndexAddress.Value); - return glyphId == 0 ? 0 : (glyphId + idDeltas[i]) & 0xFFFF; + return (codepoint + idDeltas[i]) & 0xFFFF; } - return 0; - }; + var glyphIndexAddress = ComputeFormat4GlyphIndexAddress( + idRangeOffsetOffset, i, idRangeOffsets[i], codepoint, startCodes[i], glyphIdArrayOffset, tableEnd); + if (glyphIndexAddress == null) + { + return 0; + } + + var glyphId = SfntContainer.ReadUInt16(data, (int)glyphIndexAddress.Value); + return glyphId == 0 ? 0 : (glyphId + idDeltas[i]) & 0xFFFF; + } + + return 0; } /// @@ -466,42 +488,54 @@ private static (int EndCodeOffset, int StartCodeOffset, int IdDeltaOffset, int I } } - return codepoint => + return codepoint => LookupFormat12(codepoint, starts, ends, startGlyphIds); + } + + /// + /// Resolves one codepoint via a parsed format-12 subtable's group arrays, performing the + /// binary search over charCode ranges the format defines. + /// + /// + /// Isolated from as its own self-contained lookup step - + /// resolving a codepoint against already-validated group arrays is independent of, and + /// independently testable from, the parsing/validation that produced those arrays. + /// + /// The resolved glyph index, or 0 (".notdef") if unmapped or malformed. + private static int LookupFormat12(int codepoint, uint[] starts, uint[] ends, uint[] startGlyphIds) + { + if (codepoint < 0) + { + return 0; + } + + var code = (uint)codepoint; + var lo = 0; + var hi = starts.Length - 1; + while (lo <= hi) { - if (codepoint < 0) + var mid = lo + (hi - lo) / 2; + if (code < starts[mid]) { - return 0; + hi = mid - 1; } - - var code = (uint)codepoint; - var lo = 0; - var hi = starts.Length - 1; - while (lo <= hi) + else if (code > ends[mid]) + { + lo = mid + 1; + } + else { - var mid = lo + (hi - lo) / 2; - if (code < starts[mid]) - { - hi = mid - 1; - } - else if (code > ends[mid]) - { - lo = mid + 1; - } - else - { - // Compute the sum in `long` arithmetic rather than `uint` - `startGlyphIds[mid]` - // combined with a large `code - starts[mid]` offset can otherwise wrap modulo - // 2^32, silently producing a small, plausible-looking (but bogus) glyph index - // instead of failing. Real fonts never have anywhere near `int.MaxValue` - // glyphs, so any result that would not fit in a non-negative `int` (i.e. would - // have wrapped, or would itself become negative when cast) is rejected - // outright rather than truncated. - var glyphId = (long)startGlyphIds[mid] + (code - starts[mid]); - return glyphId > int.MaxValue ? 0 : (int)glyphId; - } + // Compute the sum in `long` arithmetic rather than `uint` - `startGlyphIds[mid]` + // combined with a large `code - starts[mid]` offset can otherwise wrap modulo + // 2^32, silently producing a small, plausible-looking (but bogus) glyph index + // instead of failing. Real fonts never have anywhere near `int.MaxValue` + // glyphs, so any result that would not fit in a non-negative `int` (i.e. would + // have wrapped, or would itself become negative when cast) is rejected + // outright rather than truncated. + var glyphId = (long)startGlyphIds[mid] + (code - starts[mid]); + return glyphId > int.MaxValue ? 0 : (int)glyphId; } + } - return 0; - }; + return 0; } } diff --git a/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs b/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs index 13dd3d8e..51cc8386 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/GlyfLocaReader.cs @@ -79,6 +79,14 @@ internal sealed class GlyfLocaReader private const int ScaledComponentOffset = 0x0800; private const int UnscaledComponentOffset = 0x1000; + // Per-point flag bits for a simple glyph's contour data (TrueType 'glyf' simple glyph flags). + private const int OnCurvePoint = 0x01; + private const int XShortVector = 0x02; + private const int YShortVector = 0x04; + private const int RepeatFlag = 0x08; + private const int XIsSameOrPositiveXShortVector = 0x10; + private const int YIsSameOrPositiveYShortVector = 0x20; + private readonly byte[] _data; private readonly int _glyfOffset; private readonly int _glyfLength; @@ -244,22 +252,7 @@ private Path DecodeSimpleGlyph(int glyphOffset, int glyphLength, int numberOfCon return Path.Empty; } - EnsureAvailable(pos, numberOfContours * 2, limit); - var endPts = new int[numberOfContours]; - var previousEnd = -1; - for (var i = 0; i < numberOfContours; i++) - { - var value = SfntContainer.ReadUInt16(_data, pos); - pos += 2; - if (value <= previousEnd) - { - throw new InvalidDataException("Glyph contour end points must be strictly increasing."); - } - - endPts[i] = value; - previousEnd = value; - } - + var endPts = ReadContourEndPoints(numberOfContours, limit, ref pos); var numPoints = endPts[^1] + 1; // Charge the budget before allocating the flags/xs/ys arrays below - a crafted glyph @@ -277,71 +270,112 @@ private Path DecodeSimpleGlyph(int glyphOffset, int glyphLength, int numberOfCon EnsureAvailable(pos, instructionLength, limit); pos += instructionLength; + var flags = ReadPointFlags(numPoints, limit, ref pos); + var xs = ReadCoordinates(flags, numPoints, limit, XShortVector, XIsSameOrPositiveXShortVector, ref pos); + var ys = ReadCoordinates(flags, numPoints, limit, YShortVector, YIsSameOrPositiveYShortVector, ref pos); + + return BuildSimpleGlyphPath(numberOfContours, endPts, flags, xs, ys); + } + + /// + /// Reads a simple glyph's per-contour end-point indices, validating that they are + /// strictly increasing (a TrueType contour is always non-empty, and contours are always + /// stored in point order), and advances past the bytes consumed. + /// + private int[] ReadContourEndPoints(int numberOfContours, int limit, ref int pos) + { + EnsureAvailable(pos, numberOfContours * 2, limit); + var endPts = new int[numberOfContours]; + var previousEnd = -1; + for (var i = 0; i < numberOfContours; i++) + { + var value = SfntContainer.ReadUInt16(_data, pos); + pos += 2; + if (value <= previousEnd) + { + throw new InvalidDataException("Glyph contour end points must be strictly increasing."); + } + + endPts[i] = value; + previousEnd = value; + } + + return endPts; + } + + /// + /// Decodes a simple glyph's run-length-encoded per-point flag bytes, expanding every + /// run into its repeated flag value, and advances + /// past the bytes consumed. + /// + private byte[] ReadPointFlags(int numPoints, int limit, ref int pos) + { var flags = new byte[numPoints]; - var i2 = 0; - while (i2 < numPoints) + var i = 0; + while (i < numPoints) { EnsureAvailable(pos, 1, limit); var flag = _data[pos++]; - flags[i2++] = flag; - if ((flag & 0x08) != 0) + flags[i++] = flag; + if ((flag & RepeatFlag) != 0) { EnsureAvailable(pos, 1, limit); var repeatCount = _data[pos++]; - if (repeatCount > numPoints - i2) + if (repeatCount > numPoints - i) { throw new InvalidDataException("Glyph flag repeat count exceeds the declared point count."); } for (var r = 0; r < repeatCount; r++) { - flags[i2++] = flag; + flags[i++] = flag; } } } - var xs = new int[numPoints]; - var x = 0; - for (var i = 0; i < numPoints; i++) - { - var flag = flags[i]; - if ((flag & 0x02) != 0) - { - EnsureAvailable(pos, 1, limit); - var dx = _data[pos++]; - x += (flag & 0x10) != 0 ? dx : -dx; - } - else if ((flag & 0x10) == 0) - { - EnsureAvailable(pos, 2, limit); - x += SfntContainer.ReadInt16(_data, pos); - pos += 2; - } - - xs[i] = x; - } + return flags; + } - var ys = new int[numPoints]; - var y = 0; + /// + /// Decodes one axis (x or y) of a simple glyph's delta-encoded, running-sum point + /// coordinates, and advances past the bytes consumed. Shared + /// between the x and y passes by taking as parameters which flag bit selects the short + /// (1-byte magnitude) encoding and which bit selects that byte's sign, or - for the 2-byte + /// encoding - whether the coordinate repeats the previous value unchanged. + /// + private int[] ReadCoordinates(byte[] flags, int numPoints, int limit, int shortVectorFlag, int sameOrPositiveFlag, ref int pos) + { + var coords = new int[numPoints]; + var value = 0; for (var i = 0; i < numPoints; i++) { var flag = flags[i]; - if ((flag & 0x04) != 0) + if ((flag & shortVectorFlag) != 0) { EnsureAvailable(pos, 1, limit); - var dy = _data[pos++]; - y += (flag & 0x20) != 0 ? dy : -dy; + var delta = _data[pos++]; + value += (flag & sameOrPositiveFlag) != 0 ? delta : -delta; } - else if ((flag & 0x20) == 0) + else if ((flag & sameOrPositiveFlag) == 0) { EnsureAvailable(pos, 2, limit); - y += SfntContainer.ReadInt16(_data, pos); + value += SfntContainer.ReadInt16(_data, pos); pos += 2; } - ys[i] = y; + coords[i] = value; } + return coords; + } + + /// + /// Assembles a simple glyph's decoded per-contour end points, flags, and x/y coordinate + /// arrays into a by slicing the flat point arrays back into their + /// per-contour ranges and emitting one call per contour. + /// + private static Path BuildSimpleGlyphPath(int numberOfContours, int[] endPts, byte[] flags, int[] xs, int[] ys) + { var builder = new PathBuilder(); var contourStart = 0; for (var c = 0; c < numberOfContours; c++) @@ -350,7 +384,7 @@ private Path DecodeSimpleGlyph(int glyphOffset, int glyphLength, int numberOfCon var points = new List<(Vector2 Point, bool OnCurve)>(contourEnd - contourStart + 1); for (var p = contourStart; p <= contourEnd; p++) { - points.Add((new Vector2(xs[p], ys[p]), (flags[p] & 0x01) != 0)); + points.Add((new Vector2(xs[p], ys[p]), (flags[p] & OnCurvePoint) != 0)); } EmitContour(builder, points); @@ -440,7 +474,6 @@ private Path DecodeCompositeGlyph(int glyphOffset, int glyphLength, int depth, r var limit = glyphOffset + glyphLength; var pos = glyphOffset + 10; var builder = new PathBuilder(); - var hasContent = false; bool more; do @@ -455,45 +488,8 @@ private Path DecodeCompositeGlyph(int glyphOffset, int glyphLength, int depth, r throw new InvalidDataException("Point-matched composite glyph components are not supported."); } - float dx, dy; - if ((flags & ArgsAreWords) != 0) - { - EnsureAvailable(pos, 4, limit); - dx = SfntContainer.ReadInt16(_data, pos); - dy = SfntContainer.ReadInt16(_data, pos + 2); - pos += 4; - } - else - { - EnsureAvailable(pos, 2, limit); - dx = unchecked((sbyte)_data[pos]); - dy = unchecked((sbyte)_data[pos + 1]); - pos += 2; - } - - float a = 1f, b = 0f, c = 0f, d = 1f; - if ((flags & WeHaveAScale) != 0) - { - EnsureAvailable(pos, 2, limit); - a = d = SfntContainer.ReadF2Dot14(_data, pos); - pos += 2; - } - else if ((flags & WeHaveAnXAndYScale) != 0) - { - EnsureAvailable(pos, 4, limit); - a = SfntContainer.ReadF2Dot14(_data, pos); - d = SfntContainer.ReadF2Dot14(_data, pos + 2); - pos += 4; - } - else if ((flags & WeHaveATwoByTwo) != 0) - { - EnsureAvailable(pos, 8, limit); - a = SfntContainer.ReadF2Dot14(_data, pos); - b = SfntContainer.ReadF2Dot14(_data, pos + 2); - c = SfntContainer.ReadF2Dot14(_data, pos + 4); - d = SfntContainer.ReadF2Dot14(_data, pos + 6); - pos += 8; - } + var (dx, dy) = ReadComponentOffset(flags, ref pos, limit); + var (a, b, c, d) = ReadComponentTransform(flags, ref pos, limit); var componentPath = DecodeGlyph(componentGlyphIndex, depth, ref totalComponents, ref totalPoints); @@ -510,30 +506,115 @@ private Path DecodeCompositeGlyph(int glyphOffset, int glyphLength, int depth, r dy = scaledDy; } - AppendTransformed(builder, componentPath, a, b, c, d, dx, dy, ref totalPoints); - hasContent = true; + AppendTransformed(builder, componentPath, new Matrix3x2(a, b, c, d, dx, dy), ref totalPoints); more = (flags & MoreComponents) != 0; } while (more); - return hasContent ? builder.Build() : Path.Empty; + // No separate "did we add anything" tracking is needed here: the loop above always runs + // at least once (it is a do-while over at least one component record), but Build() already + // returns Path.Empty whenever no subpath was ever committed to the builder (for example + // when every referenced component itself resolved to Path.Empty and contributed no + // commands via AppendTransformed), so it produces the same result as an explicit + // "had any component contributed content" flag would. + return builder.Build(); + } + + /// + /// Reads a composite glyph component's translation (dx/dy) arguments, + /// advancing past whatever byte width the flags declare. + /// + /// + /// Isolated from because reading a component's offset + /// is a self-contained, independently testable parsing step - per the 'glyf' table spec, + /// alone decides whether the two argument bytes are a pair of + /// signed 16-bit words or a pair of signed 8-bit bytes. + /// + /// The component's flags word, previously validated to have set. + /// The current read position, advanced past the consumed argument bytes. + /// The exclusive upper bound within the read must stay within. + /// The component's (dx, dy) translation. + private (float Dx, float Dy) ReadComponentOffset(int flags, ref int pos, int limit) + { + if ((flags & ArgsAreWords) != 0) + { + EnsureAvailable(pos, 4, limit); + var dx = SfntContainer.ReadInt16(_data, pos); + var dy = SfntContainer.ReadInt16(_data, pos + 2); + pos += 4; + return (dx, dy); + } + + EnsureAvailable(pos, 2, limit); + var byteDx = unchecked((sbyte)_data[pos]); + var byteDy = unchecked((sbyte)_data[pos + 1]); + pos += 2; + return (byteDx, byteDy); + } + + /// + /// Reads a composite glyph component's optional 2x2 transform matrix + /// (a/b/c/d), advancing past whatever + /// byte width the flags declare, defaulting to the identity matrix when none of the + /// scale/2x2 flags are set. + /// + /// + /// Isolated from because parsing the transform is a + /// self-contained, independently testable step - per the 'glyf' table spec, exactly one of + /// , , or + /// may be set, in increasing order of generality (uniform scale, independent x/y scale, or + /// a full 2x2 matrix). + /// + /// The component's flags word. + /// The current read position, advanced past the consumed transform bytes. + /// The exclusive upper bound within the read must stay within. + /// The component's (a, b, c, d) 2x2 transform matrix. + private (float A, float B, float C, float D) ReadComponentTransform(int flags, ref int pos, int limit) + { + if ((flags & WeHaveAScale) != 0) + { + EnsureAvailable(pos, 2, limit); + var scale = SfntContainer.ReadF2Dot14(_data, pos); + pos += 2; + return (scale, 0f, 0f, scale); + } + + if ((flags & WeHaveAnXAndYScale) != 0) + { + EnsureAvailable(pos, 4, limit); + var scaleX = SfntContainer.ReadF2Dot14(_data, pos); + var scaleY = SfntContainer.ReadF2Dot14(_data, pos + 2); + pos += 4; + return (scaleX, 0f, 0f, scaleY); + } + + if ((flags & WeHaveATwoByTwo) != 0) + { + EnsureAvailable(pos, 8, limit); + var a = SfntContainer.ReadF2Dot14(_data, pos); + var b = SfntContainer.ReadF2Dot14(_data, pos + 2); + var c = SfntContainer.ReadF2Dot14(_data, pos + 4); + var d = SfntContainer.ReadF2Dot14(_data, pos + 6); + pos += 8; + return (a, b, c, d); + } + + return (1f, 0f, 0f, 1f); } /// /// Re-issues every subpath of into , - /// applying the given 2x2 matrix (///) - /// and translation (/) to every point, charging + /// applying the given affine (2x2 matrix plus translation, as + /// decoded from the composite glyph's component record) to every point, charging /// the number of points/commands about to be copied against /// before performing any copy - resolving a component's outline is cheap (it is already /// built), but re-emitting it into the composite's builder is the step that actually /// multiplies a large component's geometry by every reference to it, so the budget must be /// checked here rather than only when the component was first decoded. /// - private static void AppendTransformed(PathBuilder builder, Path source, float a, float b, float c, float d, float dx, float dy, ref int totalPoints) + private static void AppendTransformed(PathBuilder builder, Path source, Matrix3x2 transform, ref int totalPoints) { - Vector2 Transform(Vector2 p) => new(a * p.X + c * p.Y + dx, b * p.X + d * p.Y + dy); - // Count the points/commands (one MoveTo plus every command) this copy is about to // produce, and charge the budget before copying a single one of them. var pointsToCopy = 0; @@ -550,17 +631,17 @@ private static void AppendTransformed(PathBuilder builder, Path source, float a, foreach (var subpath in source.Subpaths) { - builder.MoveTo(Transform(subpath.Start)); + builder.MoveTo(Vector2.Transform(subpath.Start, transform)); foreach (var command in subpath.Commands) { switch (command.Type) { case PathCommandType.LineTo: - builder.LineTo(Transform(command.EndPoint)); + builder.LineTo(Vector2.Transform(command.EndPoint, transform)); break; case PathCommandType.QuadraticBezierTo: - builder.QuadraticBezierTo(Transform(command.Control1), Transform(command.EndPoint)); + builder.QuadraticBezierTo(Vector2.Transform(command.Control1, transform), Vector2.Transform(command.EndPoint, transform)); break; case PathCommandType.Close: diff --git a/src/DemaConsulting.CanvasNet/Geometry/CornerRoundEffect.cs b/src/DemaConsulting.CanvasNet/Geometry/CornerRoundEffect.cs index b9695dd4..0fddf8cb 100644 --- a/src/DemaConsulting.CanvasNet/Geometry/CornerRoundEffect.cs +++ b/src/DemaConsulting.CanvasNet/Geometry/CornerRoundEffect.cs @@ -72,40 +72,9 @@ public static Path Apply(Path source, float radius) foreach (var subpath in source.Subpaths) { var commands = subpath.Commands; - - // A "wrap-around corner" is the vertex at subpath.Start where the *implicit* closing - // edge (drawn by a trailing Close, from the last real command's endpoint back to - // subpath.Start) meets the *first* LineTo (from subpath.Start to commands[0].EndPoint). - // This is the same kind of "LineTo meets LineTo" polyline corner the main loop below - // detects for every interior vertex, except neither edge is literally adjacent in the - // commands list - one is the implicit closing edge (whose "command" is Close, at - // lastIndex), the other is the first command. It requires at least two LineTo - // commands before Close (a single LineTo before Close would make the "incoming" and - // "outgoing" edges the same segment, which is not a real corner). var lastIndex = commands.Count - 1; - var closingLineIndex = lastIndex - 1; - var hasWrapAroundCorner = closingLineIndex > 0 && - commands[lastIndex].Type == PathCommandType.Close && - commands[closingLineIndex].Type == PathCommandType.LineTo && - commands[0].Type == PathCommandType.LineTo; - - var moveToPoint = subpath.Start; - var wrapRadius = radius; - if (hasWrapAroundCorner) - { - var incomingStart = commands[closingLineIndex].EndPoint; - var outgoingEnd = commands[0].EndPoint; - wrapRadius = ClampRadius(radius, incomingStart, subpath.Start, outgoingEnd); - - // Compute where the wrap-around arc's tangent-out point lands using a scratch - // builder (the same TangentArcTo math used for every other corner), so the real - // subpath below can start there directly instead of at the un-rounded Start. - var scratch = new PathBuilder() - .MoveTo(incomingStart) - .TangentArcTo(subpath.Start, outgoingEnd, wrapRadius) - .Build(); - moveToPoint = scratch.Subpaths[0].Commands[^1].EndPoint; - } + + var (hasWrapAroundCorner, wrapRadius, moveToPoint) = ComputeWrapAroundCorner(subpath, radius); builder.MoveTo(moveToPoint); @@ -198,6 +167,64 @@ public static Path Apply(Path source, float radius) return builder.Build(); } + /// + /// Determines whether has a "wrap-around corner" at its start + /// point - where the implicit closing edge (drawn by a trailing , + /// from the last real command's endpoint back to ) meets the + /// first - and, when it does, computes the rounded + /// radius and the tangent-out point the rest of the subpath should start from instead of + /// the un-rounded start point. + /// + /// + /// Isolated from as its own self-contained computation - determining + /// and rounding the wrap-around corner is independent of the traversal loop that emits the + /// rest of the subpath's commands, and does not depend on any state the loop accumulates. + /// This is the same kind of "LineTo meets LineTo" polyline corner 's + /// main loop detects for every interior vertex, except neither edge is literally adjacent + /// in the commands list - one is the implicit closing edge (whose "command" is + /// , at the subpath's last index), the other is the + /// first command. It requires at least two commands + /// before (a single LineTo before Close would make the + /// "incoming" and "outgoing" edges the same segment, which is not a real corner). + /// + /// The subpath to inspect. + /// The requested corner radius, before per-corner clamping. + /// + /// Whether a wrap-around corner exists, its clamped radius (equal to + /// when none exists), and the point the subpath should actually start from (equal to + /// when none exists). + /// + private static (bool HasWrapAroundCorner, float WrapRadius, Vector2 MoveToPoint) ComputeWrapAroundCorner(Subpath subpath, float radius) + { + var commands = subpath.Commands; + var lastIndex = commands.Count - 1; + var closingLineIndex = lastIndex - 1; + var hasWrapAroundCorner = closingLineIndex > 0 && + commands[lastIndex].Type == PathCommandType.Close && + commands[closingLineIndex].Type == PathCommandType.LineTo && + commands[0].Type == PathCommandType.LineTo; + + if (!hasWrapAroundCorner) + { + return (false, radius, subpath.Start); + } + + var incomingStart = commands[closingLineIndex].EndPoint; + var outgoingEnd = commands[0].EndPoint; + var wrapRadius = ClampRadius(radius, incomingStart, subpath.Start, outgoingEnd); + + // Compute where the wrap-around arc's tangent-out point lands using a scratch builder + // (the same TangentArcTo math used for every other corner), so the real subpath below + // can start there directly instead of at the un-rounded Start. + var scratch = new PathBuilder() + .MoveTo(incomingStart) + .TangentArcTo(subpath.Start, outgoingEnd, wrapRadius) + .Build(); + var moveToPoint = scratch.Subpaths[0].Commands[^1].EndPoint; + + return (true, wrapRadius, moveToPoint); + } + /// /// Clamps to half of the shorter of the incoming and outgoing /// segment lengths meeting at , so an aggressive requested diff --git a/src/DemaConsulting.CanvasNet/Geometry/Path.cs b/src/DemaConsulting.CanvasNet/Geometry/Path.cs index f5c9d2e2..2f8effad 100644 --- a/src/DemaConsulting.CanvasNet/Geometry/Path.cs +++ b/src/DemaConsulting.CanvasNet/Geometry/Path.cs @@ -24,7 +24,7 @@ public sealed class Path /// 's union-identity convention: an empty path contributes nothing to /// any bounds it is combined with. /// - public static readonly Path Empty = new(new List()); + public static readonly Path Empty = new([]); /// /// The ordered collection of independent subpaths making up this path. @@ -116,12 +116,7 @@ private static Rect AccumulateCommandBounds(Vector2 current, PathCommand command { var points = new List(); BezierFlattening.FlattenQuadratic(current, command.Control1, command.EndPoint, flattenTolerance, points); - foreach (var point in points) - { - bounds = bounds.Union(PointRect(point)); - } - - return bounds; + return UnionPoints(bounds, points); } return bounds.Union(PointRect(command.Control1)).Union(PointRect(command.EndPoint)); @@ -131,12 +126,7 @@ private static Rect AccumulateCommandBounds(Vector2 current, PathCommand command { var points = new List(); BezierFlattening.FlattenCubic(current, command.Control1, command.Control2, command.EndPoint, flattenTolerance, points); - foreach (var point in points) - { - bounds = bounds.Union(PointRect(point)); - } - - return bounds; + return UnionPoints(bounds, points); } return bounds.Union(PointRect(command.Control1)).Union(PointRect(command.Control2)).Union(PointRect(command.EndPoint)); @@ -155,10 +145,7 @@ private static Rect AccumulateCommandBounds(Vector2 current, PathCommand command { var points = new List(); BezierFlattening.FlattenCubic(segmentStart, segment.Control1, segment.Control2, segment.End, flattenTolerance, points); - foreach (var point in points) - { - bounds = bounds.Union(PointRect(point)); - } + bounds = UnionPoints(bounds, points); } else { @@ -176,6 +163,25 @@ private static Rect AccumulateCommandBounds(Vector2 current, PathCommand command } } + /// + /// Folds every point in into via + /// . + /// + /// + /// Isolated from to eliminate the identical + /// "flatten a curve, then union every flattened point" loop that would otherwise be + /// repeated for the quadratic, cubic, and arc (per-segment) flattening branches. + /// + private static Rect UnionPoints(Rect bounds, List points) + { + foreach (var point in points) + { + bounds = bounds.Union(PointRect(point)); + } + + return bounds; + } + /// /// Returns a new obtained by applying to /// every subpath's start point and every drawing command's control points and endpoints. diff --git a/src/DemaConsulting.CanvasNet/Geometry/PathBuilder.cs b/src/DemaConsulting.CanvasNet/Geometry/PathBuilder.cs index d407c1fc..de5c67fd 100644 --- a/src/DemaConsulting.CanvasNet/Geometry/PathBuilder.cs +++ b/src/DemaConsulting.CanvasNet/Geometry/PathBuilder.cs @@ -165,7 +165,7 @@ public PathBuilder ArcTo(Vector2 radius, float rotationDegrees, bool largeArc, b /// /// /// Degenerates gracefully: when the two rays are collinear, the incoming/outgoing - /// vectors are zero-length, or is zero, the method emits a + /// vectors are near-zero-length, or is zero, the method emits a /// single to instead of an arc. /// /// @@ -194,7 +194,25 @@ public PathBuilder TangentArcTo(Vector2 corner, Vector2 end, float radius) var len1 = v1.Length(); var len2 = v2.Length(); - if (radius == 0f || len1 == 0f || len2 == 0f) + // radius == 0f is an intentional exact-sentinel check: the caller explicitly requesting + // zero radius (mirroring HTML5 canvas arcTo semantics) means "no arc", regardless of how + // that value was produced. len1/len2, however, are Length() results of computed vectors, + // so a tolerance (rather than exact zero) catches a corner that is only near-coincident + // with start/end - avoiding an ill-conditioned near-unit-vector normalization below. Each + // ray gets its own tolerance, scaled only to the magnitude of the two points that define + // it (never the other, unrelated ray), so an ordinary long ray on the other side of the + // corner cannot make this ray look coincident, and a genuinely tiny local corner is not + // mistaken for coincidence either. + const float relativeCoincidenceTolerance = 1e-6f; + var ray1Scale = MathF.Max( + MathF.Max(MathF.Abs(start.X), MathF.Abs(start.Y)), + MathF.Max(MathF.Abs(corner.X), MathF.Abs(corner.Y))); + var ray2Scale = MathF.Max( + MathF.Max(MathF.Abs(end.X), MathF.Abs(end.Y)), + MathF.Max(MathF.Abs(corner.X), MathF.Abs(corner.Y))); + if (radius == 0f + || len1 <= ray1Scale * relativeCoincidenceTolerance + || len2 <= ray2Scale * relativeCoincidenceTolerance) { _currentCommands.Add(PathCommand.LineTo(corner)); return this; diff --git a/test/DemaConsulting.CanvasNet.Tests/Canvas/SurfaceTests.cs b/test/DemaConsulting.CanvasNet.Tests/Canvas/SurfaceTests.cs index d8c6e1fa..73fb50ee 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Canvas/SurfaceTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Canvas/SurfaceTests.cs @@ -539,7 +539,11 @@ public void Surface_PremultiplyAlpha_MultiRowBoundaryWidths_ComputesExpectedPixe /// private static byte ComputeExpectedPremultiplied(byte color, byte alpha) { - var value = Math.Round(color * alpha / 255.0, MidpointRounding.AwayFromZero); + // Cast the first operand to double before multiplying (rather than casting the product + // afterward) so the multiplication itself is performed in double precision - this is + // harmless for today's byte-range operands, but avoids the integer-multiplication-then- + // widen pattern that would silently truncate for wider integer types. + var value = Math.Round((double)color * alpha / 255.0, MidpointRounding.AwayFromZero); return (byte)Math.Clamp(value, 0, 255); } diff --git a/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs b/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs index 18e441c8..01813763 100644 --- a/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs @@ -318,8 +318,10 @@ public void CanvasNet_SystemIntegration_JpegGetInfoWithLargeLeadingSegments_Retu var paddedBytes = padded.ToArray(); // Act - var info = JpegCodec.GetInfo(new MemoryStream(paddedBytes)); - var loaded = JpegCodec.Load(new MemoryStream(paddedBytes)); + using var infoStream = new MemoryStream(paddedBytes); + var info = JpegCodec.GetInfo(infoStream); + using var loadStream = new MemoryStream(paddedBytes); + var loaded = JpegCodec.Load(loadStream); // Assert: the system reports the expected integrated declared dimensions, matching what // Load itself decodes, without throwing merely because the probe cap was reached @@ -361,7 +363,8 @@ public void CanvasNet_SystemIntegration_GifLoad_ReturnsExpectedPixel() ]; // Act: load the surface through the public API - var surface = GifCodec.Load(new MemoryStream(gif)); + using var gifStream = new MemoryStream(gif); + var surface = GifCodec.Load(gifStream); // Assert: the system produces the expected integrated decoded pixel value Assert.Equal(1, surface.Width); @@ -675,7 +678,8 @@ public void CanvasNet_SystemIntegration_LoadFontAndFillGlyphOutline_ReturnsExpec // Arrange: load a synthetic font, look up 'A', and extract its glyph outline in raw font // design units (0..1000, matching this font's UnitsPerEm) var fontData = BuildSyntheticFontWithDiamondGlyph(); - var font = TrueTypeFont.Load(new MemoryStream(fontData)); + using var fontStream = new MemoryStream(fontData); + var font = TrueTypeFont.Load(fontStream); var glyphIndex = font.GetGlyphIndex('A'); var outline = font.GetGlyphOutline(glyphIndex); @@ -750,7 +754,8 @@ public void CanvasNet_SystemIntegration_LoadFontAndQueryMetrics_ReturnsExpectedV // Arrange/Act: load a synthetic font and query its top-level metrics and the glyph's // advance width through the public API var fontData = BuildSyntheticFontWithDiamondGlyph(); - var font = TrueTypeFont.Load(new MemoryStream(fontData)); + using var fontStream = new MemoryStream(fontData); + var font = TrueTypeFont.Load(fontStream); var glyphIndex = font.GetGlyphIndex('A'); var advanceWidth = font.GetAdvanceWidth(glyphIndex); @@ -873,7 +878,8 @@ public void CanvasNet_SystemIntegration_DrawAndMeasureTextViaCanvas_RendersAndMe { // Arrange: load a synthetic font with a single diamond-shaped glyph mapped from 'A' var fontData = BuildSyntheticFontWithDiamondGlyph(); - var font = TrueTypeFont.Load(new MemoryStream(fontData)); + using var fontStream = new MemoryStream(fontData); + var font = TrueTypeFont.Load(fontStream); const float size = 32f; var color = new Rgba32(0, 128, 255, 255); diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/GifCodecTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/GifCodecTests.cs index 2ffada92..2af48019 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/GifCodecTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/GifCodecTests.cs @@ -417,11 +417,13 @@ public void GifCodec_GetInfo_OversizedDimensions_ReturnsRawValue_ButLoadThrows() WriteHeader(stream, oversized, oversized, null); var bytes = stream.ToArray(); - var info = GifCodec.GetInfo(new MemoryStream(bytes)); + using var infoStream = new MemoryStream(bytes); + var info = GifCodec.GetInfo(infoStream); Assert.Equal(oversized, info.Width); Assert.Equal(oversized, info.Height); - Assert.Throws(() => GifCodec.Load(new MemoryStream(bytes))); + using var loadStream = new MemoryStream(bytes); + Assert.Throws(() => GifCodec.Load(loadStream)); } /// Test: GifCodec_GetInfo_ZeroWidth_ThrowsInvalidDataException. diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/GifFixtureTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/GifFixtureTests.cs index d9a40c4f..a68ab6df 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/GifFixtureTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/GifFixtureTests.cs @@ -19,9 +19,12 @@ public class GifFixtureTests { /// /// The directory containing the GIF fixture corpus, copied to the test output directory - /// by the test project's GifFixtures\** content item. + /// by the test project's GifFixtures\** content item. Path.Join is used + /// instead of purely to avoid CodeQL's + /// cs/path-combine rule, since Path.Join does not discard + /// when the second segment looks rooted. /// - private static string AssetsPath => Path.Combine(AppContext.BaseDirectory, "GifFixtures"); + private static string AssetsPath => Path.Join(AppContext.BaseDirectory, "GifFixtures"); /// /// Resolves a fixture file within . The file names always diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegCodecTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegCodecTests.cs index 01ee2dcd..bbac63c3 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegCodecTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegCodecTests.cs @@ -366,7 +366,8 @@ public void JpegCodec_Load_MissingSoiMarker_ThrowsInvalidDataException() { var bytes = new byte[] { 0x00, 0x00, MarkerPrefix, MarkerEoi }; - Assert.Throws(() => JpegCodec.Load(new MemoryStream(bytes))); + using var ms368 = new MemoryStream(bytes); + Assert.Throws(() => JpegCodec.Load(ms368)); } /// @@ -384,7 +385,8 @@ public void JpegCodec_Load_UnsupportedSofMarker_ThrowsInvalidDataException(byte BuildSofSegment(marker, 1, 1, (1, 0x11, 0)) ]); - Assert.Throws(() => JpegCodec.Load(new MemoryStream(jpeg))); + using var ms386 = new MemoryStream(jpeg); + Assert.Throws(() => JpegCodec.Load(ms386)); } /// @@ -408,8 +410,10 @@ public void JpegCodec_UnsupportedSofMarkerFollowedByValidSof0_BothLoadAndGetInfo BuildSofSegment(MarkerSof0, 4, 3, (1, 0x11, 0)) ]); - Assert.Throws(() => JpegCodec.Load(new MemoryStream(jpeg))); - Assert.Throws(() => JpegCodec.GetInfo(new MemoryStream(jpeg))); + using var ms410 = new MemoryStream(jpeg); + Assert.Throws(() => JpegCodec.Load(ms410)); + using var ms411 = new MemoryStream(jpeg); + Assert.Throws(() => JpegCodec.GetInfo(ms411)); } /// @@ -430,7 +434,8 @@ public void JpegCodec_Load_FourComponentSof0_ThrowsInvalidDataException() (4, 0x11, 0)) ]); - Assert.Throws(() => JpegCodec.Load(new MemoryStream(jpeg))); + using var ms432 = new MemoryStream(jpeg); + Assert.Throws(() => JpegCodec.Load(ms432)); } /// @@ -447,7 +452,8 @@ public void JpegCodec_Load_WidthExceedsMaxDimension_ThrowsInvalidDataException() BuildSofSegment(MarkerSof0, Surface.MaxDimension + 1, 1, (1, 0x11, 0)) ]); - Assert.Throws(() => JpegCodec.Load(new MemoryStream(jpeg))); + using var ms449 = new MemoryStream(jpeg); + Assert.Throws(() => JpegCodec.Load(ms449)); } /// @@ -463,7 +469,8 @@ public void JpegCodec_Load_HeightExceedsMaxDimension_ThrowsInvalidDataException( BuildSofSegment(MarkerSof0, 1, Surface.MaxDimension + 1, (1, 0x11, 0)) ]); - Assert.Throws(() => JpegCodec.Load(new MemoryStream(jpeg))); + using var ms465 = new MemoryStream(jpeg); + Assert.Throws(() => JpegCodec.Load(ms465)); } /// @@ -479,7 +486,8 @@ public void JpegCodec_Load_MissingSofSegment_ThrowsInvalidDataException() BuildSosSegment((1, 0x00)) ]); - Assert.Throws(() => JpegCodec.Load(new MemoryStream(jpeg))); + using var ms481 = new MemoryStream(jpeg); + Assert.Throws(() => JpegCodec.Load(ms481)); } /// @@ -496,7 +504,8 @@ public void JpegCodec_Load_MissingDhtSegment_ThrowsInvalidDataException() ], entropyData: [0x00]); - Assert.Throws(() => JpegCodec.Load(new MemoryStream(jpeg))); + using var ms498 = new MemoryStream(jpeg); + Assert.Throws(() => JpegCodec.Load(ms498)); } /// @@ -513,7 +522,8 @@ public void JpegCodec_Load_MissingDqtSegment_ThrowsInvalidDataException() ], entropyData: [0x00]); - Assert.Throws(() => JpegCodec.Load(new MemoryStream(jpeg))); + using var ms515 = new MemoryStream(jpeg); + Assert.Throws(() => JpegCodec.Load(ms515)); } /// @@ -529,7 +539,8 @@ public void JpegCodec_Load_MissingSosSegment_ThrowsInvalidDataException() BuildSofSegment(MarkerSof0, 1, 1, (1, 0x11, 0)) ]); - Assert.Throws(() => JpegCodec.Load(new MemoryStream(jpeg))); + using var ms531 = new MemoryStream(jpeg); + Assert.Throws(() => JpegCodec.Load(ms531)); } /// @@ -541,7 +552,8 @@ public void JpegCodec_Load_TruncatedHeader_ThrowsInvalidDataException() { var jpeg = new byte[] { MarkerPrefix, MarkerSoi, MarkerPrefix, MarkerSof0, 0x00 }; - Assert.Throws(() => JpegCodec.Load(new MemoryStream(jpeg))); + using var ms543 = new MemoryStream(jpeg); + Assert.Throws(() => JpegCodec.Load(ms543)); } /// @@ -561,7 +573,8 @@ public void JpegCodec_Load_TruncatedEntropyData_ThrowsInvalidDataException() entropyData: [], includeEoi: false); - Assert.Throws(() => JpegCodec.Load(new MemoryStream(jpeg))); + using var ms563 = new MemoryStream(jpeg); + Assert.Throws(() => JpegCodec.Load(ms563)); } /// @@ -608,7 +621,8 @@ public static TheoryData TruncatedSegmentPayloadCases() [MemberData(nameof(TruncatedSegmentPayloadCases))] public void JpegCodec_Load_TruncatedSegmentPayload_ThrowsInvalidDataException(byte[] jpeg) { - Assert.Throws(() => JpegCodec.Load(new MemoryStream(jpeg))); + using var ms610 = new MemoryStream(jpeg); + Assert.Throws(() => JpegCodec.Load(ms610)); } // ------------------------------------------------------------------------------------------ @@ -707,8 +721,10 @@ public void JpegCodec_Load_WithRestartMarkers_DecodesIdenticallyToWithoutRestart ], entropyData: [0x00, MarkerPrefix, MarkerRst0, 0x00]); - var canvasWithoutRestartMarkers = JpegCodec.Load(new MemoryStream(withoutRestartMarkers)); - var canvasWithRestartMarkers = JpegCodec.Load(new MemoryStream(withRestartMarkers)); + using var ms709 = new MemoryStream(withoutRestartMarkers); + var canvasWithoutRestartMarkers = JpegCodec.Load(ms709); + using var ms710 = new MemoryStream(withRestartMarkers); + var canvasWithRestartMarkers = JpegCodec.Load(ms710); AssertPixelsApproximatelyEqual(canvasWithoutRestartMarkers, canvasWithRestartMarkers, 0); } @@ -777,7 +793,8 @@ public void JpegCodec_GetInfo_Grayscale_ReturnsExpectedInfoWithoutSosOrEntropyDa BuildSofSegment(MarkerSof0, 4, 3, (1, 0x11, 0)) ], includeEoi: false); - var info = JpegCodec.GetInfo(new MemoryStream(jpeg)); + using var ms779 = new MemoryStream(jpeg); + var info = JpegCodec.GetInfo(ms779); Assert.Equal(new ImageInfo(4, 3, 1, false), info); } @@ -797,7 +814,8 @@ public void JpegCodec_GetInfo_Color_ReturnsExpectedInfo() BuildSofSegment(MarkerSof0, 6, 4, (1, 0x22, 0), (2, 0x11, 1), (3, 0x11, 1)) ]); - var info = JpegCodec.GetInfo(new MemoryStream(jpeg)); + using var ms799 = new MemoryStream(jpeg); + var info = JpegCodec.GetInfo(ms799); Assert.Equal(new ImageInfo(6, 4, 3, false), info); } @@ -867,7 +885,8 @@ public void JpegCodec_GetInfo_MissingSoiMarker_ThrowsInvalidDataException() { var jpeg = new byte[] { 0x00, 0x00, 0x00, 0x00 }; - Assert.Throws(() => JpegCodec.GetInfo(new MemoryStream(jpeg))); + using var ms869 = new MemoryStream(jpeg); + Assert.Throws(() => JpegCodec.GetInfo(ms869)); } /// @@ -879,7 +898,8 @@ public void JpegCodec_GetInfo_TruncatedBeforeSofFound_ThrowsInvalidDataException { var jpeg = BuildJpeg([BuildMinimalDqtSegment()], includeEoi: false); - Assert.Throws(() => JpegCodec.GetInfo(new MemoryStream(jpeg))); + using var ms881 = new MemoryStream(jpeg); + Assert.Throws(() => JpegCodec.GetInfo(ms881)); } /// @@ -891,7 +911,8 @@ public void JpegCodec_GetInfo_SosBeforeSof_ThrowsInvalidDataException() { var jpeg = BuildJpeg([BuildSosSegment((1, 0x00))]); - Assert.Throws(() => JpegCodec.GetInfo(new MemoryStream(jpeg))); + using var ms893 = new MemoryStream(jpeg); + Assert.Throws(() => JpegCodec.GetInfo(ms893)); } /// @@ -911,7 +932,8 @@ public void JpegCodec_GetInfo_NoSofAnywhereEvenBeyondProbeCap_ThrowsInvalidDataE var jpeg = BuildJpeg([], entropyData: filler, includeEoi: false); - var exception = Assert.Throws(() => JpegCodec.GetInfo(new MemoryStream(jpeg))); + using var ms913 = new MemoryStream(jpeg); + var exception = Assert.Throws(() => JpegCodec.GetInfo(ms913)); Assert.Contains("SOF0/SOF2 marker", exception.Message, StringComparison.OrdinalIgnoreCase); } @@ -949,7 +971,8 @@ public void JpegCodec_GetInfo_SofSegmentExtendsBeyondProbeCap_FailsForFormatReas var jpeg = BuildJpeg([.. segments], entropyData: new byte[200_000], includeEoi: false); - var exception = Assert.Throws(() => JpegCodec.GetInfo(new MemoryStream(jpeg))); + using var ms951 = new MemoryStream(jpeg); + var exception = Assert.Throws(() => JpegCodec.GetInfo(ms951)); Assert.DoesNotContain("probe limit", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("sample precision", exception.Message, StringComparison.OrdinalIgnoreCase); } @@ -986,8 +1009,10 @@ public void JpegCodec_GetInfo_LargeLeadingAppSegments_SucceedsAndMatchesLoadResu var jpeg = BuildJpeg([.. segments], entropyData: [0x00, 0x00]); // Act - var info = JpegCodec.GetInfo(new MemoryStream(jpeg)); - var surface = JpegCodec.Load(new MemoryStream(jpeg)); + using var ms988 = new MemoryStream(jpeg); + var info = JpegCodec.GetInfo(ms988); + using var ms989 = new MemoryStream(jpeg); + var surface = JpegCodec.Load(ms989); // Assert: GetInfo succeeds (rather than throwing merely because the soft cap was // reached) and matches Load's own decoded dimensions/channel count exactly. @@ -1145,8 +1170,10 @@ public void JpegCodec_GetInfoVsLoad_LeadingMetadataExceedsHardLimit_BothThrowCon var jpeg = BuildJpeg([.. segments], entropyData: [0x00, 0x00]); // Act - var loadException = Assert.Throws(() => JpegCodec.Load(new MemoryStream(jpeg))); - var getInfoException = Assert.Throws(() => JpegCodec.GetInfo(new MemoryStream(jpeg))); + using var ms1147 = new MemoryStream(jpeg); + var loadException = Assert.Throws(() => JpegCodec.Load(ms1147)); + using var ms1148 = new MemoryStream(jpeg); + var getInfoException = Assert.Throws(() => JpegCodec.GetInfo(ms1148)); // Assert: both Load and GetInfo, on the exact same bytes, throw InvalidDataException // referencing the hard limit - true parity, not a documented exception. @@ -1196,8 +1223,10 @@ public void JpegCodec_GetInfoVsLoad_LeadingSegmentCountExceedsLimit_BothThrowCon "segment-count cap can plausibly trigger the failure for either method."); // Act - var loadException = Assert.Throws(() => JpegCodec.Load(new MemoryStream(jpeg))); - var getInfoException = Assert.Throws(() => JpegCodec.GetInfo(new MemoryStream(jpeg))); + using var ms1198 = new MemoryStream(jpeg); + var loadException = Assert.Throws(() => JpegCodec.Load(ms1198)); + using var ms1199 = new MemoryStream(jpeg); + var getInfoException = Assert.Throws(() => JpegCodec.GetInfo(ms1199)); // Assert: both Load and GetInfo, on the exact same bytes, throw InvalidDataException // referencing the segment limit - true parity, not a documented exception. @@ -1263,8 +1292,10 @@ public void JpegCodec_GetInfoVsLoad_SofSegmentStraddlesHardLimit_BothThrowConsis "Test fixture must place the SOF0 segment so it straddles MaxProbeHeaderBytesHardLimit."); // Act - var loadException = Assert.Throws(() => JpegCodec.Load(new MemoryStream(jpeg))); - var getInfoException = Assert.Throws(() => JpegCodec.GetInfo(new MemoryStream(jpeg))); + using var ms1265 = new MemoryStream(jpeg); + var loadException = Assert.Throws(() => JpegCodec.Load(ms1265)); + using var ms1266 = new MemoryStream(jpeg); + var getInfoException = Assert.Throws(() => JpegCodec.GetInfo(ms1266)); // Assert: both Load and GetInfo, on the exact same bytes, throw InvalidDataException // referencing the hard limit even though the SOF0 segment - not merely the leading filler @@ -1309,8 +1340,10 @@ public void JpegCodec_GetInfoVsLoad_SosBeforeSofAtSegmentCountBoundary_BothThrow var jpeg = BuildJpeg([.. segments], entropyData: null, includeEoi: false); // Act - var loadException = Assert.Throws(() => JpegCodec.Load(new MemoryStream(jpeg))); - var getInfoException = Assert.Throws(() => JpegCodec.GetInfo(new MemoryStream(jpeg))); + using var ms1311 = new MemoryStream(jpeg); + var loadException = Assert.Throws(() => JpegCodec.Load(ms1311)); + using var ms1312 = new MemoryStream(jpeg); + var getInfoException = Assert.Throws(() => JpegCodec.GetInfo(ms1312)); // Assert: both Load and GetInfo, on the exact same bytes, throw InvalidDataException // referencing the SOS-before-SOF condition, not the segment-count limit - proving the SOS @@ -1423,8 +1456,10 @@ public void JpegCodec_GetInfo_SofAsSegmentImmediatelyAfterSegmentCountLimit_Stil var jpeg = BuildJpeg([.. segments], entropyData: [0x00, 0x00]); // Act - var info = JpegCodec.GetInfo(new MemoryStream(jpeg)); - var surface = JpegCodec.Load(new MemoryStream(jpeg)); + using var ms1425 = new MemoryStream(jpeg); + var info = JpegCodec.GetInfo(ms1425); + using var ms1426 = new MemoryStream(jpeg); + var surface = JpegCodec.Load(ms1426); // Assert: GetInfo succeeds (rather than throwing merely because the SOF marker landed // immediately past the segment-count ceiling) and matches Load's own decoded @@ -1525,7 +1560,8 @@ public void JpegCodec_GetInfo_ZeroLengthSegment_TerminatesPromptlyWithInvalidDat // process itself would need to be killed by the CI job's own timeout (regressed to truly // unbounded) - there is no ambiguous "slow but fine" middle ground that timing would add // value in distinguishing. - var caught = Assert.Throws(() => JpegCodec.GetInfo(new MemoryStream(jpeg))); + using var ms1527 = new MemoryStream(jpeg); + var caught = Assert.Throws(() => JpegCodec.GetInfo(ms1527)); // Assert: the call returned (did not hang) with the documented exception. Assert.NotNull(caught); @@ -1544,9 +1580,11 @@ public void JpegCodec_GetInfo_OversizedDimensions_NotRejected_ButLoadThrows() BuildSofSegment(MarkerSof0, Surface.MaxDimension + 1, 1, (1, 0x11, 0)) ]); - var info = JpegCodec.GetInfo(new MemoryStream(jpeg)); + using var ms1546 = new MemoryStream(jpeg); + var info = JpegCodec.GetInfo(ms1546); Assert.Equal(Surface.MaxDimension + 1, info.Width); - Assert.Throws(() => JpegCodec.Load(new MemoryStream(jpeg))); + using var ms1549 = new MemoryStream(jpeg); + Assert.Throws(() => JpegCodec.Load(ms1549)); } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs index 752ab918..d45b36fc 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs @@ -19,15 +19,20 @@ public class JpegFixtureTests { /// /// The directory containing the JPEG fixture corpus, copied to the test output directory - /// by the test project's JpegFixtures\** content item. + /// by the test project's JpegFixtures\** content item. Path.Join is used + /// instead of purely to avoid CodeQL's + /// cs/path-combine rule, since Path.Join does not discard + /// when the second segment looks rooted. /// - private static string AssetsPath => Path.Combine(AppContext.BaseDirectory, "JpegFixtures"); + private static string AssetsPath => Path.Join(AppContext.BaseDirectory, "JpegFixtures"); /// /// The directory containing the PngSuite corpus, copied to the test output directory by - /// the test project's PngSuite\** content item. + /// the test project's PngSuite\** content item. Path.Join is used instead + /// of for the same CodeQL reason as + /// . /// - private static string PngSuitePath => Path.Combine(AppContext.BaseDirectory, "PngSuite"); + private static string PngSuitePath => Path.Join(AppContext.BaseDirectory, "PngSuite"); /// /// Resolves a fixture file within . The file names always @@ -113,7 +118,7 @@ private static void AssertPixelsApproximatelyEqual(Surface expected, Surface act [Fact] public void JpegFixtures_SourcePngDimensions_Are32x32() { - var source = PngCodec.Load(Path.Combine(PngSuitePath, "basn2c08.png")); + var source = PngCodec.Load(ResolveFixturePath(PngSuitePath, "basn2c08.png")); Assert.Equal(32, source.Width); Assert.Equal(32, source.Height); @@ -142,7 +147,7 @@ public void JpegCodec_Load_Fixture_ReturnsCanvasWithExpectedDimensions(string fi [MemberData(nameof(ColorFixtureFiles))] public void JpegCodec_Load_ColorFixture_MatchesSourcePngWithinTolerance(string fileName, int tolerancePerChannel) { - var expected = PngCodec.Load(Path.Combine(PngSuitePath, "basn2c08.png")); + var expected = PngCodec.Load(ResolveFixturePath(PngSuitePath, "basn2c08.png")); var actual = JpegCodec.Load(ResolveFixturePath(AssetsPath, fileName)); AssertPixelsApproximatelyEqual(expected, actual, tolerancePerChannel); diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/PngSuiteTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/PngSuiteTests.cs index 493c650b..375b03c6 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/PngSuiteTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/PngSuiteTests.cs @@ -50,9 +50,12 @@ public class PngSuiteTests { /// /// The directory containing the PngSuite corpus, copied to the test output directory by - /// the test project's PngSuite\** content item. + /// the test project's PngSuite\** content item. Path.Join is used instead + /// of purely to avoid CodeQL's + /// cs/path-combine rule, since Path.Join does not discard + /// when the second segment looks rooted. /// - private static string AssetsPath => Path.Combine(AppContext.BaseDirectory, "PngSuite"); + private static string AssetsPath => Path.Join(AppContext.BaseDirectory, "PngSuite"); /// /// Resolves a fixture file within . The file names always diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs index 57fb0762..776cc10a 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgCodecTests.cs @@ -50,7 +50,8 @@ private static TrueTypeFont BuildTestFont() .AddTable("kern", kern) .Build(); - return TrueTypeFont.Load(new MemoryStream(data)); + using var stream = new MemoryStream(data); + return TrueTypeFont.Load(stream); } // ================================================================================================ @@ -65,7 +66,8 @@ public void SvgCodec_Load_Rect_RendersFilledRectangle() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 20, 20); + using var stream67 = ToStream(svg); + var surface = SvgCodec.Load(stream67, 20, 20); // Assert Assert.Equal(new Rgba32(0x11, 0x22, 0x33, 255), surface[10, 10]); @@ -84,7 +86,8 @@ public void SvgCodec_Load_RectWithRoundedCorners_CutsCornerButFillsCenter() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream86 = ToStream(svg); + var surface = SvgCodec.Load(stream86, 100, 100); // Assert: the extreme corner (well within the cut radius) is unfilled, the center is filled Assert.Equal(0, surface[1, 1].A); @@ -99,7 +102,8 @@ public void SvgCodec_Load_Circle_RendersFilledCircle() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream101 = ToStream(svg); + var surface = SvgCodec.Load(stream101, 100, 100); // Assert: center is filled; far corner (outside the circle) is not Assert.Equal(new Rgba32(0, 255, 0, 255), surface[50, 50]); @@ -117,7 +121,8 @@ public void SvgCodec_Load_Ellipse_RendersElongatedFill() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream119 = ToStream(svg); + var surface = SvgCodec.Load(stream119, 100, 100); // Assert: point (85,50) is within the long (x) axis's radius but would be outside a // radius-10 circle - only a true ellipse (not a circle) fills it @@ -137,7 +142,8 @@ public void SvgCodec_Load_Line_RendersStrokedLine() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream139 = ToStream(svg); + var surface = SvgCodec.Load(stream139, 100, 100); // Assert: a point on the line is stroked; a point well away from the line is not Assert.Equal(255, surface[50, 50].A); @@ -157,7 +163,8 @@ public void SvgCodec_Load_Polyline_DoesNotCloseBetweenLastAndFirstPoint() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream159 = ToStream(svg); + var surface = SvgCodec.Load(stream159, 100, 100); // Assert: a point on the actual "L" path is stroked Assert.Equal(255, surface[10, 50].A); @@ -176,7 +183,8 @@ public void SvgCodec_Load_Polygon_RendersClosedFilledShape() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream178 = ToStream(svg); + var surface = SvgCodec.Load(stream178, 100, 100); // Assert: the triangle's centroid-ish interior point is filled Assert.Equal(new Rgba32(255, 0, 255, 255), surface[50, 70]); @@ -196,7 +204,8 @@ public void SvgCodec_Load_PathWithAbsoluteMoveLineClose_RendersFilledTriangle() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream198 = ToStream(svg); + var surface = SvgCodec.Load(stream198, 100, 100); // Assert Assert.Equal(255, surface[50, 70].A); @@ -214,7 +223,8 @@ public void SvgCodec_Load_PathWithRelativeMoveLineClose_RendersSameShapeAsAbsolu const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream216 = ToStream(svg); + var surface = SvgCodec.Load(stream216, 100, 100); // Assert: identical filled/unfilled pixels to the absolute-command test above Assert.Equal(255, surface[50, 70].A); @@ -232,7 +242,8 @@ public void SvgCodec_Load_PathWithHorizontalAndVerticalLines_RendersRectangle() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream234 = ToStream(svg); + var surface = SvgCodec.Load(stream234, 100, 100); // Assert Assert.Equal(255, surface[50, 50].A); @@ -251,7 +262,8 @@ public void SvgCodec_Load_PathWithCubicBezier_RendersFilledCurvedShape() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream253 = ToStream(svg); + var surface = SvgCodec.Load(stream253, 100, 100); // Assert: the base rectangle's interior is filled; well above the bulge is not Assert.Equal(255, surface[50, 70].A); @@ -270,7 +282,8 @@ public void SvgCodec_Load_PathWithSmoothCubicBezier_RendersFilledShape() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream272 = ToStream(svg); + var surface = SvgCodec.Load(stream272, 100, 100); // Assert Assert.Equal(255, surface[50, 60].A); @@ -284,7 +297,8 @@ public void SvgCodec_Load_PathWithQuadraticBezier_RendersFilledShape() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream286 = ToStream(svg); + var surface = SvgCodec.Load(stream286, 100, 100); // Assert Assert.Equal(255, surface[50, 70].A); @@ -302,7 +316,8 @@ public void SvgCodec_Load_PathWithSmoothQuadraticBezier_RendersFilledShape() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream304 = ToStream(svg); + var surface = SvgCodec.Load(stream304, 100, 100); // Assert Assert.Equal(255, surface[50, 60].A); @@ -319,7 +334,8 @@ public void SvgCodec_Load_PathWithArc_RendersFilledHalfDisc() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream321 = ToStream(svg); + var surface = SvgCodec.Load(stream321, 100, 100); // Assert: a point well within the half-disc (below the flat edge) is filled Assert.Equal(255, surface[50, 80].A); @@ -342,7 +358,8 @@ public void SvgCodec_Load_GroupFillInheritance_AppliesToChildWithoutOwnFill() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream344 = ToStream(svg); + var surface = SvgCodec.Load(stream344, 100, 100); // Assert Assert.Equal(new Rgba32(0xFF, 0x88, 0x00, 255), surface[25, 25]); @@ -359,7 +376,8 @@ public void SvgCodec_Load_ChildOwnFill_OverridesGroupFill() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream361 = ToStream(svg); + var surface = SvgCodec.Load(stream361, 100, 100); // Assert Assert.Equal(new Rgba32(0, 0, 255, 255), surface[25, 25]); @@ -376,7 +394,8 @@ public void SvgCodec_Load_NestedGroupOpacity_MultipliesIntoFillAlpha() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream378 = ToStream(svg); + var surface = SvgCodec.Load(stream378, 100, 100); // Assert: resulting alpha is approximately 25% of fully opaque, not 50% or 100% var alpha = surface[25, 25].A; @@ -395,7 +414,8 @@ public void SvgCodec_Load_TranslateTransform_MovesShapeByOffset() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream397 = ToStream(svg); + var surface = SvgCodec.Load(stream397, 100, 100); // Assert: filled at the translated position; not filled at the pre-translation position Assert.Equal(255, surface[50, 50].A); @@ -410,7 +430,8 @@ public void SvgCodec_Load_ScaleTransform_EnlargesShapeAboutOrigin() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream412 = ToStream(svg); + var surface = SvgCodec.Load(stream412, 100, 100); // Assert: filled well within the scaled-up shape; not filled beyond it Assert.Equal(255, surface[40, 40].A); @@ -429,7 +450,8 @@ public void SvgCodec_Load_RotateTransform_RotatesShapeAboutOrigin() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream431 = ToStream(svg); + var surface = SvgCodec.Load(stream431, 100, 100); // Assert: the square (originally centered near local (40,0)) now renders near (0,40) Assert.Equal(255, surface[0, 40].A); @@ -445,7 +467,8 @@ public void SvgCodec_Load_MatrixTransform_AppliesRawComponents() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream447 = ToStream(svg); + var surface = SvgCodec.Load(stream447, 100, 100); // Assert Assert.Equal(255, surface[50, 50].A); @@ -464,7 +487,8 @@ public void SvgCodec_Load_SkewXTransform_ShearsShapeAlongX() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream466 = ToStream(svg); + var surface = SvgCodec.Load(stream466, 100, 100); // Assert: near the top (y=1), the strip has barely moved and still covers x~13 Assert.Equal(255, surface[13, 1].A); @@ -490,7 +514,8 @@ public void SvgCodec_Load_CombinedTransformFunctions_AppliesRightmostFunctionFir const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream492 = ToStream(svg); + var surface = SvgCodec.Load(stream492, 100, 100); // Assert: filled at the "rotate-then-translate" expected position Assert.Equal(255, surface[40, 80].A); @@ -514,7 +539,8 @@ public void SvgCodec_Load_FillRuleEvenOdd_PunchesHoleInOverlappingSubpaths() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream516 = ToStream(svg); + var surface = SvgCodec.Load(stream516, 100, 100); // Assert: the shared overlapping center is a hole (unfilled); the outer ring is filled Assert.Equal(0, surface[50, 50].A); @@ -532,7 +558,8 @@ public void SvgCodec_Load_FillRuleNonzeroDefault_FillsUnionOfOverlappingSubpaths const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream534 = ToStream(svg); + var surface = SvgCodec.Load(stream534, 100, 100); // Assert: the center is filled under nonzero, unlike under evenodd Assert.Equal(255, surface[50, 50].A); @@ -549,7 +576,8 @@ public void SvgCodec_Load_FillNoneWithStroke_RendersOnlyOutline() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream551 = ToStream(svg); + var surface = SvgCodec.Load(stream551, 100, 100); // Assert: the outline (near x=20) is stroked; the interior (center) is not filled Assert.Equal(255, surface[20, 50].A); @@ -567,7 +595,8 @@ public void SvgCodec_Load_StrokeDasharray_RendersGapsAlongLine() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream569 = ToStream(svg); + var surface = SvgCodec.Load(stream569, 100, 100); // Assert: at least one sampled point along the line is unstroked (a dash gap) - a solid // stroke (dasharray ignored) would leave every sampled point stroked @@ -604,7 +633,8 @@ public void SvgCodec_Load_StrokeMiterLimitZero_FallsBackToInheritedDefaultWithou const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream606 = ToStream(svg); + var surface = SvgCodec.Load(stream606, 100, 100); // Assert: the outline still renders (no exception, and the stroke was not dropped) Assert.Equal(255, surface[20, 50].A); @@ -622,7 +652,8 @@ public void SvgCodec_Load_StrokeMiterLimitNegative_FallsBackToInheritedDefaultWi const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream624 = ToStream(svg); + var surface = SvgCodec.Load(stream624, 100, 100); // Assert: the outline still renders (no exception, and the stroke was not dropped) Assert.Equal(255, surface[20, 50].A); @@ -645,7 +676,8 @@ public void SvgCodec_Load_StrokeMiterLimitNaN_FallsBackToInheritedDefaultWithout const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream647 = ToStream(svg); + var surface = SvgCodec.Load(stream647, 100, 100); // Assert: the outline still renders (no exception, and the stroke was not dropped) Assert.Equal(255, surface[20, 50].A); @@ -668,7 +700,8 @@ public void SvgCodec_Load_StrokeMiterLimitInfinity_FallsBackToInheritedDefaultWi const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream670 = ToStream(svg); + var surface = SvgCodec.Load(stream670, 100, 100); // Assert: the outline still renders (no exception, and the stroke was not dropped) Assert.Equal(255, surface[20, 50].A); @@ -685,7 +718,8 @@ public void SvgCodec_Load_StrokeMiterLimitValid_RendersNormally() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream687 = ToStream(svg); + var surface = SvgCodec.Load(stream687, 100, 100); // Assert: the outline renders normally Assert.Equal(255, surface[20, 50].A); @@ -741,7 +775,8 @@ public void SvgCodec_Load_ExtremeMiterLimitWithSpikeVertexSynthesizesOversizedMi ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream743 = ToStream(svg); + var surface = SvgCodec.Load(stream743, 100, 100); // Assert: rendering completed without incident (no hang/crash), and the whole stroke - // which, un-skipped, would have engulfed the entire 100x100 canvas given its 900,000-unit @@ -771,7 +806,8 @@ public void SvgCodec_Load_NormalMiterJoinWithDefaultMiterLimit_RendersNormally() ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream773 = ToStream(svg); + var surface = SvgCodec.Load(stream773, 100, 100); // Assert: the corner's sharp miter tip renders as expected, near (60,20) Assert.Equal(255, surface[60, 20].A); @@ -821,7 +857,8 @@ public void SvgCodec_Load_NestedTransformScaleOverflowsStrokeWidthToInfinity_Ski """; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream823 = ToStream(svg); + var surface = SvgCodec.Load(stream823, 100, 100); // Assert: rendering completed without the raw ArgumentOutOfRangeException a non-finite // effective stroke width reaching StrokeStyle's constructor would otherwise throw @@ -863,7 +900,8 @@ public void SvgCodec_Load_StrokeWidthScaledPastMagnitudeBound_SkipsStrokeWithout ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream865 = ToStream(svg); + var surface = SvgCodec.Load(stream865, 100, 100); // Assert: the fill still renders normally (its own transformed geometry stays within // bound) ... @@ -917,7 +955,8 @@ public void SvgCodec_Load_NestedTransformScaleOverflowsGradientTransformToNonFin """; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream919 = ToStream(svg); + var surface = SvgCodec.Load(stream919, 100, 100); // Assert: loads without throwing, and the (skipped) rect leaves nothing rendered Assert.Equal(100, surface.Width); @@ -967,7 +1006,8 @@ public void SvgCodec_Load_ScaledDashArrayOverflowsToInfinity_FallsBackToSolidStr // Act & Assert: rejected at parse time, well before RenderStroke's own scaling would ever // run - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream969 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream969, 100, 100)); } /// @@ -981,7 +1021,8 @@ public void SvgCodec_Load_Opacity_MultipliesIntoFillAlpha() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream983 = ToStream(svg); + var surface = SvgCodec.Load(stream983, 100, 100); // Assert: alpha is approximately 40% of fully opaque (0.4 * 255 = 102), not 0 or 255 Assert.InRange((int)surface[25, 25].A, 90, 112); @@ -1013,7 +1054,8 @@ public void SvgCodec_Load_LinearGradientUserSpaceOnUse_VariesAlongUserSpaceAxis( """; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream1015 = ToStream(svg); + var surface = SvgCodec.Load(stream1015, 100, 100); // Assert: brightness increases left to right, and reflects the user-space (not // bounding-box-relative) axis @@ -1041,7 +1083,8 @@ public void SvgCodec_Load_RadialGradient_VariesFromCenterToEdge() """; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream1043 = ToStream(svg); + var surface = SvgCodec.Load(stream1043, 100, 100); // Assert: the center is brighter than a point near the shape's edge Assert.True(surface[50, 50].R > surface[95, 50].R); @@ -1074,7 +1117,8 @@ public void SvgCodec_Load_RadialGradientRNegative_FallsBackToDefaultRadiusWithou """; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream1076 = ToStream(svg); + var surface = SvgCodec.Load(stream1076, 100, 100); // Assert: the rect still renders (no exception, and the fill was not dropped) Assert.Equal(255, surface[5, 5].A); @@ -1102,7 +1146,8 @@ public void SvgCodec_Load_RadialGradientFrNegative_FallsBackToDefaultFocalRadius """; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream1104 = ToStream(svg); + var surface = SvgCodec.Load(stream1104, 100, 100); // Assert: the rect still renders (no exception, and the fill was not dropped) Assert.Equal(255, surface[5, 5].A); @@ -1129,7 +1174,8 @@ public void SvgCodec_Load_RadialGradientRFrValid_RendersNormally() """; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream1131 = ToStream(svg); + var surface = SvgCodec.Load(stream1131, 100, 100); // Assert: the center is brighter than a point near the shape's edge (gradient still applied) Assert.True(surface[50, 50].R > surface[95, 50].R); @@ -1184,7 +1230,8 @@ public void SvgCodec_Load_GradientTransformComposedWithHugeBoundingBoxOverflowsT """; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream1186 = ToStream(svg); + var surface = SvgCodec.Load(stream1186, 100, 100); // Assert: loads without throwing, and the gradient fill is tolerated as "no paint" Assert.Equal(100, surface.Width); @@ -1214,7 +1261,8 @@ public void SvgCodec_Load_GradientSpreadMethodRepeat_TilesPastBaseRange() """; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream1216 = ToStream(svg); + var surface = SvgCodec.Load(stream1216, 100, 100); // Assert: the tiled positions render nearly the same color (repeat), and that color is // not the fully-clamped white a "pad" (default) spread would produce at x equals 25 @@ -1243,7 +1291,8 @@ public void SvgCodec_Load_StrokeGradient_PaintsVaryingColorAlongStroke() """; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream1245 = ToStream(svg); + var surface = SvgCodec.Load(stream1245, 100, 100); // Assert: the stroke is darker near the start than near the end Assert.True(surface[10, 50].R < surface[90, 50].R); @@ -1271,7 +1320,8 @@ public void SvgCodec_Load_GradientHrefInheritance_InheritsStopsFromTemplate() """; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream1273 = ToStream(svg); + var surface = SvgCodec.Load(stream1273, 100, 100); // Assert: the inherited stops still produce a left-to-right brightness gradient Assert.True(surface[10, 50].R < surface[90, 50].R); @@ -1300,7 +1350,8 @@ public void SvgCodec_Load_GradientHrefChainOfTwoHops_ResolvesToEventualStops() """; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream1302 = ToStream(svg); + var surface = SvgCodec.Load(stream1302, 100, 100); // Assert Assert.True(surface[10, 50].R < surface[90, 50].R); @@ -1325,7 +1376,8 @@ public void SvgCodec_Load_GradientHrefCycle_ThrowsInvalidDataException() """; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream1327 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream1327, 100, 100)); } /// @@ -1357,7 +1409,8 @@ public void SvgCodec_Load_GradientReferencedByManyShapes_CachesStopsAndRendersId """; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 50); + using var stream1359 = ToStream(svg); + var surface = SvgCodec.Load(stream1359, 100, 50); // Assert: every one of the 50 rows shows the same left-to-right brightness ramp from the // shared, cached gradient @@ -1441,7 +1494,8 @@ public void SvgCodec_Load_UseElement_RendersCopyAtOffsetIndependentOfDocumentOrd const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 10, 10); + using var stream1443 = ToStream(svg); + var surface = SvgCodec.Load(stream1443, 10, 10); // Assert: the original box renders at (0,0)-(4,4) Assert.Equal(255, surface[1, 1].A); @@ -1460,7 +1514,8 @@ public void SvgCodec_Load_UseElementDanglingReference_IsSilentNoOp() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 10, 10); + using var stream1462 = ToStream(svg); + var surface = SvgCodec.Load(stream1462, 10, 10); // Assert: no exception, and nothing was rendered Assert.Equal(0, surface[5, 5].A); @@ -1487,7 +1542,8 @@ public void SvgCodec_Load_UseElementReferencingGroup_RendersAllGroupChildren() """; // Act - var surface = SvgCodec.Load(ToStream(svg), 20, 20); + using var stream1489 = ToStream(svg); + var surface = SvgCodec.Load(stream1489, 20, 20); // Assert: both group children rendered Assert.Equal(255, surface[1, 1].A); @@ -1512,7 +1568,8 @@ public void SvgCodec_Load_UseElementMutualRecursionCycle_ThrowsInvalidDataExcept """; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 10, 10)); + using var stream1514 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream1514, 10, 10)); } /// @@ -1552,7 +1609,8 @@ public void SvgCodec_Load_UseFanOutExceedingTotalElementBudget_ThrowsInvalidData builder.Append(""); // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(builder.ToString()), 10, 10)); + using var stream1554 = ToStream(builder.ToString()); + Assert.Throws(() => SvgCodec.Load(stream1554, 10, 10)); } /// @@ -1574,7 +1632,8 @@ public void SvgCodec_Load_DeeplyNestedGroups_ThrowsInvalidDataException() + ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 10, 10)); + using var stream1576 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream1576, 10, 10)); } // ================================================================================================ @@ -1600,7 +1659,8 @@ public void SvgCodec_Load_PathDataExceedingGeometryWorkBudget_ThrowsInvalidDataE var svg = $""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 10, 10)); + using var stream1602 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream1602, 10, 10)); } /// @@ -1618,7 +1678,8 @@ public void SvgCodec_Load_PointListExceedingGeometryWorkBudget_ThrowsInvalidData var svg = $""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 10, 10)); + using var stream1620 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream1620, 10, 10)); } /// @@ -1648,7 +1709,7 @@ public void SvgCodec_Load_PointsListLargeExceedingBudget_ThrowsWithoutLargeAlloc const int hugePairCount = 1_000_000; var points = string.Concat(Enumerable.Repeat("1,1 ", hugePairCount)); var svg = $""; - var stream = ToStream(svg); + using var stream = ToStream(svg); // Act var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); @@ -1681,7 +1742,8 @@ public void SvgCodec_Load_TextExceedingGeometryWorkBudget_ThrowsInvalidDataExcep var fonts = new Dictionary { ["TestFont"] = BuildTestFont() }; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 10, 10, fonts)); + using var stream1683 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream1683, 10, 10, fonts)); } // ================================================================================================ @@ -1705,7 +1767,7 @@ public void SvgCodec_Load_StrokeDasharrayExceedingNumberListLengthCap_ThrowsWith const int hugeNumberCount = 1_000_000; var dasharray = string.Join(',', Enumerable.Repeat("1", hugeNumberCount)); var svg = $""; - var stream = ToStream(svg); + using var stream = ToStream(svg); // Act var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); @@ -1734,7 +1796,8 @@ public void SvgCodec_Load_TransformArgumentListExceedingLengthCap_ThrowsInvalidD var svg = $""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 10, 10)); + using var stream1736 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream1736, 10, 10)); } /// @@ -1752,7 +1815,8 @@ public void SvgCodec_Load_ViewBoxNumberListExceedingLengthCap_ThrowsInvalidDataE var svg = $""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 10, 10)); + using var stream1754 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream1754, 10, 10)); } // ================================================================================================ @@ -1783,7 +1847,8 @@ public void SvgCodec_Load_UnrenderedDefsElementCountExceedingDocumentElementBudg builder.Append(""); // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(builder.ToString()), 10, 10)); + using var stream1785 = ToStream(builder.ToString()); + Assert.Throws(() => SvgCodec.Load(stream1785, 10, 10)); } /// @@ -1810,7 +1875,7 @@ public void SvgCodec_Load_GradientStopCountExceedingDocumentElementBudget_Throws builder.Append(""); builder.Append(""); - var stream = ToStream(builder.ToString()); + using var stream = ToStream(builder.ToString()); // Act var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); @@ -1843,7 +1908,8 @@ public void SvgCodec_Load_TextWithMatchingFont_RendersGlyphAtExpectedPosition() var fonts = new Dictionary { ["TestFont"] = BuildTestFont() }; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100, fonts); + using var stream1845 = ToStream(svg); + var surface = SvgCodec.Load(stream1845, 100, 100, fonts); // Assert: inside the glyph square Assert.Equal(255, surface[35, 35].A); @@ -1866,7 +1932,8 @@ public void SvgCodec_Load_TextAnchorMiddle_CentersTextHorizontally() var fonts = new Dictionary { ["TestFont"] = BuildTestFont() }; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100, fonts); + using var stream1868 = ToStream(svg); + var surface = SvgCodec.Load(stream1868, 100, 100, fonts); // Assert: filled within the centered square Assert.Equal(255, surface[25, 35].A); @@ -1887,7 +1954,8 @@ public void SvgCodec_Load_TextAnchorEnd_RightAlignsText() var fonts = new Dictionary { ["TestFont"] = BuildTestFont() }; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100, fonts); + using var stream1889 = ToStream(svg); + var surface = SvgCodec.Load(stream1889, 100, 100, fonts); // Assert: filled within the visible part of the right-aligned square Assert.Equal(255, surface[20, 35].A); @@ -1910,7 +1978,8 @@ public void SvgCodec_Load_TextWithKerningPair_AppliesKerningBetweenGlyphs() var fonts = new Dictionary { ["TestFont"] = BuildTestFont() }; // Act - var surface = SvgCodec.Load(ToStream(svg), 200, 100, fonts); + using var stream1912 = ToStream(svg); + var surface = SvgCodec.Load(stream1912, 200, 100, fonts); // Assert: filled only if the -10 kerning adjustment was applied before placing 'B' Assert.Equal(255, surface[105, 35].A); @@ -1930,7 +1999,8 @@ public void SvgCodec_Load_TextWithoutFontsDictionary_SkipsSilentlyWithoutThrowin const string svg = "A"; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream1932 = ToStream(svg); + var surface = SvgCodec.Load(stream1932, 100, 100); // Assert: no exception was thrown (implicit), and no ink was painted anywhere Assert.Equal(0, surface[35, 35].A); @@ -1948,7 +2018,8 @@ public void SvgCodec_Load_TextFontFamilyNoMatch_SkipsSilentlyWithoutThrowing() var fonts = new Dictionary { ["OtherFont"] = BuildTestFont() }; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100, fonts); + using var stream1950 = ToStream(svg); + var surface = SvgCodec.Load(stream1950, 100, 100, fonts); // Assert: no exception, and no ink was painted Assert.Equal(0, surface[35, 35].A); @@ -1966,7 +2037,8 @@ public void SvgCodec_Load_TextFontFamilyCommaSeparatedList_MatchesFirstAvailable var fonts = new Dictionary { ["TestFont"] = BuildTestFont() }; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100, fonts); + using var stream1968 = ToStream(svg); + var surface = SvgCodec.Load(stream1968, 100, 100, fonts); // Assert: the glyph rendered, proving the fallback list was walked to "TestFont" Assert.Equal(255, surface[35, 35].A); @@ -1988,7 +2060,8 @@ public void SvgCodec_Load_WideViewBoxIntoSquareRaster_LetterboxesTopAndBottom() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream1990 = ToStream(svg); + var surface = SvgCodec.Load(stream1990, 100, 100); // Assert: content band is filled Assert.Equal(255, surface[50, 50].A); @@ -2009,7 +2082,8 @@ public void SvgCodec_Load_TallViewBoxIntoSquareRaster_LetterboxesLeftAndRight() const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream2011 = ToStream(svg); + var surface = SvgCodec.Load(stream2011, 100, 100); // Assert: content band is filled Assert.Equal(255, surface[50, 50].A); @@ -2034,7 +2108,8 @@ public void SvgCodec_GetInfo_ViewBoxPresent_ReturnsViewBoxDimensions() const string svg = ""; // Act - var info = SvgCodec.GetInfo(ToStream(svg)); + using var stream2036 = ToStream(svg); + var info = SvgCodec.GetInfo(stream2036); // Assert Assert.Equal(40, info.Width); @@ -2054,7 +2129,8 @@ public void SvgCodec_GetInfo_NoViewBoxWidthHeightPresent_ReturnsWidthHeight() const string svg = ""; // Act - var info = SvgCodec.GetInfo(ToStream(svg)); + using var stream2056 = ToStream(svg); + var info = SvgCodec.GetInfo(stream2056); // Assert Assert.Equal(64, info.Width); @@ -2073,7 +2149,8 @@ public void SvgCodec_GetInfo_NoViewBoxNoWidthHeight_ReturnsCssDefault300x150() const string svg = ""; // Act - var info = SvgCodec.GetInfo(ToStream(svg)); + using var stream2075 = ToStream(svg); + var info = SvgCodec.GetInfo(stream2075); // Assert Assert.Equal(300, info.Width); @@ -2135,7 +2212,8 @@ public void SvgCodec_GetInfo_ViewBoxWidthExceedsInt32Range_ClampsToInt32MaxValue const string svg = ""; // Act & Assert - Assert.Throws(() => SvgCodec.GetInfo(ToStream(svg))); + using var stream2137 = ToStream(svg); + Assert.Throws(() => SvgCodec.GetInfo(stream2137)); } /// @@ -2150,7 +2228,8 @@ public void SvgCodec_GetInfo_WidthExceedsInt32Range_ClampsToInt32MaxValueWithout const string svg = ""; // Act - var info = SvgCodec.GetInfo(ToStream(svg)); + using var stream2152 = ToStream(svg); + var info = SvgCodec.GetInfo(stream2152); // Assert Assert.Equal(int.MaxValue, info.Width); @@ -2173,7 +2252,8 @@ public void SvgCodec_GetInfo_MalformedXmlAfterRootElement_DoesNotThrowAndReturns const string svg = ""; // Act & Assert - Assert.Throws(() => SvgCodec.GetInfo(ToStream(svg))); + using var stream2203 = ToStream(svg); + Assert.Throws(() => SvgCodec.GetInfo(stream2203)); } // ================================================================================================ @@ -2225,7 +2306,8 @@ public void SvgCodec_Load_DocumentWithDoctypeDeclaration_ThrowsInvalidDataExcept const string svg = "]>"; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream2227 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2227, 100, 100)); } /// @@ -2246,7 +2328,8 @@ public void SvgCodec_Load_DocumentWithExternalEntityDoctype_ThrowsInvalidDataExc "&xxe;"; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream2248 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2248, 100, 100)); } /// @@ -2265,7 +2348,8 @@ public void SvgCodec_GetInfo_DocumentWithExternalEntityDoctype_ThrowsInvalidData "&xxe;"; // Act & Assert - Assert.Throws(() => SvgCodec.GetInfo(ToStream(svg))); + using var stream2267 = ToStream(svg); + Assert.Throws(() => SvgCodec.GetInfo(stream2267)); } // ================================================================================================ @@ -2283,7 +2367,8 @@ public void SvgCodec_Load_MalformedXml_ThrowsInvalidDataException() const string svg = "(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream2285 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2285, 100, 100)); } /// @@ -2297,7 +2382,8 @@ public void SvgCodec_Load_MalformedViewBoxWrongNumberCount_ThrowsInvalidDataExce const string svg = ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream2299 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2299, 100, 100)); } /// @@ -2311,7 +2397,8 @@ public void SvgCodec_Load_ViewBoxNonPositiveWidth_ThrowsInvalidDataException() const string svg = ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream2313 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2313, 100, 100)); } /// @@ -2334,7 +2421,8 @@ public void SvgCodec_Load_ViewBoxWidthExtremelySmallCausesNonFiniteFitScale_Thro const string svg = ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream2336 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2336, 100, 100)); } /// @@ -2348,7 +2436,8 @@ public void SvgCodec_Load_MalformedPathDataUnknownCommand_ThrowsInvalidDataExcep const string svg = ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream2350 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2350, 100, 100)); } /// @@ -2362,7 +2451,8 @@ public void SvgCodec_Load_MalformedPathDataMissingArguments_ThrowsInvalidDataExc const string svg = ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream2364 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2364, 100, 100)); } /// @@ -2393,7 +2483,8 @@ public void SvgCodec_Load_PathRelativeAccumulationOverflowsToInfinity_Terminates ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 10, 10)); + using var stream2395 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2395, 10, 10)); } /// @@ -2422,7 +2513,8 @@ public void SvgCodec_Load_HugeFinitePathWithFineDashPattern_TerminatesPromptlyWi ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 10, 10)); + using var stream2424 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2424, 10, 10)); } /// @@ -2454,7 +2546,8 @@ public void SvgCodec_Load_PathSmoothCubicReflectionOverflowsToInfinity_SkipsPath ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 10, 10)); + using var stream2456 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2456, 10, 10)); } /// @@ -2489,7 +2582,8 @@ public void SvgCodec_Load_PathArcCommandRadiusOverflowsToNonFinite_SkipsPathWith ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 10, 10)); + using var stream2491 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2491, 10, 10)); } /// @@ -2517,7 +2611,8 @@ public void SvgCodec_Load_RectRoundedCornerArcConversionOverflowsToNonFinite_Ski ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 10, 10)); + using var stream2519 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2519, 10, 10)); } /// @@ -2559,7 +2654,8 @@ public void SvgCodec_Load_TransformScaleAmplifiesCoordinatePastMagnitudeBound_Sk ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream2561 = ToStream(svg); + var surface = SvgCodec.Load(stream2561, 100, 100); // Assert: Load did not throw, and the shape was tolerantly skipped entirely - the sampled // pixel, which the scaled square's huge bounding box would otherwise cover if the shape @@ -2578,7 +2674,8 @@ public void SvgCodec_Load_MalformedTransformUnrecognizedFunction_ThrowsInvalidDa const string svg = ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream2580 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2580, 100, 100)); } /// @@ -2599,7 +2696,8 @@ public void SvgCodec_Load_DocumentExceedingCharacterBudget_ThrowsInvalidDataExce var svg = $""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream2601 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2601, 100, 100)); } /// @@ -2616,7 +2714,8 @@ public void SvgCodec_Load_DocumentWithinCharacterBudget_LoadsSuccessfully() var svg = $""; // Act - var surface = SvgCodec.Load(ToStream(svg), 10, 10); + using var stream2618 = ToStream(svg); + var surface = SvgCodec.Load(stream2618, 10, 10); // Assert: the rect still rendered Assert.Equal(255, surface[5, 5].A); @@ -2637,7 +2736,8 @@ public void SvgCodec_Load_WidthAttributeNaN_ThrowsInvalidDataException() const string svg = ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream2639 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2639, 100, 100)); } /// @@ -2653,7 +2753,8 @@ public void SvgCodec_Load_StrokeWidthInfinity_ThrowsInvalidDataException() const string svg = ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream2655 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2655, 100, 100)); } /// @@ -2674,7 +2775,8 @@ public void SvgCodec_Load_PathDataNumberOverflowToInfinity_ThrowsInvalidDataExce const string svg = ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream2676 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2676, 100, 100)); } /// @@ -2691,7 +2793,8 @@ public void SvgCodec_Load_PointsListNumberOverflowToInfinity_ThrowsInvalidDataEx const string svg = ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream2693 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2693, 100, 100)); } /// @@ -2708,7 +2811,8 @@ public void SvgCodec_Load_PathDataCoordinateExceedingMaxMagnitude_ThrowsInvalidD const string svg = ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream2710 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2710, 100, 100)); } /// @@ -2723,7 +2827,8 @@ public void SvgCodec_Load_PointsListCoordinateExceedingMaxMagnitude_ThrowsInvali const string svg = ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream2725 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2725, 100, 100)); } /// @@ -2746,7 +2851,8 @@ public void SvgCodec_Load_CoordinateWithinMaxMagnitude_RendersSuccessfully() ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 10, 10); + using var stream2748 = ToStream(svg); + var surface = SvgCodec.Load(stream2748, 10, 10); // Assert: no exception, and the rect (which covers the whole viewBox) rendered Assert.Equal(255, surface[5, 5].A); @@ -2765,7 +2871,8 @@ public void SvgCodec_Load_NegativeScientificAndPercentageValues_RendersWithoutTh const string svg = ""; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream2767 = ToStream(svg); + var surface = SvgCodec.Load(stream2767, 100, 100); // Assert: the rect (x=-10, width=100 => spans to x=90) covers (50,25) at ~50% opacity // (0.5 * 255 = 127.5 ~ 127/128), not 0 (rejected) and not 255 (opacity ignored) @@ -2786,7 +2893,8 @@ public void SvgCodec_Load_RectXPercentage_ThrowsInvalidDataException() const string svg = ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream2788 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2788, 100, 100)); } /// @@ -2802,7 +2910,8 @@ public void SvgCodec_Load_RectWidthPercentage_ThrowsInvalidDataException() const string svg = ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 100, 100)); + using var stream2804 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream2804, 100, 100)); } /// @@ -2834,7 +2943,8 @@ public void SvgCodec_Load_GradientStopOffsetNaN_DoesNotThrowAndRenders() """; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream2836 = ToStream(svg); + var surface = SvgCodec.Load(stream2836, 100, 100); // Assert: rendering completed without the raw ArgumentOutOfRangeException a non-finite // offset reaching GradientStop's constructor would otherwise throw @@ -2865,7 +2975,8 @@ public void SvgCodec_Load_GradientX1Infinity_FallsBackToDefaultAndRenders() """; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream2867 = ToStream(svg); + var surface = SvgCodec.Load(stream2867, 100, 100); // Assert: brightness still increases left to right, proving x1 fell back to 0 rather // than Infinity (which would break or degenerate the ramp) @@ -2902,7 +3013,8 @@ public void SvgCodec_Load_RgbaAlphaInfinity_TreatsColorAsUnrecognizedNoPaint() """; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream2904 = ToStream(svg); + var surface = SvgCodec.Load(stream2904, 100, 100); // Assert: the background white shows through - the Infinity-alpha rgba() color was // rejected as unrecognized, not drawn as (saturated-opaque) black @@ -2927,7 +3039,8 @@ public void SvgCodec_GetInfo_WidthHeightInfinity_FallsBackToDefaultSize() const string svg = ""; // Act - var info = SvgCodec.GetInfo(ToStream(svg)); + using var stream2929 = ToStream(svg); + var info = SvgCodec.GetInfo(stream2929); // Assert: falls back to the CSS/UA default replaced-element intrinsic size Assert.Equal(300, info.Width); @@ -2945,7 +3058,8 @@ public void SvgCodec_GetInfo_WidthHeightScientificNotation_ResolvesToBareValue() const string svg = ""; // Act - var info = SvgCodec.GetInfo(ToStream(svg)); + using var stream2947 = ToStream(svg); + var info = SvgCodec.GetInfo(stream2947); // Assert Assert.Equal(100, info.Width); @@ -2973,7 +3087,8 @@ public void SvgCodec_Load_GradientStopOffsetPercentage_RendersGradientCorrectly( """; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream2975 = ToStream(svg); + var surface = SvgCodec.Load(stream2975, 100, 100); // Assert: brightness increases left to right, as with the equivalent bare-number test Assert.True(surface[10, 50].R < surface[90, 50].R); @@ -3005,7 +3120,8 @@ public void SvgCodec_Load_UnsupportedConstructs_StillRendersRestOfDocument() """; // Act - var surface = SvgCodec.Load(ToStream(svg), 100, 100); + using var stream3007 = ToStream(svg); + var surface = SvgCodec.Load(stream3007, 100, 100); // Assert: the plain rect after the unsupported constructs still rendered Assert.Equal(255, surface[20, 20].A); @@ -3084,7 +3200,8 @@ public void SvgCodec_Load_NonPositiveWidth_PropagatesSurfaceArgumentOutOfRangeEx const string svg = ""; // Act & Assert - Assert.Throws(() => SvgCodec.Load(ToStream(svg), 0, 10)); + using var stream3086 = ToStream(svg); + Assert.Throws(() => SvgCodec.Load(stream3086, 0, 10)); } /// diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgFixtureTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgFixtureTests.cs index dc0b5010..38c70730 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgFixtureTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/SvgFixtureTests.cs @@ -18,15 +18,31 @@ public class SvgFixtureTests { /// /// The directory containing the SVG fixture corpus, copied to the test output directory - /// by the test project's SvgFixtures\** content item. + /// by the test project's SvgFixtures\** content item. Path.Join is used + /// instead of purely to avoid CodeQL's + /// cs/path-combine rule, since Path.Join does not discard + /// when the second segment looks rooted. /// - private static string AssetsPath => Path.Combine(AppContext.BaseDirectory, "SvgFixtures"); + private static string AssetsPath => Path.Join(AppContext.BaseDirectory, "SvgFixtures"); /// /// The path to the real "Open Sans" TrueType font, copied to the test output directory by - /// the test project's FontFixtures\** content item. + /// the test project's FontFixtures\** content item. Path.Join is used + /// instead of for the same CodeQL + /// reason as . /// - private static string FontPath => Path.Combine(AppContext.BaseDirectory, "FontFixtures", "OpenSans-Regular.ttf"); + private static string FontPath => Path.Join(AppContext.BaseDirectory, "FontFixtures", "OpenSans-Regular.ttf"); + + /// + /// Resolves a fixture file within . The file names always + /// originate from this class's own 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. + /// + private static string ResolveFixturePath(string fileName) => + Path.Join(AssetsPath, fileName); /// /// Proves that shapes.svg's four basic shapes (rect/circle/ellipse/polygon) each @@ -37,7 +53,7 @@ public class SvgFixtureTests public void SvgCodec_Load_ShapesFixture_RendersExpectedShapeColors() { // Arrange & Act: load the fixture at its native 1:1 viewBox-to-raster size - var surface = SvgCodec.Load(Path.Combine(AssetsPath, "shapes.svg"), 100, 100); + var surface = SvgCodec.Load(ResolveFixturePath("shapes.svg"), 100, 100); // Assert: each shape's expected solid color appears at a representative interior pixel Assert.Equal(new Rgba32(255, 0, 0, 255), surface[25, 25]); // rect (red) @@ -60,7 +76,7 @@ public void SvgCodec_Load_ShapesFixture_RendersExpectedShapeColors() public void SvgCodec_Load_GroupsAndTransformsFixture_RendersAtTransformedPositions() { // Arrange & Act - var surface = SvgCodec.Load(Path.Combine(AssetsPath, "groups-and-transforms.svg"), 100, 100); + var surface = SvgCodec.Load(ResolveFixturePath("groups-and-transforms.svg"), 100, 100); // Assert: the first rect inherits its group's "orange" fill and renders at its // translate(50,0)-mapped position, not its pre-translation local (0,0)-(20,20) position @@ -83,7 +99,7 @@ public void SvgCodec_Load_GroupsAndTransformsFixture_RendersAtTransformedPositio public void SvgCodec_Load_GradientFixture_RendersVaryingGradientColors() { // Arrange & Act - var surface = SvgCodec.Load(Path.Combine(AssetsPath, "gradient.svg"), 100, 100); + var surface = SvgCodec.Load(ResolveFixturePath("gradient.svg"), 100, 100); // Assert: the red channel increases monotonically from left to right, and is fully // opaque throughout - proving a real gradient (not a flat fallback color) was rendered @@ -106,7 +122,7 @@ public void SvgCodec_Load_GradientFixture_RendersVaryingGradientColors() public void SvgCodec_Load_UseReferenceFixture_RendersAtOffsetPositionOnly() { // Arrange & Act - var surface = SvgCodec.Load(Path.Combine(AssetsPath, "use-reference.svg"), 100, 100); + var surface = SvgCodec.Load(ResolveFixturePath("use-reference.svg"), 100, 100); // Assert: the referenced 20x20 "teal" box renders at its use-offset (30,40) position Assert.Equal(new Rgba32(0, 128, 128, 255), surface[40, 50]); @@ -125,7 +141,7 @@ public void SvgCodec_Load_UseReferenceFixture_RendersAtOffsetPositionOnly() public void SvgCodec_Load_ToleratesUnsupportedConstructFixture_StillRendersRemainingContent() { // Arrange & Act - var surface = SvgCodec.Load(Path.Combine(AssetsPath, "tolerant-unsupported.svg"), 100, 100); + var surface = SvgCodec.Load(ResolveFixturePath("tolerant-unsupported.svg"), 100, 100); // Assert: the rect still renders its "lime" fill despite the sibling element Assert.Equal(new Rgba32(0, 255, 0, 255), surface[25, 25]); @@ -148,7 +164,7 @@ public void SvgCodec_Load_TextFixtureWithRealFont_RendersVisibleGlyphInk() var fonts = new Dictionary { ["Open Sans"] = font }; // Act: rasterize the fixture with the font dictionary supplied - var surface = SvgCodec.Load(Path.Combine(AssetsPath, "text.svg"), 200, 80, fonts); + var surface = SvgCodec.Load(ResolveFixturePath("text.svg"), 200, 80, fonts); // Assert: at least one pixel within the text's expected region (below and around the // baseline at x=10, y=55, font-size 40) was actually painted (non-transparent) @@ -191,7 +207,7 @@ public void SvgCodec_Load_TextFixtureWithRealFont_RendersVisibleGlyphInk() public void SvgCodec_Load_SvgGradientFixture_RendersVaryingGradientAndPinkBackground() { // Arrange & Act: rasterize at the fixture's native 300x200 viewBox size - var surface = SvgCodec.Load(Path.Combine(AssetsPath, "SvgGradient.svg"), 300, 200); + var surface = SvgCodec.Load(ResolveFixturePath("SvgGradient.svg"), 300, 200); // Assert: the pink background rect is visible at a point clearly outside the "bar" use // shape (y in [80,100]) and every gradient rect (rows at y in [30,70] and [110,190]) @@ -231,7 +247,7 @@ public void SvgCodec_Load_InkscapeFiltersFixture_ToleratesFiltersAndRendersFlowe // Arrange & Act: rasterize at the fixture's native 600x1000 width/height - this must not // throw despite every flower (other than the very first) referencing an unsupported // element - var surface = SvgCodec.Load(Path.Combine(AssetsPath, "InkscapeFilters.svg"), 600, 1000); + var surface = SvgCodec.Load(ResolveFixturePath("InkscapeFilters.svg"), 600, 1000); // Assert: a petal of the second flower - translate(150,50), filter="url(#filter48)" - // still renders its own "#ff8010" (255,128,16) fill, proving the unsupported diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffCodecTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffCodecTests.cs index 71ae500b..ed9098fc 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffCodecTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffCodecTests.cs @@ -921,7 +921,8 @@ public void TiffCodec_Load_LittleEndianRgbSingleStrip_ReturnsExpectedPixels() .WithStrips(raw) .Build(); - var surface = TiffCodec.Load(new MemoryStream(file)); + using var ms923 = new MemoryStream(file); + var surface = TiffCodec.Load(ms923); Assert.Equal(width, surface.Width); Assert.Equal(height, surface.Height); @@ -947,7 +948,8 @@ public void TiffCodec_Load_BigEndianRgbSingleStrip_ReturnsExpectedPixels() .WithStrips(raw) .Build(); - var surface = TiffCodec.Load(new MemoryStream(file)); + using var ms949 = new MemoryStream(file); + var surface = TiffCodec.Load(ms949); Assert.Equal(new Rgba32(1, 2, 3, 255), surface[0, 0]); Assert.Equal(new Rgba32(4, 5, 6, 255), surface[1, 0]); @@ -976,7 +978,8 @@ public void TiffCodec_Load_RgbaWithExtraSamples_PreservesAlpha() .WithStrips(raw) .Build(); - var surface = TiffCodec.Load(new MemoryStream(file)); + using var ms978 = new MemoryStream(file); + var surface = TiffCodec.Load(ms978); Assert.Equal(new Rgba32(1, 2, 3, 128), surface[0, 0]); Assert.Equal(new Rgba32(4, 5, 6, 64), surface[1, 0]); @@ -1004,7 +1007,8 @@ public void TiffCodec_Load_Grayscale_ReturnsGrayPixelsWithFullAlpha() .WithStrips(raw) .Build(); - var surface = TiffCodec.Load(new MemoryStream(file)); + using var ms1006 = new MemoryStream(file); + var surface = TiffCodec.Load(ms1006); Assert.Equal(new Rgba32(100, 100, 100, 255), surface[0, 0]); Assert.Equal(new Rgba32(200, 200, 200, 255), surface[1, 0]); @@ -1027,7 +1031,8 @@ public void TiffCodec_Load_MultiStrip_ReturnsExpectedPixels() .WithStrips(strip0, strip1, strip2, strip3) .Build(); - var surface = TiffCodec.Load(new MemoryStream(file)); + using var ms1029 = new MemoryStream(file); + var surface = TiffCodec.Load(ms1029); Assert.Equal(new Rgba32(1, 1, 1, 255), surface[0, 0]); Assert.Equal(new Rgba32(2, 2, 2, 255), surface[1, 0]); @@ -1054,7 +1059,8 @@ public void TiffCodec_Load_MultiStripWithTwoRowsPerStrip_ReturnsExpectedPixels() .WithStrips(strip0, strip1) .Build(); - var surface = TiffCodec.Load(new MemoryStream(file)); + using var ms1056 = new MemoryStream(file); + var surface = TiffCodec.Load(ms1056); Assert.Equal(new Rgba32(10, 10, 10, 255), surface[0, 0]); Assert.Equal(new Rgba32(20, 20, 20, 255), surface[0, 1]); @@ -1076,7 +1082,8 @@ public void TiffCodec_Load_PackBitsCompressedStrip_DecodesUsingIndependentEncode .WithStrips(compressed) .Build(); - var surface = TiffCodec.Load(new MemoryStream(file)); + using var ms1078 = new MemoryStream(file); + var surface = TiffCodec.Load(ms1078); Assert.Equal(new Rgba32(1, 2, 3, 255), surface[0, 0]); Assert.Equal(new Rgba32(4, 5, 6, 255), surface[1, 0]); @@ -1099,7 +1106,8 @@ public void TiffCodec_Load_LzwCompressedStrip_DecodesUsingIndependentEncoder() .WithStrips(compressed) .Build(); - var surface = TiffCodec.Load(new MemoryStream(file)); + using var ms1101 = new MemoryStream(file); + var surface = TiffCodec.Load(ms1101); Assert.Equal(new Rgba32(1, 2, 3, 255), surface[0, 0]); Assert.Equal(new Rgba32(4, 5, 6, 255), surface[1, 0]); @@ -1127,7 +1135,8 @@ public void TiffCodec_Load_LzwCompressedRepeatingData_DecodesUsingIndependentEnc .WithStrips(compressed) .Build(); - var surface = TiffCodec.Load(new MemoryStream(file)); + using var ms1129 = new MemoryStream(file); + var surface = TiffCodec.Load(ms1129); for (var x = 0; x < width; x++) { @@ -1151,7 +1160,8 @@ public void TiffCodec_Load_DeflateCompressedStrip_DecodesUsingIndependentEncoder .WithStrips(compressed) .Build(); - var surface = TiffCodec.Load(new MemoryStream(file)); + using var ms1153 = new MemoryStream(file); + var surface = TiffCodec.Load(ms1153); Assert.Equal(new Rgba32(1, 2, 3, 255), surface[0, 0]); Assert.Equal(new Rgba32(4, 5, 6, 255), surface[1, 0]); @@ -1183,7 +1193,8 @@ public void TiffCodec_Load_PredictorWithLzw_DecodesUsingIndependentEncoder() .WithStrips(compressed) .Build(); - var surface = TiffCodec.Load(new MemoryStream(file)); + using var ms1185 = new MemoryStream(file); + var surface = TiffCodec.Load(ms1185); for (var x = 0; x < width; x++) { @@ -1209,7 +1220,8 @@ public void TiffCodec_Load_PredictorWithDeflate_DecodesUsingIndependentEncoder() .WithStrips(compressed) .Build(); - var surface = TiffCodec.Load(new MemoryStream(file)); + using var ms1211 = new MemoryStream(file); + var surface = TiffCodec.Load(ms1211); for (var x = 0; x < width; x++) { @@ -1231,7 +1243,8 @@ public void TiffCodec_Load_BadByteOrderMark_ThrowsInvalidDataException() file[0] = (byte)'X'; file[1] = (byte)'X'; - Assert.Throws(() => TiffCodec.Load(new MemoryStream(file))); + using var ms1233 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.Load(ms1233)); } /// @@ -1241,7 +1254,8 @@ public void TiffCodec_Load_BadByteOrderMark_ThrowsInvalidDataException() public void TiffCodec_Load_TruncatedHeader_ThrowsInvalidDataException() { var file = new byte[] { (byte)'I', (byte)'I', 42 }; - Assert.Throws(() => TiffCodec.Load(new MemoryStream(file))); + using var ms1243 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.Load(ms1243)); } /// @@ -1252,7 +1266,8 @@ public void TiffCodec_Load_TruncatedStripData_ThrowsInvalidDataException() { var file = StandardRgbBuilder(false, 4, 4, 1, 4).WithStrips(new byte[] { 1, 2, 3 }).Build(); - Assert.Throws(() => TiffCodec.Load(new MemoryStream(file))); + using var ms1254 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.Load(ms1254)); } /// @@ -1273,7 +1288,8 @@ public void TiffCodec_Load_UnsupportedBitsPerSample_ThrowsInvalidDataException() .WithStrips(new byte[] { 1, 2, 3, 4, 5, 6 }) .Build(); - Assert.Throws(() => TiffCodec.Load(new MemoryStream(file))); + using var ms1275 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.Load(ms1275)); } /// @@ -1294,7 +1310,8 @@ public void TiffCodec_Load_UnsupportedPhotometricPalette_ThrowsInvalidDataExcept .WithStrips(new byte[] { 1 }) .Build(); - Assert.Throws(() => TiffCodec.Load(new MemoryStream(file))); + using var ms1296 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.Load(ms1296)); } /// @@ -1305,7 +1322,8 @@ public void TiffCodec_Load_UnsupportedCompression_ThrowsInvalidDataException() { var file = StandardRgbBuilder(false, 1, 1, 6, 1).WithStrips(new byte[] { 1, 2, 3 }).Build(); - Assert.Throws(() => TiffCodec.Load(new MemoryStream(file))); + using var ms1307 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.Load(ms1307)); } /// @@ -1322,7 +1340,8 @@ public void TiffCodec_Load_WidthExceedsMaxDimension_ThrowsInvalidDataException() .WithStrips(new byte[width * 3]) .Build(); - Assert.Throws(() => TiffCodec.Load(new MemoryStream(file))); + using var ms1324 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.Load(ms1324)); } /// @@ -1338,7 +1357,8 @@ public void TiffCodec_Load_HeightExceedsMaxDimension_ThrowsInvalidDataException( .WithStrips(new byte[height * 3]) .Build(); - Assert.Throws(() => TiffCodec.Load(new MemoryStream(file))); + using var ms1340 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.Load(ms1340)); } /// @@ -1359,7 +1379,8 @@ public void TiffCodec_Load_PlanarConfigurationPlanar_ThrowsInvalidDataException( .WithStrips(new byte[] { 1, 2, 3 }) .Build(); - Assert.Throws(() => TiffCodec.Load(new MemoryStream(file))); + using var ms1361 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.Load(ms1361)); } /// @@ -1380,7 +1401,8 @@ public void TiffCodec_Load_TiledTiff_ThrowsInvalidDataException() .Add(TagTileLength, TypeLong, 16) .Build(); - Assert.Throws(() => TiffCodec.Load(new MemoryStream(file))); + using var ms1382 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.Load(ms1382)); } /// @@ -1406,7 +1428,8 @@ public void TiffCodec_GetInfo_Seekable_TiledTiff_ThrowsInvalidDataException() .Add(TagTileLength, TypeLong, 16) .Build(); - Assert.Throws(() => TiffCodec.GetInfo(new MemoryStream(file))); + using var ms1408 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.GetInfo(ms1408)); } /// @@ -1504,7 +1527,8 @@ public void TiffCodec_Load_MissingMandatoryTag_ThrowsInvalidDataException(ushort var file = builder.Build(); - Assert.Throws(() => TiffCodec.Load(new MemoryStream(file))); + using var ms1506 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.Load(ms1506)); } /// @@ -1518,7 +1542,8 @@ public void TiffCodec_GetInfo_Seekable_Rgb_ReturnsExpectedInfoWithoutAlpha() .WithStrips(new byte[3 * 3 * 2]) .Build(); - var info = TiffCodec.GetInfo(new MemoryStream(file)); + using var ms1520 = new MemoryStream(file); + var info = TiffCodec.GetInfo(ms1520); Assert.Equal(new ImageInfo(3, 2, 3, false), info); } @@ -1535,7 +1560,8 @@ public void TiffCodec_GetInfo_Seekable_Rgba_ReturnsExpectedInfoWithAlpha() .WithStrips(new byte[3 * 4 * 2]) .Build(); - var info = TiffCodec.GetInfo(new MemoryStream(file)); + using var ms1537 = new MemoryStream(file); + var info = TiffCodec.GetInfo(ms1537); Assert.Equal(new ImageInfo(3, 2, 4, true), info); } @@ -1581,7 +1607,8 @@ public void TiffCodec_GetInfo_NonSeekableStream_SucceedsAndMatchesLoadResult() .WithStrips(new byte[width * height * 3]) .Build(); using var nonSeekableStream = new FunctionallySeekableButCanSeekFalseStream(new MemoryStream(file)); - var loadedSurface = TiffCodec.Load(new MemoryStream(file)); + using var ms1583 = new MemoryStream(file); + var loadedSurface = TiffCodec.Load(ms1583); // Act var info = TiffCodec.GetInfo(nonSeekableStream); @@ -1676,7 +1703,8 @@ public void TiffCodec_GetInfo_BadByteOrderMark_ThrowsInvalidDataException() bytes[0] = (byte)'X'; bytes[1] = (byte)'X'; - Assert.Throws(() => TiffCodec.GetInfo(new MemoryStream(bytes))); + using var ms1678 = new MemoryStream(bytes); + Assert.Throws(() => TiffCodec.GetInfo(ms1678)); } /// @@ -1692,10 +1720,12 @@ public void TiffCodec_GetInfo_OversizedDimensions_ReturnsRawValue_ButLoadThrows( .WithStrips(new byte[width * 3]) .Build(); - var info = TiffCodec.GetInfo(new MemoryStream(file)); + using var ms1694 = new MemoryStream(file); + var info = TiffCodec.GetInfo(ms1694); Assert.Equal(width, info.Width); - Assert.Throws(() => TiffCodec.Load(new MemoryStream(file))); + using var ms1697 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.Load(ms1697)); } /// @@ -1715,10 +1745,12 @@ public void TiffCodec_GetInfo_SucceedsWithTruncatedStripData_ButLoadThrows() // Truncate away the trailing strip data entirely, leaving the header and IFD intact var truncated = file[..(file.Length - (width * height * 3))]; - var info = TiffCodec.GetInfo(new MemoryStream(truncated)); + using var ms1717 = new MemoryStream(truncated); + var info = TiffCodec.GetInfo(ms1717); Assert.Equal(new ImageInfo(width, height, 3, false), info); - Assert.Throws(() => TiffCodec.Load(new MemoryStream(truncated))); + using var ms1720 = new MemoryStream(truncated); + Assert.Throws(() => TiffCodec.Load(ms1720)); } /// @@ -1739,7 +1771,8 @@ public void TiffCodec_GetInfo_Seekable_SamplesPerPixelTagOmitted_DefaultsToBitsP .Build(); // Act - var seekableInfo = TiffCodec.GetInfo(new MemoryStream(file)); + using var ms1741 = new MemoryStream(file); + var seekableInfo = TiffCodec.GetInfo(ms1741); // Assert: Channels defaults to BitsPerSample's entry count (3) var expected = new ImageInfo(width, height, 3, false); @@ -1772,7 +1805,8 @@ public void TiffCodec_GetInfo_Seekable_UnsupportedBitsPerSample_ThrowsInvalidDat var file = builder.WithStrips(new byte[width * height * 3 * 2]).Build(); // Act / Assert: the seekable stream rejects the file - Assert.Throws(() => TiffCodec.GetInfo(new MemoryStream(file))); + using var ms1774 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.GetInfo(ms1774)); } /// @@ -1817,7 +1851,8 @@ public void TiffCodec_GetInfo_Seekable_MaliciousOutOfLineTagLength_ThrowsWithout // from the fixed code. The allocated-byte count does: the bug allocates the full // ~1.8 GB claimed by the malicious Count before discovering the stream is too short. var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); - var ex = Assert.Throws(() => TiffCodec.GetInfo(new MemoryStream(file))); + using var ms1819 = new MemoryStream(file); + var ex = Assert.Throws(() => TiffCodec.GetInfo(ms1819)); var allocatedDuring = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore; Assert.Contains("implausibly large declared value count", ex.Message); @@ -1847,7 +1882,8 @@ public void TiffCodec_Load_ImageWidthZeroCount_ThrowsInvalidDataExceptionNotInde { var file = BuildFileWithZeroCountImageWidth(); - Assert.Throws(() => TiffCodec.Load(new MemoryStream(file))); + using var ms1849 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.Load(ms1849)); } /// @@ -1859,7 +1895,8 @@ public void TiffCodec_GetInfo_Seekable_ImageWidthZeroCount_ThrowsInvalidDataExce { var file = BuildFileWithZeroCountImageWidth(); - Assert.Throws(() => TiffCodec.GetInfo(new MemoryStream(file))); + using var ms1861 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.GetInfo(ms1861)); } /// @@ -1893,7 +1930,8 @@ public void TiffCodec_Load_IfdOffsetAboveInt32Range_ThrowsInvalidDataExceptionNo { var file = BuildHeaderOnlyFileWithIfdOffset(3_000_000_000); - Assert.Throws(() => TiffCodec.Load(new MemoryStream(file))); + using var ms1895 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.Load(ms1895)); } /// @@ -1905,7 +1943,8 @@ public void TiffCodec_GetInfo_Seekable_IfdOffsetAboveInt32Range_ThrowsInvalidDat { var file = BuildHeaderOnlyFileWithIfdOffset(3_000_000_000); - Assert.Throws(() => TiffCodec.GetInfo(new MemoryStream(file))); + using var ms1907 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.GetInfo(ms1907)); } /// @@ -1938,7 +1977,8 @@ public void TiffCodec_Load_OutOfLineTagOffsetAboveInt32Range_ThrowsInvalidDataEx { var file = BuildFileWithBitsPerSampleOverride(count: 3, valueOrOffset: 3_000_000_000); - Assert.Throws(() => TiffCodec.Load(new MemoryStream(file))); + using var ms1940 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.Load(ms1940)); } /// @@ -1950,7 +1990,8 @@ public void TiffCodec_GetInfo_Seekable_OutOfLineTagOffsetAboveInt32Range_ThrowsI { var file = BuildFileWithBitsPerSampleOverride(count: 3, valueOrOffset: 3_000_000_000); - Assert.Throws(() => TiffCodec.GetInfo(new MemoryStream(file))); + using var ms1952 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.GetInfo(ms1952)); } /// @@ -2012,7 +2053,8 @@ public void TiffCodec_GetInfo_Seekable_BitsPerSampleCountImplausiblyLarge_Throws Assert.True(ifdEndOffset + (bitsPerSampleCount * 2) <= file.Length); var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); - var ex = Assert.Throws(() => TiffCodec.GetInfo(new MemoryStream(file))); + using var ms2014 = new MemoryStream(file); + var ex = Assert.Throws(() => TiffCodec.GetInfo(ms2014)); var allocatedDuring = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore; Assert.Contains("implausibly large declared value count", ex.Message); @@ -2050,7 +2092,8 @@ public void TiffCodec_Load_StripOffsetAboveInt32Range_ThrowsInvalidDataException // itself; overwriting it in place does not disturb the file's layout. PatchInlineTagEntryValue(file, entryIndex: 5, value: 3_000_000_000); - Assert.Throws(() => TiffCodec.Load(new MemoryStream(file))); + using var ms2052 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.Load(ms2052)); } /// @@ -2069,7 +2112,8 @@ public void TiffCodec_Load_StripByteCountAboveInt32Range_ThrowsInvalidDataExcept PatchInlineTagEntryValue(file, entryIndex: 8, value: 3_000_000_000); - Assert.Throws(() => TiffCodec.Load(new MemoryStream(file))); + using var ms2071 = new MemoryStream(file); + Assert.Throws(() => TiffCodec.Load(ms2071)); } /// diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffFixtureTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffFixtureTests.cs index f1d14af1..08683451 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffFixtureTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/TiffFixtureTests.cs @@ -21,15 +21,20 @@ public class TiffFixtureTests { /// /// The directory containing the TIFF fixture corpus, copied to the test output directory - /// by the test project's TiffFixtures\** content item. + /// by the test project's TiffFixtures\** content item. Path.Join is used + /// instead of purely to avoid CodeQL's + /// cs/path-combine rule, since Path.Join does not discard + /// when the second segment looks rooted. /// - private static string AssetsPath => Path.Combine(AppContext.BaseDirectory, "TiffFixtures"); + private static string AssetsPath => Path.Join(AppContext.BaseDirectory, "TiffFixtures"); /// /// The directory containing the PngSuite corpus, copied to the test output directory by - /// the test project's PngSuite\** content item. + /// the test project's PngSuite\** content item. Path.Join is used instead + /// of for the same CodeQL reason as + /// . /// - private static string PngSuitePath => Path.Combine(AppContext.BaseDirectory, "PngSuite"); + private static string PngSuitePath => Path.Join(AppContext.BaseDirectory, "PngSuite"); /// /// Resolves a fixture file within . The file names always @@ -100,8 +105,8 @@ private static string ResolveFixturePath(string baseDirectory, string fileName) [Fact] public void TiffFixtures_SourcePngDimensions_Are32x32() { - var rgbSource = PngCodec.Load(Path.Combine(PngSuitePath, "basn2c08.png")); - var rgbaSource = PngCodec.Load(Path.Combine(PngSuitePath, "basn6a08.png")); + var rgbSource = PngCodec.Load(ResolveFixturePath(PngSuitePath, "basn2c08.png")); + var rgbaSource = PngCodec.Load(ResolveFixturePath(PngSuitePath, "basn6a08.png")); Assert.Equal(32, rgbSource.Width); Assert.Equal(32, rgbSource.Height); @@ -131,7 +136,7 @@ public void TiffCodec_Load_Fixture_ReturnsCanvasWithExpectedDimensions(string fi [MemberData(nameof(RgbFixtureFiles))] public void TiffCodec_Load_RgbFixture_MatchesSourcePngWithOpaqueAlpha(string fileName) { - var expected = PngCodec.Load(Path.Combine(PngSuitePath, "basn2c08.png")); + var expected = PngCodec.Load(ResolveFixturePath(PngSuitePath, "basn2c08.png")); var actual = TiffCodec.Load(ResolveFixturePath(AssetsPath, fileName)); Assert.Equal(expected.Width, actual.Width); @@ -158,7 +163,7 @@ public void TiffCodec_Load_RgbFixture_MatchesSourcePngWithOpaqueAlpha(string fil [MemberData(nameof(RgbaFixtureFiles))] public void TiffCodec_Load_RgbaFixture_MatchesSourcePngExactly(string fileName) { - var expected = PngCodec.Load(Path.Combine(PngSuitePath, "basn6a08.png")); + var expected = PngCodec.Load(ResolveFixturePath(PngSuitePath, "basn6a08.png")); var actual = TiffCodec.Load(ResolveFixturePath(AssetsPath, fileName)); Assert.Equal(expected.Width, actual.Width); diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/UnsupportedImageFeatureExceptionTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/UnsupportedImageFeatureExceptionTests.cs index f07ee902..43d610fb 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Codecs/UnsupportedImageFeatureExceptionTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/UnsupportedImageFeatureExceptionTests.cs @@ -90,7 +90,7 @@ public void UnsupportedImageFeatureException_IsIOException() { var exception = new UnsupportedImageFeatureException("test-feature", "test message"); - Assert.IsAssignableFrom(exception); + Assert.IsType(exception, exactMatch: false); } /// diff --git a/test/DemaConsulting.CanvasNet.Tests/Drawing/DashSplitterTests.cs b/test/DemaConsulting.CanvasNet.Tests/Drawing/DashSplitterTests.cs index 6ebbc81c..5b134a6a 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Drawing/DashSplitterTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Drawing/DashSplitterTests.cs @@ -463,10 +463,10 @@ public void DashSplitter_Split_OrdinaryDashPatternOnOrdinaryPath_ProducesNormalD // is the whole-path solid-stroke fallback shape. Assert.Equal( [ - new List { new(0, 0), new(3, 0) }, - new List { new(5, 0), new(8, 0) }, - new List { new(10, 0), new(13, 0) }, - new List { new(15, 0), new(17, 0) } + [new(0, 0), new(3, 0)], + [new(5, 0), new(8, 0)], + [new(10, 0), new(13, 0)], + [new(15, 0), new(17, 0)] ], segments.Select(s => s.Points).ToList()); } diff --git a/test/DemaConsulting.CanvasNet.Tests/Drawing/StrokeOutlinerTests.cs b/test/DemaConsulting.CanvasNet.Tests/Drawing/StrokeOutlinerTests.cs index 01cf4630..558ac983 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Drawing/StrokeOutlinerTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Drawing/StrokeOutlinerTests.cs @@ -569,8 +569,15 @@ public void StrokeOutliner_Outline_ClosedContourWithExtremeFloat32NonCollinearCo // defeated by the same float32 overflow being proven fixed above. var outerArea = GetSignedAreaDouble(polygons[0]); var innerArea = GetSignedAreaDouble(polygons[1]); + + // Mirror StrokeOutliner's own near-zero tolerance (rather than an exact-zero comparison) + // when treating a computed area as "non-zero" - these areas are computed, not literal + // sentinel values, so a tiny tolerance guards against floating noise near true zero. + const double areaNearZeroTolerance = 1e-9; Assert.True( - outerArea != 0.0 && innerArea != 0.0 && Math.Sign(outerArea) != Math.Sign(innerArea), + Math.Abs(outerArea) > areaNearZeroTolerance + && Math.Abs(innerArea) > areaNearZeroTolerance + && Math.Sign(outerArea) != Math.Sign(innerArea), $"Expected oppositely-wound non-zero rings, but got outerArea={outerArea}, innerArea={innerArea}."); } diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs index 4005b4b5..ae324bc9 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/GlyfLocaReaderTests.cs @@ -50,8 +50,7 @@ public void GlyfLocaReader_SimpleGlyph_AllOnCurve_ProducesLineSegments() var path = reader.GetGlyphOutline(0); // Assert: a single subpath of straight line segments is produced - Assert.Single(path.Subpaths); - var subpath = path.Subpaths[0]; + var subpath = Assert.Single(path.Subpaths); Assert.Equal(new System.Numerics.Vector2(0, 0), subpath.Start); Assert.All(subpath.Commands, c => Assert.True(c.Type is PathCommandType.LineTo or PathCommandType.Close)); } diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontRealFontIntegrationTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontRealFontIntegrationTests.cs index b7b93979..dd50bad9 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontRealFontIntegrationTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontRealFontIntegrationTests.cs @@ -22,9 +22,12 @@ public class TrueTypeFontRealFontIntegrationTests { /// /// The path to the real "Open Sans" TrueType font, copied to the test output directory by - /// the test project's FontFixtures\** content item. + /// the test project's FontFixtures\** content item. + /// is used instead of purely + /// to avoid CodeQL's cs/path-combine rule, since Path.Join does not discard + /// preceding segments when a later segment looks rooted. /// - private static string FontPath => System.IO.Path.Combine(AppContext.BaseDirectory, "FontFixtures", "OpenSans-Regular.ttf"); + private static string FontPath => System.IO.Path.Join(AppContext.BaseDirectory, "FontFixtures", "OpenSans-Regular.ttf"); /// /// Proves that a real glyph loaded from a real production font, when transformed into diff --git a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs index b534711f..7abd3aa2 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Fonts/TrueTypeFontTests.cs @@ -48,7 +48,8 @@ public void TrueTypeFont_Load_WellFormedFont_ExposesMetrics() var data = BuildWellFormedFont(); // Act: load the font - var font = TrueTypeFont.Load(new MemoryStream(data)); + using var ms50 = new MemoryStream(data); + var font = TrueTypeFont.Load(ms50); // Assert: font-level metrics are exposed as configured Assert.Equal(1000, font.UnitsPerEm); @@ -68,7 +69,8 @@ public void TrueTypeFont_Load_EndToEnd_GlyphIndexOutlineAdvanceAndKerning() var data = BuildWellFormedFont(); // Act: load the font and query glyph index, outline, advance width, and kerning - var font = TrueTypeFont.Load(new MemoryStream(data)); + using var ms70 = new MemoryStream(data); + var font = TrueTypeFont.Load(ms70); var glyphIndex = font.GetGlyphIndex('A'); var outline = font.GetGlyphOutline(glyphIndex); var advance = font.GetAdvanceWidth(glyphIndex); @@ -148,7 +150,8 @@ public void TrueTypeFont_Load_MissingRequiredTable_ThrowsInvalidDataException() .Build(); // missing maxp/hhea/hmtx/loca/glyf // Act/Assert: loading the font with missing required tables throws - Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); + using var ms150 = new MemoryStream(data); + Assert.Throws(() => TrueTypeFont.Load(ms150)); } /// @@ -164,7 +167,8 @@ public void TrueTypeFont_Load_OttoFont_ThrowsInvalidDataException() .Build(); // Act/Assert: loading the OTTO font throws - Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); + using var ms166 = new MemoryStream(data); + Assert.Throws(() => TrueTypeFont.Load(ms166)); } /// @@ -185,7 +189,8 @@ public void TrueTypeFont_Load_MaxpVersion05_ThrowsInvalidDataException() .Build(); // Act/Assert: loading the font with the unsupported maxp version throws - Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); + using var ms187 = new MemoryStream(data); + Assert.Throws(() => TrueTypeFont.Load(ms187)); } /// @@ -212,7 +217,8 @@ public void TrueTypeFont_Load_TruncatedMaxpVersion10_ThrowsInvalidDataException( .Build(); // Act/Assert: loading the font with the truncated version 1.0 maxp table throws - Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); + using var ms214 = new MemoryStream(data); + Assert.Throws(() => TrueTypeFont.Load(ms214)); } /// @@ -233,7 +239,8 @@ public void TrueTypeFont_Load_ZeroUnitsPerEm_ThrowsInvalidDataException() .Build(); // Act/Assert: loading the font with zero unitsPerEm throws - Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); + using var ms235 = new MemoryStream(data); + Assert.Throws(() => TrueTypeFont.Load(ms235)); } /// @@ -254,7 +261,8 @@ public void TrueTypeFont_Load_InvalidIndexToLocFormat_ThrowsInvalidDataException .Build(); // Act/Assert: loading the font with the invalid indexToLocFormat throws - Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(data))); + using var ms256 = new MemoryStream(data); + Assert.Throws(() => TrueTypeFont.Load(ms256)); } /// @@ -275,7 +283,8 @@ public void TrueTypeFont_Load_NoCmapOrKern_FallsBackGracefully() .Build(); // Act: load the font and query codepoint lookup and kerning - var font = TrueTypeFont.Load(new MemoryStream(data)); + using var ms277 = new MemoryStream(data); + var font = TrueTypeFont.Load(ms277); // Assert: missing optional tables fall back gracefully to default values Assert.Equal(0, font.GetGlyphIndex('A')); @@ -289,7 +298,8 @@ public void TrueTypeFont_Load_NoCmapOrKern_FallsBackGracefully() public void TrueTypeFont_GetGlyphOutline_NegativeGlyphIndex_ThrowsArgumentOutOfRangeException() { // Arrange: load a well-formed font - var font = TrueTypeFont.Load(new MemoryStream(BuildWellFormedFont())); + using var ms291 = new MemoryStream(BuildWellFormedFont()); + var font = TrueTypeFont.Load(ms291); // Act/Assert: requesting a negative glyph index throws Assert.Throws(() => font.GetGlyphOutline(-1)); @@ -302,7 +312,8 @@ public void TrueTypeFont_GetGlyphOutline_NegativeGlyphIndex_ThrowsArgumentOutOfR public void TrueTypeFont_GetGlyphOutline_GlyphIndexTooLarge_ThrowsArgumentOutOfRangeException() { // Arrange: load a well-formed font - var font = TrueTypeFont.Load(new MemoryStream(BuildWellFormedFont())); + using var ms304 = new MemoryStream(BuildWellFormedFont()); + var font = TrueTypeFont.Load(ms304); // Act/Assert: requesting a glyph index at/beyond the glyph count throws Assert.Throws(() => font.GetGlyphOutline(font.GlyphCount)); @@ -315,7 +326,8 @@ public void TrueTypeFont_GetGlyphOutline_GlyphIndexTooLarge_ThrowsArgumentOutOfR public void TrueTypeFont_GetAdvanceWidth_GlyphIndexTooLarge_ThrowsArgumentOutOfRangeException() { // Arrange: load a well-formed font - var font = TrueTypeFont.Load(new MemoryStream(BuildWellFormedFont())); + using var ms317 = new MemoryStream(BuildWellFormedFont()); + var font = TrueTypeFont.Load(ms317); // Act/Assert: requesting the advance width of an out-of-range glyph index throws Assert.Throws(() => font.GetAdvanceWidth(font.GlyphCount)); @@ -328,7 +340,8 @@ public void TrueTypeFont_GetAdvanceWidth_GlyphIndexTooLarge_ThrowsArgumentOutOfR public void TrueTypeFont_GetKerning_OutOfRangeGlyphIndex_NeverThrows() { // Arrange: load a well-formed font - var font = TrueTypeFont.Load(new MemoryStream(BuildWellFormedFont())); + using var ms330 = new MemoryStream(BuildWellFormedFont()); + var font = TrueTypeFont.Load(ms330); // Act: query kerning with out-of-range glyph indices var result = font.GetKerning(-1, 99999); @@ -363,7 +376,8 @@ public void TrueTypeFont_GetKerning_GlyphIndexValidUshortButBeyondGlyphCount_Ret .AddTable("kern", kern) .Build(); - var font = TrueTypeFont.Load(new MemoryStream(data)); + using var ms365 = new MemoryStream(data); + var font = TrueTypeFont.Load(ms365); // Act: query kerning for the pair recorded in the kern table var result = font.GetKerning(100, 101); @@ -381,7 +395,8 @@ public void TrueTypeFont_GetKerning_GlyphIndexValidUshortButBeyondGlyphCount_Ret public void TrueTypeFont_GetGlyphIndex_UnmappedCodepoint_NeverThrows() { // Arrange: load a well-formed font - var font = TrueTypeFont.Load(new MemoryStream(BuildWellFormedFont())); + using var ms383 = new MemoryStream(BuildWellFormedFont()); + var font = TrueTypeFont.Load(ms383); // Act: query the glyph index for a codepoint absent from the cmap var result = font.GetGlyphIndex(0x10FFFF); @@ -416,7 +431,8 @@ public void TrueTypeFont_GetGlyphIndex_MalformedCmapOutOfRangeGlyphId_ClampsToZe .Build(); // Act: load the font and resolve the malformed-mapped codepoint - var font = TrueTypeFont.Load(new MemoryStream(data)); + using var ms418 = new MemoryStream(data); + var font = TrueTypeFont.Load(ms418); var glyphIndex = font.GetGlyphIndex(65); // Assert: the out-of-range mapped glyph index is clamped to zero rather than propagated, @@ -436,6 +452,7 @@ public void TrueTypeFont_Load_TruncatedStream_ThrowsInvalidDataException() var truncated = data[..(data.Length / 2)]; // Act/Assert: loading the truncated stream throws - Assert.Throws(() => TrueTypeFont.Load(new MemoryStream(truncated))); + using var ms438 = new MemoryStream(truncated); + Assert.Throws(() => TrueTypeFont.Load(ms438)); } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Geometry/BezierFlatteningTests.cs b/test/DemaConsulting.CanvasNet.Tests/Geometry/BezierFlatteningTests.cs index 1e9220ad..b804c07c 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Geometry/BezierFlatteningTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Geometry/BezierFlatteningTests.cs @@ -184,8 +184,8 @@ public void BezierFlattening_FlattenCubic_CoincidentControlPoints_TerminatesWith BezierFlattening.FlattenCubic(p, p, p, p, 0.01f, output); // Assert: terminates immediately (already flat) with just the end point - Assert.Single(output); - Assert.Equal(p, output[0]); + var point = Assert.Single(output); + Assert.Equal(p, point); } /// diff --git a/test/DemaConsulting.CanvasNet.Tests/Geometry/PathBuilderTests.cs b/test/DemaConsulting.CanvasNet.Tests/Geometry/PathBuilderTests.cs index 5c55b6fe..d56f0bb1 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Geometry/PathBuilderTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Geometry/PathBuilderTests.cs @@ -264,7 +264,7 @@ public void Path_Subpaths_DowncastToIList_ThrowsNotSupportedExceptionOnMutation( var path = builder.Build(); // Act: cast the read-only view back down to a mutable interface - var mutable = Assert.IsAssignableFrom>(path.Subpaths); + var mutable = Assert.IsType>(path.Subpaths, exactMatch: false); // Assert: every mutating member throws, regardless of the compile-time IReadOnlyList type Assert.Throws(() => mutable.Add(default)); @@ -287,7 +287,7 @@ public void Subpath_Commands_DowncastToIList_ThrowsNotSupportedExceptionOnMutati var subpath = path.Subpaths[0]; // Act - var mutable = Assert.IsAssignableFrom>(subpath.Commands); + var mutable = Assert.IsType>(subpath.Commands, exactMatch: false); // Assert Assert.Throws(() => mutable.Add(default)); @@ -400,9 +400,9 @@ public void PathBuilder_TangentArcTo_CollinearInputs_DegradesToLineTo() builder.MoveTo(new Vector2(0, 0)); builder.TangentArcTo(new Vector2(5, 0), new Vector2(10, 0), 2f); var commands = builder.Build().Subpaths[0].Commands; - Assert.Single(commands); - Assert.Equal(PathCommandType.LineTo, commands[0].Type); - Assert.Equal(new Vector2(5, 0), commands[0].EndPoint); + var command = Assert.Single(commands); + Assert.Equal(PathCommandType.LineTo, command.Type); + Assert.Equal(new Vector2(5, 0), command.EndPoint); } /// PathBuilder_TangentArcTo_ZeroRadius_DegradesToLineToCorner. @@ -413,9 +413,9 @@ public void PathBuilder_TangentArcTo_ZeroRadius_DegradesToLineToCorner() builder.MoveTo(new Vector2(0, 0)); builder.TangentArcTo(new Vector2(10, 0), new Vector2(10, 10), 0f); var commands = builder.Build().Subpaths[0].Commands; - Assert.Single(commands); - Assert.Equal(PathCommandType.LineTo, commands[0].Type); - Assert.Equal(new Vector2(10, 0), commands[0].EndPoint); + var command = Assert.Single(commands); + Assert.Equal(PathCommandType.LineTo, command.Type); + Assert.Equal(new Vector2(10, 0), command.EndPoint); } /// PathBuilder_TangentArcTo_NegativeRadius_ThrowsArgumentOutOfRangeException. diff --git a/test/DemaConsulting.CanvasNet.Tests/Rendering/TextRendererTests.cs b/test/DemaConsulting.CanvasNet.Tests/Rendering/TextRendererTests.cs index 494b8261..ac230800 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Rendering/TextRendererTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Rendering/TextRendererTests.cs @@ -36,7 +36,8 @@ private static TrueTypeFont NewFont() .AddTable("kern", kern) .Build(); - return TrueTypeFont.Load(new MemoryStream(data)); + using var stream = new MemoryStream(data); + return TrueTypeFont.Load(stream); } /// TextRenderer_MeasureText_EmptyString_ReturnsZeroWidth. @@ -102,7 +103,8 @@ private static TrueTypeFont NewFontWithPositiveDescender() .AddTable("cmap", cmap) .Build(); - return TrueTypeFont.Load(new MemoryStream(data)); + using var stream = new MemoryStream(data); + return TrueTypeFont.Load(stream); } ///