From ff3d7ad92af29166a0c1a487a39df1e510d9d35c Mon Sep 17 00:00:00 2001 From: Sawraz Date: Fri, 23 Oct 2026 10:13:10 +0600 Subject: [PATCH] Add net462 target on SkiaSharp 4.153.1 with no SixLabors.ImageSharp dependency ImageSharp 2.x is end-of-life and no longer patched, so .NET Framework gets its own build. An internal engine (SkiaEngine/) mirrors the ImageSharp API subset AnyBitmap uses, backed by SkiaSharp 4.153.1 for decode/resize/JPEG/WebP and BitMiracle.LibTiff for TIFF, with own PNG/BMP/GIF writers that keep ImageSharp's pixel-layout behaviour. - 42 public ImageSharp implicit operators are compiled out on net462 - SVG on net462 uses Svg.Skia 5.2.3 (SkiaSharp.Svg 1.60 is incompatible with SkiaSharp 4) - nuspec: net462 dependency group and lib/net462 - System.* reference check compares .NET Framework groups against the package's matching lib folder - Tests: run on net48 against net462; ImageSharp-cast tests guarded, plus SkiaEngineFunctionality covering the same behaviour through the public API Co-Authored-By: Claude Opus 5.5 --- CI/scripts/Test-SystemAssemblyReferences.ps1 | 29 +- .../IronSoftware.Drawing.Common.Tests.csproj | 13 +- .../UnitTests/AnyBitmapFunctionality.cs | 27 + .../UnitTests/ColorFunctionality.cs | 2 + .../UnitTests/Compare.cs | 14 + .../UnitTests/RectangleFunctionality.cs | 4 + .../UnitTests/SkiaEngineFunctionality.cs | 184 +++++ .../IronSoftware.Drawing.Common/AnyBitmap.cs | 124 +++- .../IronSoftware.Drawing.Common/Color.cs | 36 + .../IronSoftware.Drawing.Common.csproj | 22 +- .../Legacy/CropRectangle.cs | 8 + .../IronSoftware.Drawing.Common/Point.cs | 4 + .../IronSoftware.Drawing.Common/PointF.cs | 4 + .../IronSoftware.Drawing.Common/Rectangle.cs | 8 + .../IronSoftware.Drawing.Common/RectangleF.cs | 4 + .../IronSoftware.Drawing.Common/Size.cs | 4 + .../IronSoftware.Drawing.Common/SizeF.cs | 4 + .../SkiaEngine/Encoders.cs | 695 ++++++++++++++++++ .../SkiaEngine/Formats.cs | 224 ++++++ .../SkiaEngine/Image.cs | 546 ++++++++++++++ .../SkiaEngine/ImageDecoder.cs | 292 ++++++++ .../SkiaEngine/PixelFormats.cs | 190 +++++ .../SkiaEngine/Processing.cs | 178 +++++ IronSoftware.Drawing/SystemPackages.props | 6 +- NuGet/IronSoftware.Drawing.nuspec | 18 + 25 files changed, 2585 insertions(+), 55 deletions(-) create mode 100644 IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/SkiaEngineFunctionality.cs create mode 100644 IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/Encoders.cs create mode 100644 IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/Formats.cs create mode 100644 IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/Image.cs create mode 100644 IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/ImageDecoder.cs create mode 100644 IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/PixelFormats.cs create mode 100644 IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/Processing.cs diff --git a/CI/scripts/Test-SystemAssemblyReferences.ps1 b/CI/scripts/Test-SystemAssemblyReferences.ps1 index afdecaed..b2691294 100644 --- a/CI/scripts/Test-SystemAssemblyReferences.ps1 +++ b/CI/scripts/Test-SystemAssemblyReferences.ps1 @@ -66,17 +66,30 @@ function Get-AssemblyVersion([string] $path) { finally { $stream.Dispose() } } -# Lowest assembly version the given package version provides to .NET Framework / netstandard2.0 -# consumers. The lib folders can differ (System.Memory 4.6.3: net462 = 4.0.5.0, netstandard2.0 = 4.0.2.0), -# so take the minimum to be safe for every consumer. -function Get-PackageAssemblyVersion([string] $id, [string] $version, [string] $assemblyName) { +# Assembly version the given package version provides to consumers of our $tfm lib folder. +# The package lib folders can differ (System.Memory 4.6.3: net462 = 4.0.5.0, netstandard2.0 = 4.0.2.0). +# - .NET Framework group: the app resolves the package's own .NET Framework lib folder (the highest +# net4x folder not above our target, else netstandard2.0), so check against that one. +# - netstandard2.0 group: it can be consumed from any .NET Framework or netstandard app, so take the +# minimum across those lib folders to be safe for every consumer. +function Get-PackageAssemblyVersion([string] $id, [string] $version, [string] $assemblyName, [string] $tfm) { $packageDir = Join-Path $PackagesRoot (Join-Path $id.ToLowerInvariant() $version.ToLowerInvariant()) if (-not (Test-Path $packageDir)) { throw "Package $id $version is not in '$PackagesRoot'. Restore the solution before running this check." } - $versions = foreach ($lib in 'net462', 'net461', 'net46', 'net45', 'netstandard2.0') { - $dll = Join-Path $packageDir "lib/$lib/$assemblyName.dll" - if (Test-Path $dll) { Get-AssemblyVersion $dll } + $candidates = 'net48', 'net472', 'net471', 'net47', 'net462', 'net461', 'net46', 'net45' + if ($tfm -match '^net4\d+$') { + $ours = [int]($tfm.Substring(3).PadRight(3, '0')) + $lib = @($candidates | Where-Object { [int]($_.Substring(3).PadRight(3, '0')) -le $ours }) + 'netstandard2.0' | + Where-Object { Test-Path (Join-Path $packageDir "lib/$_/$assemblyName.dll") } | + Select-Object -First 1 + $versions = if ($lib) { Get-AssemblyVersion (Join-Path $packageDir "lib/$lib/$assemblyName.dll") } + } + else { + $versions = foreach ($lib in $candidates + 'netstandard2.0') { + $dll = Join-Path $packageDir "lib/$lib/$assemblyName.dll" + if (Test-Path $dll) { Get-AssemblyVersion $dll } + } } if (-not $versions) { throw "No .NET Framework / netstandard2.0 assembly found for $id $version in '$packageDir'." @@ -129,7 +142,7 @@ foreach ($group in $nuspec.SelectNodes("//*[local-name()='dependencies']/*[local continue } $packageVersion = $declared[$ref.Name] - $provided = Get-PackageAssemblyVersion $ref.Name $packageVersion $ref.Name + $provided = Get-PackageAssemblyVersion $ref.Name $packageVersion $ref.Name $tfm $status = if ($ref.Version -gt $provided) { 'FAIL' } else { 'ok' } Write-Host ("[{0}] {1}: {2} {3} -> declared package {4} provides {5} {6}" -f $tfm, $dll.Name, $ref.Name, $ref.Version, $packageVersion, $provided, $status) if ($status -eq 'FAIL') { diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/IronSoftware.Drawing.Common.Tests.csproj b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/IronSoftware.Drawing.Common.Tests.csproj index 30232625..042025fa 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/IronSoftware.Drawing.Common.Tests.csproj +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/IronSoftware.Drawing.Common.Tests.csproj @@ -14,8 +14,6 @@ - - @@ -28,6 +26,17 @@ + + + + + + + + + + + diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs index 88e2a8cc..9f2b0b8e 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs @@ -249,6 +249,7 @@ public void AnyBitmap_should_get_Bytes() Assert.Equal(expected, resultExport); } +#if !NET48 [FactWithAutomaticDisplayName] public void AnyBitmap_should_set_Pixel() { @@ -289,6 +290,7 @@ public void AnyBitmap_should_set_Pixel() #endif } } +#endif [FactWithAutomaticDisplayName] public void AnyBitmap_should_get_Stream() @@ -338,6 +340,7 @@ public void AnyBitmap_should_ToString() Assert.Equal(expected, result); } +#if !NET48 [FactWithAutomaticDisplayName] public void AnyBitmap_GetPixel_should_not_throw_OutOfMemoryException() { @@ -389,6 +392,7 @@ public void Clone_AnyBitmap() AssertImageAreEqual("expected.png", "result.png", true); } +#endif [FactWithAutomaticDisplayName] public void Clone_Crop_AnyBitmap() @@ -472,6 +476,7 @@ public void CastSKImage_from_AnyBitmap() AssertImageAreEqual("expected.png", "result.png", true); } +#if !NET48 [FactWithAutomaticDisplayName] public void CastSixLabors_to_AnyBitmap() { @@ -532,6 +537,7 @@ public void CastSixLabors_from_AnyBitmap_Rgba32(string filename) AssertImageAreEqual("expected.bmp", "result.bmp", true); } +#endif [FactWithAutomaticDisplayName] public void CastBitmap_to_AnyBitmap_using_FromBitmap() @@ -710,6 +716,7 @@ public void Create_Multi_page_Tiff_Paths() File.Delete(outputImagePath); } +#if !NET48 [FactWithAutomaticDisplayName] public void CreateMultiFrameTiffStream_Preserves_Mixed_Orientation() { @@ -752,6 +759,7 @@ public void CreateMultiFrameTiffBytes_Preserves_Per_Page_Dimensions_And_Resoluti .Should().BeApproximately(150d, 2d); } } +#endif [FactWithAutomaticDisplayName] public void CreateMultiFrameTiffStream_Empty_Sequence_Throws() @@ -760,6 +768,7 @@ public void CreateMultiFrameTiffStream_Empty_Sequence_Throws() act.Should().Throw(); } +#if !NET48 [FactWithAutomaticDisplayName] public void CreateMultiFrameTiffBytes_PreservesResolution_FromPixelsPerMeterSource() { @@ -776,12 +785,18 @@ public void CreateMultiFrameTiffBytes_PreservesResolution_FromPixelsPerMeterSour ToDotsPerInch(pages[0].XResolution, pages[0].ResolutionUnit) .Should().BeApproximately(dpi, 2d); } +#endif [FactWithAutomaticDisplayName] public void CreateMultiFrameTiff_Preserves_Rgb24_Pixels() { string jpgPath = GetRelativeFilePath("mountainclimbers.jpg"); +#if NET48 + // JPEG decoders differ by a few levels; compare against the same engine's decode of the source. + using var expected = AnyBitmap.FromFile(jpgPath); +#else using var expected = SixLabors.ImageSharp.Image.Load(jpgPath); +#endif using var result = AnyBitmap.CreateMultiFrameTiff(new List { jpgPath }); @@ -800,7 +815,11 @@ public void CreateMultiFrameTiff_Preserves_Rgb24_Pixels() foreach (var (x, y) in points) { +#if NET48 + var e = expected.GetPixel(x, y); +#else Rgb24 e = expected[x, y]; +#endif var a = result.GetPixel(x, y); a.R.Should().Be(e.R, $"red channel at ({x},{y})"); a.G.Should().Be(e.G, $"green channel at ({x},{y})"); @@ -808,6 +827,7 @@ public void CreateMultiFrameTiff_Preserves_Rgb24_Pixels() } } +#if !NET48 [TheoryWithAutomaticDisplayName] [InlineData("Rgb24")] [InlineData("Bgr24")] @@ -872,6 +892,7 @@ private static AnyBitmap MakeBitmapWithResolution(int width, int height, Rgb24 c image.Metadata.ResolutionUnits = unit; return image; } +#endif private static List<(int Width, int Height, float XResolution, ResUnit ResolutionUnit)> ReadTiffDirectories(byte[] tiffData) { @@ -912,6 +933,7 @@ private static double ToDotsPerInch(float xResolution, ResUnit unit) }; } +#if !NET48 [FactWithAutomaticDisplayName] public void Create_Multi_page_Gif() { @@ -987,6 +1009,7 @@ public void Should_Return_BitsPerPixel() bitmap = Image.Load(GetRelativeFilePath("mountainclimbers.jpg")); Assert.Equal(32, bitmap.BitsPerPixel); } +#endif [TheoryWithAutomaticDisplayName] [InlineData("ScanDev_BW.tif", 1)] @@ -1288,6 +1311,7 @@ public void Should_RotateFlip() AssertImageAreEqual(GetRelativeFilePath("checkmarkFlip.jpg"), "result_flip.bmp"); } +#if !NET48 [FactWithAutomaticDisplayName] public void Redact_ShouldRedactRegionWithColor() { @@ -1324,6 +1348,7 @@ public void Redact_ShouldRedactRegionWithColor() } } } +#endif [FactWithAutomaticDisplayName] public void TestGetRGBBuffer() @@ -1569,6 +1594,7 @@ public void LoadImage_SetPreserveOriginalFormat_ShouldReturnCorrectBitPerPixel(s } #if !NET7_0 +#if !NET48 [FactWithAutomaticDisplayName] public void CastAnyBitmap_from_SixLabors() { @@ -1584,6 +1610,7 @@ public void CastAnyBitmap_from_SixLabors() AssertLargeImageAreEqual("expected.bmp", "result.bmp", true); } #endif +#endif [IgnoreOnAzureDevopsX86Fact] diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/ColorFunctionality.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/ColorFunctionality.cs index 41c1c7a1..79608672 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/ColorFunctionality.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/ColorFunctionality.cs @@ -274,6 +274,7 @@ public void Cast_SKColor_to_Color() Assert.Equal(176, skColor.Blue); } +#if !NET48 [FactWithAutomaticDisplayName] public void Cast_ImageSharp_Color_from_Color() { @@ -537,6 +538,7 @@ public void Should_Return_Argb() Assert.Equal(bmColor.ToArgb(), rgba32.ToArgb()); Assert.Equal(bmColor.ToArgb(), rgb24.ToArgb()); } +#endif [FactWithAutomaticDisplayName] public void Should_Create_FromName() diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/Compare.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/Compare.cs index bc4a4a28..58c13a7b 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/Compare.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/Compare.cs @@ -25,9 +25,23 @@ protected static void AssertLargeImageAreEqual(string expectedImagePath, string expectedImageSharp.Mutate(x => x.Resize(100, 100)); actualImageSharp.Mutate(x => x.Resize(100, 100)); +#if NET48 + // The ImageSharp casts are not available on .NET Framework; go through PNG bytes instead. + AssertImageAreEqual(ToAnyBitmap(expectedImageSharp), ToAnyBitmap(actualImageSharp)); +#else AssertImageAreEqual(expectedImageSharp, actualImageSharp); +#endif } +#if NET48 + private static AnyBitmap ToAnyBitmap(SixLabors.ImageSharp.Image image) + { + using var stream = new MemoryStream(); + SixLabors.ImageSharp.ImageExtensions.SaveAsPng(image, stream); + return AnyBitmap.FromBytes(stream.ToArray()); + } +#endif + protected static void AssertImageAreEqual(string expectedImagePath, string resultImagePath, bool isCleanAll = false) { using var expected = AnyBitmap.FromFile(expectedImagePath); diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/RectangleFunctionality.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/RectangleFunctionality.cs index a12e3ad5..64edcb6c 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/RectangleFunctionality.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/RectangleFunctionality.cs @@ -70,6 +70,7 @@ public void CastRectangle_from_Rectangle() Assert.Equal(5, rectangle.Y); } +#if !NET48 [FactWithAutomaticDisplayName] public void CastImageSharp_Rectangle_to_Rectangle() { @@ -137,6 +138,7 @@ public void CastImageSharp_RectangleF_from_Rectangle() Assert.Equal(5, rectangle.X); Assert.Equal(5, rectangle.Y); } +#endif [FactWithAutomaticDisplayName] public void CastSKRect_to_Rectangle() @@ -411,6 +413,7 @@ public void CastRectangleF_from_RectangleF() Assert.Equal(5, Rectangle.Y); } +#if !NET48 [FactWithAutomaticDisplayName] public void CastImageSharp_RectangleF_to_RectangleF() { @@ -443,6 +446,7 @@ public void CastImageSharp_RectangleF_from_RectangleF() Assert.Equal(5, Rectangle.X); Assert.Equal(5, Rectangle.Y); } +#endif [FactWithAutomaticDisplayName] public void CastSKRect_to_RectangleF() diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/SkiaEngineFunctionality.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/SkiaEngineFunctionality.cs new file mode 100644 index 00000000..f3449668 --- /dev/null +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/SkiaEngineFunctionality.cs @@ -0,0 +1,184 @@ +#if NET48 +using FluentAssertions; +using System.Collections.Generic; +using System.Linq; +using Xunit; +using Xunit.Abstractions; + +namespace IronSoftware.Drawing.Common.Tests.UnitTests +{ + /// + /// .NET Framework runs on the SkiaSharp engine (no ImageSharp). These tests cover the behaviour that the + /// ImageSharp-cast tests check on other targets, using only the public AnyBitmap API. + /// + public class SkiaEngineFunctionality : Compare + { + public SkiaEngineFunctionality(ITestOutputHelper output) : base(output) + { + } + + private static AnyBitmap MakeBitmap(int width, int height) + { + var bitmap = new AnyBitmap(width, height, Color.White); + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + bitmap.SetPixel(x, y, Color.FromArgb(255, x * 255 / width, y * 255 / height, 128)); + } + } + + return bitmap; + } + + [FactWithAutomaticDisplayName] + public void SetPixel_GetPixel_RoundTrip() + { + using var bitmap = AnyBitmap.FromFile(GetRelativeFilePath("mountainclimbers.jpg")); + bitmap.GetPixel(0, 0).Should().NotBe(Color.Black); + + bitmap.SetPixel(0, 0, Color.Black); + bitmap.SetPixel(5, 7, Color.FromArgb(255, 10, 20, 30)); + + bitmap.GetPixel(0, 0).Should().Be(Color.Black); + bitmap.GetPixel(5, 7).Should().Be(Color.FromArgb(255, 10, 20, 30)); + } + + [TheoryWithAutomaticDisplayName] + [InlineData(AnyBitmap.ImageFormat.Png)] + [InlineData(AnyBitmap.ImageFormat.Bmp)] + [InlineData(AnyBitmap.ImageFormat.Tiff)] + public void LosslessFormats_RoundTripPixels(AnyBitmap.ImageFormat format) + { + using var source = MakeBitmap(40, 30); + using var decoded = AnyBitmap.FromBytes(source.ExportBytes(format)); + + decoded.Width.Should().Be(40); + decoded.Height.Should().Be(30); + foreach (var (x, y) in new[] { (0, 0), (39, 0), (20, 15), (0, 29), (39, 29) }) + { + decoded.GetPixel(x, y).Should().Be(source.GetPixel(x, y), $"{format} pixel ({x},{y})"); + } + } + + [TheoryWithAutomaticDisplayName] + [InlineData(AnyBitmap.ImageFormat.Jpeg, "image/jpeg")] + [InlineData(AnyBitmap.ImageFormat.Webp, "image/webp")] + [InlineData(AnyBitmap.ImageFormat.Gif, "image/gif")] + public void LossyFormats_RoundTripCloseToSource(AnyBitmap.ImageFormat format, string mimeType) + { + using var source = new AnyBitmap(32, 32, Color.FromArgb(255, 200, 40, 90)); + using var decoded = AnyBitmap.FromBytes(source.ExportBytes(format, 90)); + + decoded.MimeType.Should().Be(mimeType); + Color pixel = decoded.GetPixel(16, 16); + ((int)pixel.R).Should().BeCloseTo(200, 6); + ((int)pixel.G).Should().BeCloseTo(40, 6); + ((int)pixel.B).Should().BeCloseTo(90, 6); + } + + [FactWithAutomaticDisplayName] + public void Png_PreservesTransparency() + { + using var source = new AnyBitmap(10, 10, Color.Transparent); + source.SetPixel(3, 3, Color.FromArgb(128, 255, 0, 0)); + + using var decoded = AnyBitmap.FromBytes(source.ExportBytes(AnyBitmap.ImageFormat.Png)); + + decoded.GetPixel(0, 0).A.Should().Be(0); + decoded.GetPixel(3, 3).Should().Be(Color.FromArgb(128, 255, 0, 0)); + } + + [FactWithAutomaticDisplayName] + public void Clone_Rectangle_CropsPixels() + { + using var source = MakeBitmap(50, 40); + using var cropped = source.Clone(new Rectangle(10, 5, 20, 15)); + + cropped.Width.Should().Be(20); + cropped.Height.Should().Be(15); + cropped.GetPixel(0, 0).Should().Be(source.GetPixel(10, 5)); + cropped.GetPixel(19, 14).Should().Be(source.GetPixel(29, 19)); + } + + [FactWithAutomaticDisplayName] + public void Redact_FillsOnlyTheRegion() + { + using var source = new AnyBitmap(100, 100, Color.White); + using var redacted = source.Redact(new Rectangle(10, 10, 30, 20), Color.Red); + + redacted.GetPixel(10, 10).Should().Be(Color.Red); + redacted.GetPixel(39, 29).Should().Be(Color.Red); + redacted.GetPixel(9, 10).Should().Be(Color.White); + redacted.GetPixel(40, 30).Should().Be(Color.White); + } + + [FactWithAutomaticDisplayName] + public void RotateFlip_MovesPixels() + { + using var source = MakeBitmap(20, 10); + using var rotated = source.RotateFlip(AnyBitmap.RotateMode.Rotate90, AnyBitmap.FlipMode.None); + + rotated.Width.Should().Be(10); + rotated.Height.Should().Be(20); + // Rotating 90 degrees clockwise moves the top-left pixel to the top-right corner. + rotated.GetPixel(9, 0).Should().Be(source.GetPixel(0, 0)); + } + + [FactWithAutomaticDisplayName] + public void Resize_ChangesDimensions() + { + using var source = AnyBitmap.FromFile(GetRelativeFilePath("mountainclimbers.jpg")); + using var resized = new AnyBitmap(source, 64, 48); + + resized.Width.Should().Be(64); + resized.Height.Should().Be(48); + } + + [FactWithAutomaticDisplayName] + public void MultiFrameGif_KeepsEveryFrame() + { + var frames = new List + { + new AnyBitmap(30, 30, Color.Red), + new AnyBitmap(30, 30, Color.Lime), + new AnyBitmap(30, 30, Color.Blue) + }; + + using var gif = AnyBitmap.CreateMultiFrameGif(frames); + using var decoded = AnyBitmap.FromBytes(gif.ExportBytes(AnyBitmap.ImageFormat.Gif)); + + decoded.FrameCount.Should().Be(3); + var decodedFrames = decoded.GetAllFrames.ToList(); + decodedFrames[0].GetPixel(15, 15).Should().Be(Color.Red); + decodedFrames[1].GetPixel(15, 15).Should().Be(Color.Lime); + decodedFrames[2].GetPixel(15, 15).Should().Be(Color.Blue); + } + + [FactWithAutomaticDisplayName] + public void MultiFrameTiff_KeepsEveryPage() + { + var pages = new List { MakeBitmap(40, 30), MakeBitmap(40, 30) }; + pages[1].SetPixel(0, 0, Color.Black); + + using var tiff = AnyBitmap.CreateMultiFrameTiff(pages); + using var decoded = AnyBitmap.FromBytes(tiff.ExportBytes(AnyBitmap.ImageFormat.Tiff)); + + decoded.FrameCount.Should().Be(2); + var decodedPages = decoded.GetAllFrames.ToList(); + decodedPages[0].GetPixel(0, 0).Should().Be(pages[0].GetPixel(0, 0)); + decodedPages[1].GetPixel(0, 0).Should().Be(Color.Black); + } + + [TheoryWithAutomaticDisplayName] + [InlineData("24_bit.png", 24)] + [InlineData("mountainclimbers.jpg", 24)] + [InlineData("32_bit_transparent.png", 32)] + public void BitsPerPixel_FollowsSource(string fileName, int expected) + { + using var bitmap = AnyBitmap.FromFile(GetRelativeFilePath(fileName), true); + bitmap.BitsPerPixel.Should().Be(expected); + } + } +} +#endif diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs index 655c6470..b26b056a 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs @@ -1,5 +1,12 @@ using BitMiracle.LibTiff.Classic; using Microsoft.Maui.Graphics.Platform; +#if NETFRAMEWORK +// .NET Framework: SkiaSharp-based engine with the same API surface (ImageSharp 2.x is end-of-life). +using IronSoftware.Drawing.SkiaEngine; +using EngineRotateMode = IronSoftware.Drawing.SkiaEngine.RotateMode; +using EngineFlipMode = IronSoftware.Drawing.SkiaEngine.FlipMode; +using EnginePixelResolutionUnit = IronSoftware.Drawing.SkiaEngine.PixelResolutionUnit; +#else using SixLabors.ImageSharp; using SixLabors.ImageSharp.Drawing.Processing; using SixLabors.ImageSharp.Formats; @@ -12,6 +19,10 @@ using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; +using EngineRotateMode = SixLabors.ImageSharp.Processing.RotateMode; +using EngineFlipMode = SixLabors.ImageSharp.Processing.FlipMode; +using EnginePixelResolutionUnit = SixLabors.ImageSharp.Metadata.PixelResolutionUnit; +#endif using SkiaSharp; using System; using System.Collections.Generic; @@ -1186,8 +1197,8 @@ public IEnumerable GetAllFrames { var images = GetInternalImages(); IEnumerable frames = images.Count == 1 - ? ImageFrameCollectionToImages(images[0].Frames).Select(x => (AnyBitmap)x) - : images.Select(x => (AnyBitmap)x); + ? ImageFrameCollectionToImages(images[0].Frames).Select(x => new AnyBitmap(x)) + : images.Select(x => new AnyBitmap(x)); // Each frame is a freshly-cast AnyBitmap. Carry the captured original source depth // onto each frame. Only do this when this object captured an original depth (i.e. a @@ -1239,7 +1250,7 @@ public static AnyBitmap CreateMultiFrameTiff(IEnumerable imagePaths) public static AnyBitmap CreateMultiFrameTiff(IEnumerable images) { using var stream = new MemoryStream(); - InternalSaveAsMultiPageTiff(images.Select(x => (Image)x), stream); + InternalSaveAsMultiPageTiff(images.Select(CastToImageSharp), stream); return FromStream(stream); } @@ -1269,7 +1280,7 @@ public static MemoryStream CreateMultiFrameTiffStream(IEnumerable ima if (image == null) throw new ArgumentException("The image sequence contains a null element.", nameof(images)); - frames.Add(((Image)image).CloneAs()); + frames.Add(CastToImageSharp(image).CloneAs()); } if (frames.Count == 0) @@ -1333,7 +1344,7 @@ public static AnyBitmap CreateMultiFrameGif(IEnumerable imagePaths) public static AnyBitmap CreateMultiFrameGif(IEnumerable images) { using var stream = new MemoryStream(); - InternalSaveAsMultiPageGif(images.Select(x => (Image)x), stream); + InternalSaveAsMultiPageGif(images.Select(CastToImageSharp), stream); return FromStream(stream); } @@ -1466,20 +1477,20 @@ public static AnyBitmap RotateFlip( RotateMode rotateMode, FlipMode flipMode) { - SixLabors.ImageSharp.Processing.RotateMode rotateModeImgSharp = rotateMode switch + EngineRotateMode rotateModeImgSharp = rotateMode switch { - RotateMode.None => SixLabors.ImageSharp.Processing.RotateMode.None, - RotateMode.Rotate90 => SixLabors.ImageSharp.Processing.RotateMode.Rotate90, - RotateMode.Rotate180 => SixLabors.ImageSharp.Processing.RotateMode.Rotate180, - RotateMode.Rotate270 => SixLabors.ImageSharp.Processing.RotateMode.Rotate270, + RotateMode.None => EngineRotateMode.None, + RotateMode.Rotate90 => EngineRotateMode.Rotate90, + RotateMode.Rotate180 => EngineRotateMode.Rotate180, + RotateMode.Rotate270 => EngineRotateMode.Rotate270, _ => throw new NotImplementedException() }; - SixLabors.ImageSharp.Processing.FlipMode flipModeImgSharp = flipMode switch + EngineFlipMode flipModeImgSharp = flipMode switch { - FlipMode.None => SixLabors.ImageSharp.Processing.FlipMode.None, - FlipMode.Horizontal => SixLabors.ImageSharp.Processing.FlipMode.Horizontal, - FlipMode.Vertical => SixLabors.ImageSharp.Processing.FlipMode.Vertical, + FlipMode.None => EngineFlipMode.None, + FlipMode.Horizontal => EngineFlipMode.Horizontal, + FlipMode.Vertical => EngineFlipMode.Vertical, _ => throw new NotImplementedException() }; @@ -2060,6 +2071,7 @@ public byte[] GetRGBABuffer() } #region Implicit Casting +#if !NETFRAMEWORK /// /// Implicitly casts SixLabors.ImageSharp.Image objects to @@ -2091,6 +2103,7 @@ public static implicit operator AnyBitmap(Image image) "Error while casting AnyBitmap from SixLabors.ImageSharp.Image", e); } } +#endif /// /// Since we store ImageSharp object internal AnyBitmap (lazy) so this casting will return the same ImageSharp object if it loaded. @@ -2157,7 +2170,7 @@ private static Image CastToImageSharp(AnyBitmap bitmap) /// Since we store ImageSharp object internal AnyBitmap (lazy) so this casting will return the same ImageSharp object if it loaded. /// But if it is gif/tiff we need to make resize all frame to have the same size before we load to ImageSharp object; /// - private static Image CastToImageSharp(AnyBitmap bitmap) where T :unmanaged, SixLabors.ImageSharp.PixelFormats.IPixel + private static Image CastToImageSharp(AnyBitmap bitmap) where T :unmanaged, IPixel { try { @@ -2217,6 +2230,7 @@ private static Image CastToImageSharp(AnyBitmap bitmap) where T :unmanaged "Error while casting AnyBitmap to SixLabors.ImageSharp.Image", e); } } +#if !NETFRAMEWORK /// /// Implicitly casts to SixLabors.ImageSharp.Image objects from @@ -2234,6 +2248,8 @@ public static implicit operator Image(AnyBitmap bitmap) { return CastToImageSharp(bitmap); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts SixLabors.ImageSharp.Image objects to @@ -2264,6 +2280,8 @@ public static implicit operator AnyBitmap(Image image) "Error while casting AnyBitmap from SixLabors.ImageSharp.Image", e); } } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts to SixLabors.ImageSharp.Image objects from @@ -2294,6 +2312,8 @@ public static implicit operator Image(AnyBitmap bitmap) "Error while casting AnyBitmap to SixLabors.ImageSharp.Image", e); } } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts SixLabors.ImageSharp.Image objects to @@ -2324,6 +2344,8 @@ public static implicit operator AnyBitmap(Image image) "Error while casting AnyBitmap from SixLabors.ImageSharp.Image", e); } } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts to SixLabors.ImageSharp.Image objects from @@ -2354,6 +2376,7 @@ public static implicit operator Image(AnyBitmap bitmap) "Error while casting AnyBitmap to SixLabors.ImageSharp.Image", e); } } +#endif /// /// Implicitly casts SkiaSharp.SKImage objects to @@ -2900,6 +2923,13 @@ private static AnyBitmap LoadSVGImage(string file, bool preserveOriginalFormat) DecodeSVG(file).Encode(SKEncodedImageFormat.Png, 100) .ToArray(), preserveOriginalFormat); } +#if NETFRAMEWORK + catch (DllNotFoundException e) when (e.InnerException is FileNotFoundException) + { + // Svg.Skia is not installed; DecodeSVG's message already says so. + throw; + } +#endif catch (DllNotFoundException e) { throw new DllNotFoundException( @@ -2916,33 +2946,49 @@ private static SKBitmap DecodeSVG(string strInput) { try { - SkiaSharp.Extended.Svg.SKSvg svg = new(); - _ = svg.Load(strInput); - - SKBitmap toBitmap = new( - (int)svg.Picture.CullRect.Width, - (int)svg.Picture.CullRect.Height); - using (SKCanvas canvas = new(toBitmap)) - { - canvas.Clear(SKColors.White); - canvas.DrawPicture(svg.Picture); - canvas.Flush(); - } - - return toBitmap; - + return RenderSVG(strInput); } catch (DllNotFoundException e) { throw new DllNotFoundException("Please install SkiaSharp.Svg " + "from NuGet.", e); } +#if NETFRAMEWORK + catch (FileNotFoundException e) when (e.FileName?.StartsWith("Svg.", StringComparison.OrdinalIgnoreCase) == true) + { + throw new DllNotFoundException("Please install Svg.Skia from NuGet to load SVG images on .NET Framework.", e); + } +#endif catch (Exception e) { throw new NotSupportedException("Error while reading SVG image format.", e); } } + // Kept separate so a missing SVG assembly surfaces inside DecodeSVG's try block (it fails when this method is JIT-compiled). + [MethodImpl(MethodImplOptions.NoInlining)] + private static SKBitmap RenderSVG(string strInput) + { +#if NETFRAMEWORK + using Svg.Skia.SKSvg svg = new(); +#else + SkiaSharp.Extended.Svg.SKSvg svg = new(); +#endif + _ = svg.Load(strInput); + + SKBitmap toBitmap = new( + (int)svg.Picture.CullRect.Width, + (int)svg.Picture.CullRect.Height); + using (SKCanvas canvas = new(toBitmap)) + { + canvas.Clear(SKColors.White); + canvas.DrawPicture(svg.Picture); + canvas.Flush(); + } + + return toBitmap; + } + private static PlatformNotSupportedException SystemDotDrawingPlatformNotSupported(Exception innerException) { return new PlatformNotSupportedException($"Microsoft has chosen " + @@ -3385,7 +3431,7 @@ private Lazy> OpenImageToImageSharp(bool preserveOriginalFo PreserveOriginalFormat = preserveOriginalFormat; img = Image.Load(Binary); if (Format.Name == "PNG") - img.Mutate(img => img.BackgroundColor(SixLabors.ImageSharp.Color.White)); + img.Mutate(img => img.BackgroundColor(Color.White)); } CorrectImageSharp(img); @@ -3421,15 +3467,15 @@ private void CorrectImageSharp(Image img) // Check if image metadata is accurate already switch (resolutionUnit) { - case SixLabors.ImageSharp.Metadata.PixelResolutionUnit.PixelsPerMeter: + case EnginePixelResolutionUnit.PixelsPerMeter: // Convert metadata of the resolution unit to pixel per inch to match the conversion below of 1 meter = 37.3701 inches - img.Metadata.ResolutionUnits = SixLabors.ImageSharp.Metadata.PixelResolutionUnit.PixelsPerInch; + img.Metadata.ResolutionUnits = EnginePixelResolutionUnit.PixelsPerInch; img.Metadata.HorizontalResolution = Math.Ceiling(horizontal / 39.3701); img.Metadata.VerticalResolution = Math.Ceiling(vertical / 39.3701); break; - case SixLabors.ImageSharp.Metadata.PixelResolutionUnit.PixelsPerCentimeter: + case EnginePixelResolutionUnit.PixelsPerCentimeter: // Convert metadata of the resolution unit to pixel per inch to match the conversion below of 1 inch = 2.54 centimeters - img.Metadata.ResolutionUnits = SixLabors.ImageSharp.Metadata.PixelResolutionUnit.PixelsPerInch; + img.Metadata.ResolutionUnits = EnginePixelResolutionUnit.PixelsPerInch; img.Metadata.HorizontalResolution = Math.Ceiling(horizontal * 2.54); img.Metadata.VerticalResolution = Math.Ceiling(vertical * 2.54); break; @@ -3883,22 +3929,22 @@ private static void InternalSaveAsMultiPageTiff(IEnumerable images, Strea switch (image.Metadata.ResolutionUnits) { - case SixLabors.ImageSharp.Metadata.PixelResolutionUnit.AspectRatio: + case EnginePixelResolutionUnit.AspectRatio: output.SetField(TiffTag.XRESOLUTION, image.Metadata.HorizontalResolution); output.SetField(TiffTag.YRESOLUTION, image.Metadata.VerticalResolution); output.SetField(TiffTag.RESOLUTIONUNIT, ResUnit.NONE); break; - case SixLabors.ImageSharp.Metadata.PixelResolutionUnit.PixelsPerInch: + case EnginePixelResolutionUnit.PixelsPerInch: output.SetField(TiffTag.XRESOLUTION, image.Metadata.HorizontalResolution); output.SetField(TiffTag.YRESOLUTION, image.Metadata.VerticalResolution); output.SetField(TiffTag.RESOLUTIONUNIT, ResUnit.INCH); break; - case SixLabors.ImageSharp.Metadata.PixelResolutionUnit.PixelsPerCentimeter: + case EnginePixelResolutionUnit.PixelsPerCentimeter: output.SetField(TiffTag.XRESOLUTION, image.Metadata.HorizontalResolution); output.SetField(TiffTag.YRESOLUTION, image.Metadata.VerticalResolution); output.SetField(TiffTag.RESOLUTIONUNIT, ResUnit.CENTIMETER); break; - case SixLabors.ImageSharp.Metadata.PixelResolutionUnit.PixelsPerMeter: + case EnginePixelResolutionUnit.PixelsPerMeter: output.SetField(TiffTag.XRESOLUTION, image.Metadata.HorizontalResolution / 100); output.SetField(TiffTag.YRESOLUTION, image.Metadata.VerticalResolution / 100); output.SetField(TiffTag.RESOLUTIONUNIT, ResUnit.CENTIMETER); diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/Color.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/Color.cs index 2bf4492e..4c2415da 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common/Color.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/Color.cs @@ -1048,6 +1048,7 @@ public static implicit operator SkiaSharp.SKColor(Color color) return new SkiaSharp.SKColor(color.R, color.G, color.B, color.A); } +#if !NETFRAMEWORK /// /// Implicitly casts objects to . /// When your .NET Class methods use as parameters or return types, you now automatically support as well. @@ -1058,7 +1059,9 @@ public static implicit operator Color(SixLabors.ImageSharp.Color color) string hex = color.ToHex(); return new Color(ConvertToHexNumberByte(hex, 6, 2), ConvertToHexNumberByte(hex, 0, 2), ConvertToHexNumberByte(hex, 2, 2), ConvertToHexNumberByte(hex, 4, 2)); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts to objects from . /// When your .NET Class methods use as parameters or return types, you now automatically support as well. @@ -1068,7 +1071,9 @@ public static implicit operator SixLabors.ImageSharp.Color(Color color) { return SixLabors.ImageSharp.Color.FromRgba(color.R, color.G, color.B, color.A); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts objects to . /// When your .NET Class methods use as parameters or return types, you now automatically support as well. @@ -1079,7 +1084,9 @@ public static implicit operator Color(SixLabors.ImageSharp.PixelFormats.Rgba32 c string hex = color.ToHex(); // Rgba return new Color(ConvertToHexNumberByte(hex, 6, 2), ConvertToHexNumberByte(hex, 0, 2), ConvertToHexNumberByte(hex, 2, 2), ConvertToHexNumberByte(hex, 4, 2)); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts to objects from . /// When your .NET Class methods use as parameters or return types, you now automatically support as well. @@ -1089,7 +1096,9 @@ public static implicit operator SixLabors.ImageSharp.PixelFormats.Rgba32(Color c { return SixLabors.ImageSharp.Color.FromRgba(color.R, color.G, color.B, color.A); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts objects to . /// When your .NET Class methods use as parameters or return types, you now automatically support as well. @@ -1099,7 +1108,9 @@ public static implicit operator Color(SixLabors.ImageSharp.PixelFormats.Bgra32 c { return new Color(color.A, color.R, color.G, color.B); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts to objects from . /// When your .NET Class methods use as parameters or return types, you now automatically support as well. @@ -1109,7 +1120,9 @@ public static implicit operator SixLabors.ImageSharp.PixelFormats.Bgra32(Color c { return SixLabors.ImageSharp.Color.FromRgba(color.R, color.G, color.B, color.A); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts objects to . /// When your .NET Class methods use as parameters or return types, you now automatically support as well. @@ -1119,7 +1132,9 @@ public static implicit operator Color(SixLabors.ImageSharp.PixelFormats.Rgb24 co { return new Color(color.R, color.G, color.B); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts to objects from . /// When your .NET Class methods use as parameters or return types, you now automatically support as well. @@ -1129,7 +1144,9 @@ public static implicit operator SixLabors.ImageSharp.PixelFormats.Rgb24(Color co { return SixLabors.ImageSharp.Color.FromRgba(color.R, color.G, color.B, color.A); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts objects to . /// When your .NET Class methods use as parameters or return types, you now automatically support as well. @@ -1139,7 +1156,9 @@ public static implicit operator Color(SixLabors.ImageSharp.PixelFormats.Bgr24 co { return new Color(color.R, color.G, color.B); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts to objects from . /// When your .NET Class methods use as parameters or return types, you now automatically support as well. @@ -1149,7 +1168,9 @@ public static implicit operator SixLabors.ImageSharp.PixelFormats.Bgr24(Color co { return SixLabors.ImageSharp.Color.FromRgb(color.R, color.G, color.B); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts objects to . /// When your .NET Class methods use as parameters or return types, you now automatically support as well. @@ -1159,7 +1180,9 @@ public static implicit operator Color(SixLabors.ImageSharp.PixelFormats.Rgb48 co { return (Color)SixLabors.ImageSharp.Color.FromRgb((byte)(color.R >> 8), (byte)(color.G >> 8), (byte)(color.B >> 8)); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts to objects from . /// When your .NET Class methods use as parameters or return types, you now automatically support as well. @@ -1171,7 +1194,9 @@ public static implicit operator SixLabors.ImageSharp.PixelFormats.Rgb48(Color co result.FromRgba64((SixLabors.ImageSharp.PixelFormats.Rgba64)color); return result; } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts objects to . /// When your .NET Class methods use as parameters or return types, you now automatically support as well. @@ -1181,7 +1206,9 @@ public static implicit operator Color(SixLabors.ImageSharp.PixelFormats.Rgba64 c { return new Color(color.A, color.R, color.G, color.B); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts to objects from . /// When your .NET Class methods use as parameters or return types, you now automatically support as well. @@ -1191,7 +1218,9 @@ public static implicit operator SixLabors.ImageSharp.PixelFormats.Rgba64(Color c { return SixLabors.ImageSharp.Color.FromRgba(color.R, color.G, color.B, color.A); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts objects to . /// When your .NET Class methods use as parameters or return types, you now automatically support as well. @@ -1201,7 +1230,9 @@ public static implicit operator Color(SixLabors.ImageSharp.PixelFormats.Abgr32 c { return new Color(color.A, color.R, color.G, color.B); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts to objects from . /// When your .NET Class methods use as parameters or return types, you now automatically support as well. @@ -1211,7 +1242,9 @@ public static implicit operator SixLabors.ImageSharp.PixelFormats.Abgr32(Color c { return SixLabors.ImageSharp.Color.FromRgba(color.R, color.G, color.B, color.A); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts objects to . /// When your .NET Class methods use as parameters or return types, you now automatically support as well. @@ -1221,7 +1254,9 @@ public static implicit operator Color(SixLabors.ImageSharp.PixelFormats.Argb32 c { return new Color(color.A, color.R, color.G, color.B); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts to objects from . /// When your .NET Class methods use as parameters or return types, you now automatically support as well. @@ -1231,6 +1266,7 @@ public static implicit operator SixLabors.ImageSharp.PixelFormats.Argb32(Color c { return SixLabors.ImageSharp.Color.FromRgba(color.R, color.G, color.B, color.A); } +#endif /// /// Implicitly casts objects to . diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/IronSoftware.Drawing.Common.csproj b/IronSoftware.Drawing/IronSoftware.Drawing.Common/IronSoftware.Drawing.Common.csproj index cb0380ed..89ff3105 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common/IronSoftware.Drawing.Common.csproj +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/IronSoftware.Drawing.Common.csproj @@ -12,7 +12,7 @@ CS8002 AnyCPU true - netstandard2.0;net60 + netstandard2.0;net462;net60 true @@ -26,24 +26,40 @@ - - + + + + + + + + + + + + + diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/Legacy/CropRectangle.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/Legacy/CropRectangle.cs index 5bc12f32..0332340d 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common/Legacy/CropRectangle.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/Legacy/CropRectangle.cs @@ -201,6 +201,7 @@ public static implicit operator SkiaSharp.SKRectI(CropRectangle cropRectangle) return SkiaSharp.SKRectI.Create(cropRectangle.X, cropRectangle.Y, cropRectangle.Width, cropRectangle.Height); } +#if !NETFRAMEWORK /// /// Implicitly casts SixLabors.ImageSharp.Rectangle objects to . /// When your .NET Class methods use as parameters and return types, you now automatically support SixLabors.ImageSharp.Rectangle as well. @@ -210,7 +211,9 @@ public static implicit operator CropRectangle(SixLabors.ImageSharp.Rectangle rec { return new CropRectangle(rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts to SixLabors.ImageSharp.Rectangle objects from . /// When your .NET Class methods use as parameters and return types, you now automatically support SixLabors.ImageSharp.Rectangle as well. @@ -220,7 +223,9 @@ public static implicit operator SixLabors.ImageSharp.Rectangle(CropRectangle cro { return new SixLabors.ImageSharp.Rectangle(cropRectangle.X, cropRectangle.Y, cropRectangle.Width, cropRectangle.Height); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts SixLabors.ImageSharp.RectangleF objects to . /// When your .NET Class methods use as parameters and return types, you now automatically support SixLabors.ImageSharp.RectangleF as well. @@ -230,7 +235,9 @@ public static implicit operator CropRectangle(SixLabors.ImageSharp.RectangleF re { return new CropRectangle((int)rectangle.X, (int)rectangle.Y, (int)rectangle.Width, (int)rectangle.Height); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts to SixLabors.ImageSharp.RectangleF objects from . /// When your .NET Class methods use as parameters and return types, you now automatically support SixLabors.ImageSharp.RectangleF as well. @@ -240,6 +247,7 @@ public static implicit operator SixLabors.ImageSharp.RectangleF(CropRectangle cr { return new SixLabors.ImageSharp.RectangleF(cropRectangle.X, cropRectangle.Y, cropRectangle.Width, cropRectangle.Height); } +#endif /// /// Implicitly casts Microsoft.Maui.Graphics.Rect objects to . diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/Point.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/Point.cs index a0a9caf9..77755c3b 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common/Point.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/Point.cs @@ -40,6 +40,7 @@ public void Offset(int dx, int dy) #region Implicit Operators +#if !NETFRAMEWORK /// /// Implicitly casts SixLabors.ImageSharp.Point objects to Point /// @@ -48,7 +49,9 @@ public static implicit operator Point(SixLabors.ImageSharp.Point point) { return new Point(point.X, point.Y); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts Point objects to SixLabors.ImageSharp.Point /// @@ -57,6 +60,7 @@ public static implicit operator SixLabors.ImageSharp.Point(Point point) { return new SixLabors.ImageSharp.Point(point.X, point.Y); } +#endif /// /// Implicitly casts System.Drawing.Point objects to . diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/PointF.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/PointF.cs index 710201ff..b559db15 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common/PointF.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/PointF.cs @@ -40,6 +40,7 @@ public void Offset(float dx, float dy) #region Implicit Operators +#if !NETFRAMEWORK /// /// Implicitly casts SixLabors.ImageSharp.PointF objects to PointF /// @@ -48,7 +49,9 @@ public static implicit operator PointF(SixLabors.ImageSharp.PointF point) { return new PointF(point.X, point.Y); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts a PointF object to SixLabors.ImageSharp.PointF /// @@ -57,6 +60,7 @@ public static implicit operator SixLabors.ImageSharp.PointF(PointF point) { return new SixLabors.ImageSharp.PointF(point.X, point.Y); } +#endif /// /// Implicitly casts Microsoft.Maui.Graphics.Point objects to PointF diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/Rectangle.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/Rectangle.cs index fe8c3456..a7f8fd3a 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common/Rectangle.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/Rectangle.cs @@ -231,6 +231,7 @@ public static implicit operator SkiaSharp.SKRectI(Rectangle Rectangle) return SkiaSharp.SKRectI.Create(Rectangle.X, Rectangle.Y, Rectangle.Width, Rectangle.Height); } +#if !NETFRAMEWORK /// /// Implicitly casts SixLabors.ImageSharp.Rectangle objects to . /// When your .NET Class methods use as parameters and return types, you now automatically support SixLabors.ImageSharp.Rectangle as well. @@ -240,7 +241,9 @@ public static implicit operator Rectangle(SixLabors.ImageSharp.Rectangle rectang { return new Rectangle(rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts to SixLabors.ImageSharp.Rectangle objects from . /// When your .NET Class methods use as parameters and return types, you now automatically support SixLabors.ImageSharp.Rectangle as well. @@ -250,7 +253,9 @@ public static implicit operator SixLabors.ImageSharp.Rectangle(Rectangle Rectang { return new SixLabors.ImageSharp.Rectangle(Rectangle.X, Rectangle.Y, Rectangle.Width, Rectangle.Height); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts SixLabors.ImageSharp.RectangleF objects to . /// When your .NET Class methods use as parameters and return types, you now automatically support SixLabors.ImageSharp.RectangleF as well. @@ -260,7 +265,9 @@ public static implicit operator Rectangle(SixLabors.ImageSharp.RectangleF rectan { return new Rectangle((int)rectangle.X, (int)rectangle.Y, (int)rectangle.Width, (int)rectangle.Height); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts to SixLabors.ImageSharp.RectangleF objects from . /// When your .NET Class methods use as parameters and return types, you now automatically support SixLabors.ImageSharp.RectangleF as well. @@ -270,6 +277,7 @@ public static implicit operator SixLabors.ImageSharp.RectangleF(Rectangle Rectan { return new SixLabors.ImageSharp.RectangleF(Rectangle.X, Rectangle.Y, Rectangle.Width, Rectangle.Height); } +#endif /// /// Implicitly casts Microsoft.Maui.Graphics.Rect objects to . diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/RectangleF.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/RectangleF.cs index ead79fc6..75faad2a 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common/RectangleF.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/RectangleF.cs @@ -200,6 +200,7 @@ public static implicit operator SkiaSharp.SKRect(RectangleF RectangleF) return SkiaSharp.SKRect.Create(RectangleF.X, RectangleF.Y, RectangleF.Width, RectangleF.Height); } +#if !NETFRAMEWORK /// /// Implicitly casts SixLabors.ImageSharp.RectangleF objects to . /// When your .NET Class methods use as parameters and return types, you now automatically support SixLabors.ImageSharp.RectangleF as well. @@ -209,7 +210,9 @@ public static implicit operator RectangleF(SixLabors.ImageSharp.RectangleF Recta { return new RectangleF(RectangleF.X, RectangleF.Y, RectangleF.Width, RectangleF.Height); } +#endif +#if !NETFRAMEWORK /// /// Implicitly casts to SixLabors.ImageSharp.RectangleF objects from . /// When your .NET Class methods use as parameters and return types, you now automatically support SixLabors.ImageSharp.RectangleF as well. @@ -219,6 +222,7 @@ public static implicit operator SixLabors.ImageSharp.RectangleF(RectangleF Recta { return new SixLabors.ImageSharp.RectangleF(RectangleF.X, RectangleF.Y, RectangleF.Width, RectangleF.Height); } +#endif /// /// Implicitly casts Microsoft.Maui.Graphics.Rect objects to . diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/Size.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/Size.cs index 488d71e3..e8dde467 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common/Size.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/Size.cs @@ -195,6 +195,7 @@ public Size(Point point) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool operator !=(Size left, Size right) => !left.Equals(right); +#if !NETFRAMEWORK /// /// Convert a type to a type. /// @@ -203,7 +204,9 @@ public static implicit operator Size(SixLabors.ImageSharp.Size v) { return new Size(v.Width, v.Height); } +#endif +#if !NETFRAMEWORK /// /// Convert to a type. /// @@ -212,6 +215,7 @@ public static implicit operator SixLabors.ImageSharp.Size(Size v) { return new SixLabors.ImageSharp.Size(v.Width, v.Height); } +#endif /// /// Convert a type to a type. diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/SizeF.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/SizeF.cs index 0650543c..1a09b564 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common/SizeF.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/SizeF.cs @@ -183,6 +183,7 @@ public static implicit operator System.Drawing.SizeF(SizeF v) return new System.Drawing.SizeF(v.Width, v.Height); } +#if !NETFRAMEWORK /// /// Convert a type to a type. /// @@ -191,7 +192,9 @@ public static implicit operator SizeF(SixLabors.ImageSharp.SizeF v) { return new SizeF(v.Width, v.Height); } +#endif +#if !NETFRAMEWORK /// /// Convert to a type. /// @@ -200,6 +203,7 @@ public static implicit operator SixLabors.ImageSharp.SizeF(SizeF v) { return new SixLabors.ImageSharp.SizeF(v.Width, v.Height); } +#endif /// /// Convert a type to a type. diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/Encoders.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/Encoders.cs new file mode 100644 index 00000000..d2794db6 --- /dev/null +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/Encoders.cs @@ -0,0 +1,695 @@ +#if NETFRAMEWORK +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Runtime.InteropServices; +using SkiaSharp; + +namespace IronSoftware.Drawing.SkiaEngine +{ + internal interface IImageEncoder + { + void Encode(Image image, Stream stream); + } + + internal enum JpegColorType + { + YCbCrRatio420 = 0, + YCbCrRatio444 = 1, + Luminance = 2, + Rgb = 3, + } + + internal enum BmpBitsPerPixel + { + Pixel24 = 24, + Pixel32 = 32, + } + + // Values match the TIFF specification (and ImageSharp's TiffCompression). + internal enum TiffCompression + { + Invalid = 0, + None = 1, + Ccitt1D = 2, + CcittGroup3Fax = 3, + CcittGroup4Fax = 4, + Lzw = 5, + OldJpeg = 6, + Jpeg = 7, + Deflate = 8, + ItuTRecT82 = 9, + ItuTRecT43 = 10, + NeXT = 32766, + PackBits = 32773, + ThunderScan = 32809, + OldDeflate = 32946, + } + + internal static class ResolutionConverter + { + public static double ToPixelsPerMeter(double value, PixelResolutionUnit unit) => unit switch + { + PixelResolutionUnit.PixelsPerInch => value / 0.0254, + PixelResolutionUnit.PixelsPerCentimeter => value * 100, + _ => value, + }; + + public static double ToPixelsPerInch(double value, PixelResolutionUnit unit) => unit switch + { + PixelResolutionUnit.PixelsPerCentimeter => value * 2.54, + PixelResolutionUnit.PixelsPerMeter => value * 0.0254, + _ => value, + }; + } + + internal sealed class JpegEncoder : IImageEncoder + { + public int? Quality { get; set; } + public JpegColorType? ColorType { get; set; } + + public void Encode(Image image, Stream stream) + { + // ImageSharp's JpegColorType.Rgb / YCbCrRatio444 encode without chroma subsampling. + bool fullChroma = ColorType == JpegColorType.Rgb || ColorType == JpegColorType.YCbCrRatio444; + var options = new SKJpegEncoderOptions( + Math.Max(1, Math.Min(100, Quality ?? 75)), + fullChroma ? SKJpegEncoderDownsample.Downsample444 : SKJpegEncoderDownsample.Downsample420, + SKJpegEncoderAlphaOption.Ignore); + + byte[] encoded = SkiaEncoding.Encode(image, pixmap => pixmap.Encode(options)); + encoded = JfifDensity.Apply(encoded, image.Metadata); + stream.Write(encoded, 0, encoded.Length); + } + } + + internal sealed class WebpEncoder : IImageEncoder + { + public int Quality { get; set; } = 75; + + public void Encode(Image image, Stream stream) + { + var options = new SKWebpEncoderOptions(SKWebpEncoderCompression.Lossy, Math.Max(0, Math.Min(100, Quality))); + + byte[] encoded = SkiaEncoding.Encode(image, pixmap => pixmap.Encode(options)); + stream.Write(encoded, 0, encoded.Length); + } + } + + internal sealed class TiffEncoder : IImageEncoder + { + public TiffCompression? Compression { get; set; } + + // AnyBitmap writes TIFF through BitMiracle.LibTiff (InternalSaveAsMultiPageTiff); this encoder + // only identifies the requested format. + public void Encode(Image image, Stream stream) => + throw new NotSupportedException("TIFF is written by AnyBitmap through BitMiracle.LibTiff."); + } + + internal static class SkiaEncoding + { + public static byte[] Encode(Image image, Func encode) + { + Rgba32[] pixels = image.GetFrameRgba32(0); + GCHandle handle = GCHandle.Alloc(pixels, GCHandleType.Pinned); + try + { + var info = new SKImageInfo(image.Width, image.Height, SKColorType.Rgba8888, SKAlphaType.Unpremul); + using var pixmap = new SKPixmap(info, handle.AddrOfPinnedObject(), image.Width * 4); + using SKData data = encode(pixmap) ?? throw new NotSupportedException("SkiaSharp could not encode the image."); + return data.ToArray(); + } + finally + { + handle.Free(); + } + } + } + + /// Writes the image resolution into the JPEG JFIF APP0 segment (SkiaSharp writes a fixed one). + internal static class JfifDensity + { + public static byte[] Apply(byte[] jpeg, ImageMetadata metadata) + { + byte units; + double x = metadata.HorizontalResolution, y = metadata.VerticalResolution; + switch (metadata.ResolutionUnits) + { + case PixelResolutionUnit.AspectRatio: units = 0; break; + case PixelResolutionUnit.PixelsPerCentimeter: units = 2; break; + case PixelResolutionUnit.PixelsPerMeter: units = 1; x = ResolutionConverter.ToPixelsPerInch(x, PixelResolutionUnit.PixelsPerMeter); y = ResolutionConverter.ToPixelsPerInch(y, PixelResolutionUnit.PixelsPerMeter); break; + default: units = 1; break; + } + + ushort xd = (ushort)Math.Max(1, Math.Min(ushort.MaxValue, Math.Round(x))); + ushort yd = (ushort)Math.Max(1, Math.Min(ushort.MaxValue, Math.Round(y))); + + if (jpeg.Length > 20 && jpeg[2] == 0xFF && jpeg[3] == 0xE0 && jpeg[6] == 'J' && jpeg[7] == 'F' && jpeg[8] == 'I' && jpeg[9] == 'F') + { + jpeg[13] = units; + jpeg[14] = (byte)(xd >> 8); jpeg[15] = (byte)xd; + jpeg[16] = (byte)(yd >> 8); jpeg[17] = (byte)yd; + return jpeg; + } + + byte[] app0 = { 0xFF, 0xE0, 0x00, 0x10, (byte)'J', (byte)'F', (byte)'I', (byte)'F', 0x00, 0x01, 0x01, units, (byte)(xd >> 8), (byte)xd, (byte)(yd >> 8), (byte)yd, 0x00, 0x00 }; + var result = new byte[jpeg.Length + app0.Length]; + Array.Copy(jpeg, 0, result, 0, 2); + Array.Copy(app0, 0, result, 2, app0.Length); + Array.Copy(jpeg, 2, result, 2 + app0.Length, jpeg.Length - 2); + return result; + } + } + + /// BMP writer (32bpp BGRA with an alpha bitfield header, or 24bpp). + internal sealed class BmpEncoder : IImageEncoder + { + public BmpBitsPerPixel? BitsPerPixel { get; set; } + public bool SupportTransparency { get; set; } + + public void Encode(Image image, Stream stream) + { + int width = image.Width, height = image.Height; + bool is32 = BitsPerPixel != BmpBitsPerPixel.Pixel24; + bool alphaHeader = is32 && SupportTransparency; + int infoHeaderSize = alphaHeader ? 108 : 40; + int bytesPerPixel = is32 ? 4 : 3; + int stride = ((width * bytesPerPixel) + 3) & ~3; + int imageSize = stride * height; + int offset = 14 + infoHeaderSize; + + int xPpm = (int)Math.Round(ResolutionConverter.ToPixelsPerMeter(image.Metadata.HorizontalResolution, image.Metadata.ResolutionUnits)); + int yPpm = (int)Math.Round(ResolutionConverter.ToPixelsPerMeter(image.Metadata.VerticalResolution, image.Metadata.ResolutionUnits)); + + using var writer = new BinaryWriter(stream, System.Text.Encoding.ASCII, leaveOpen: true); + writer.Write((byte)'B'); writer.Write((byte)'M'); + writer.Write(offset + imageSize); + writer.Write(0); + writer.Write(offset); + + writer.Write(infoHeaderSize); + writer.Write(width); + writer.Write(height); // positive: bottom-up rows + writer.Write((short)1); + writer.Write((short)(bytesPerPixel * 8)); + writer.Write(alphaHeader ? 3 : 0); // BI_BITFIELDS : BI_RGB + writer.Write(imageSize); + writer.Write(xPpm); + writer.Write(yPpm); + writer.Write(0); + writer.Write(0); + if (alphaHeader) + { + writer.Write(0x00FF0000); // red mask + writer.Write(0x0000FF00); // green mask + writer.Write(0x000000FF); // blue mask + writer.Write(unchecked((int)0xFF000000)); // alpha mask + writer.Write(0x73524742); // 'sRGB' + writer.Write(new byte[36]); // endpoints + writer.Write(new byte[12]); // gamma + } + + Rgba32[] pixels = image.GetFrameRgba32(0); + var row = new byte[stride]; + for (int y = height - 1; y >= 0; y--) + { + int source = y * width; + for (int x = 0; x < width; x++) + { + Rgba32 p = pixels[source + x]; + int o = x * bytesPerPixel; + row[o] = p.B; + row[o + 1] = p.G; + row[o + 2] = p.R; + if (is32) row[o + 3] = p.A; + } + + writer.Write(row); + } + } + } + + /// PNG writer that keeps the image's pixel layout (gray/RGB/RGBA, 8 or 16 bits), like ImageSharp. + internal sealed class PngEncoder : IImageEncoder + { + private static readonly uint[] CrcTable = BuildCrcTable(); + + public void Encode(Image image, Stream stream) + { + int width = image.Width, height = image.Height; + (byte colorType, int channels, bool sixteen) = image switch + { + Image => ((byte)0, 1, false), + Image => ((byte)0, 1, true), + Image => ((byte)4, 2, false), + Image => ((byte)4, 2, true), + Image or Image => ((byte)2, 3, false), + Image => ((byte)2, 3, true), + Image => ((byte)6, 4, true), + _ => ((byte)6, 4, false), + }; + + int bytesPerPixel = channels * (sixteen ? 2 : 1); + int stride = width * bytesPerPixel; + byte[] raw = BuildRows(image, channels, sixteen, stride); + + stream.Write(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }, 0, 8); + + var ihdr = new byte[13]; + WriteInt32BE(ihdr, 0, width); + WriteInt32BE(ihdr, 4, height); + ihdr[8] = (byte)(sixteen ? 16 : 8); + ihdr[9] = colorType; + WriteChunk(stream, "IHDR", ihdr); + + var phys = new byte[9]; + bool aspect = image.Metadata.ResolutionUnits == PixelResolutionUnit.AspectRatio; + WriteInt32BE(phys, 0, (int)Math.Round(aspect ? image.Metadata.HorizontalResolution : ResolutionConverter.ToPixelsPerMeter(image.Metadata.HorizontalResolution, image.Metadata.ResolutionUnits))); + WriteInt32BE(phys, 4, (int)Math.Round(aspect ? image.Metadata.VerticalResolution : ResolutionConverter.ToPixelsPerMeter(image.Metadata.VerticalResolution, image.Metadata.ResolutionUnits))); + phys[8] = (byte)(aspect ? 0 : 1); + WriteChunk(stream, "pHYs", phys); + + WriteChunk(stream, "IDAT", Zlib(Filter(raw, height, stride, bytesPerPixel))); + WriteChunk(stream, "IEND", Array.Empty()); + } + + private static byte[] BuildRows(Image image, int channels, bool sixteen, int stride) + { + int width = image.Width, height = image.Height; + var raw = new byte[stride * height]; + if (sixteen) + { + Rgba64[] pixels = image.GetFrameRgba64(0); + int o = 0; + foreach (Rgba64 p in pixels) + { + if (channels <= 2) { WriteUInt16BE(raw, o, PixelMath.Luminance16(p.R, p.G, p.B)); o += 2; } + else { WriteUInt16BE(raw, o, p.R); WriteUInt16BE(raw, o + 2, p.G); WriteUInt16BE(raw, o + 4, p.B); o += 6; } + if (channels is 2 or 4) { WriteUInt16BE(raw, o, p.A); o += 2; } + } + } + else + { + Rgba32[] pixels = image.GetFrameRgba32(0); + int o = 0; + foreach (Rgba32 p in pixels) + { + if (channels <= 2) { raw[o++] = PixelMath.Luminance8(p.R, p.G, p.B); } + else { raw[o++] = p.R; raw[o++] = p.G; raw[o++] = p.B; } + if (channels is 2 or 4) { raw[o++] = p.A; } + } + } + + return raw; + } + + /// Adaptive filtering: per row, the filter with the smallest sum of absolute values. + private static byte[] Filter(byte[] raw, int height, int stride, int bpp) + { + var output = new byte[(stride + 1) * height]; + var candidate = new byte[5][]; + for (int i = 0; i < 5; i++) candidate[i] = new byte[stride]; + + for (int y = 0; y < height; y++) + { + int row = y * stride; + int prior = row - stride; + for (int x = 0; x < stride; x++) + { + int a = x >= bpp ? raw[row + x - bpp] : 0; + int b = y > 0 ? raw[prior + x] : 0; + int c = x >= bpp && y > 0 ? raw[prior + x - bpp] : 0; + int value = raw[row + x]; + candidate[0][x] = (byte)value; + candidate[1][x] = (byte)(value - a); + candidate[2][x] = (byte)(value - b); + candidate[3][x] = (byte)(value - ((a + b) >> 1)); + candidate[4][x] = (byte)(value - Paeth(a, b, c)); + } + + int best = 0; + long bestSum = long.MaxValue; + for (int f = 0; f < 5; f++) + { + long sum = 0; + byte[] data = candidate[f]; + for (int x = 0; x < stride; x++) sum += data[x] < 128 ? data[x] : 256 - data[x]; + if (sum < bestSum) { bestSum = sum; best = f; } + } + + int o = y * (stride + 1); + output[o] = (byte)best; + Array.Copy(candidate[best], 0, output, o + 1, stride); + } + + return output; + } + + private static int Paeth(int a, int b, int c) + { + int p = a + b - c; + int pa = Math.Abs(p - a), pb = Math.Abs(p - b), pc = Math.Abs(p - c); + return pa <= pb && pa <= pc ? a : pb <= pc ? b : c; + } + + private static byte[] Zlib(byte[] data) + { + using var output = new MemoryStream(); + output.WriteByte(0x78); + output.WriteByte(0xDA); + using (var deflate = new DeflateStream(output, CompressionLevel.Optimal, leaveOpen: true)) + { + deflate.Write(data, 0, data.Length); + } + + uint a = 1, b = 0; + foreach (byte value in data) + { + a = (a + value) % 65521; + b = (b + a) % 65521; + } + + uint adler = (b << 16) | a; + output.WriteByte((byte)(adler >> 24)); + output.WriteByte((byte)(adler >> 16)); + output.WriteByte((byte)(adler >> 8)); + output.WriteByte((byte)adler); + return output.ToArray(); + } + + private static void WriteChunk(Stream stream, string type, byte[] data) + { + var header = new byte[8]; + WriteInt32BE(header, 0, data.Length); + for (int i = 0; i < 4; i++) header[4 + i] = (byte)type[i]; + stream.Write(header, 0, 8); + stream.Write(data, 0, data.Length); + + uint crc = 0xFFFFFFFF; + for (int i = 4; i < 8; i++) crc = CrcTable[(crc ^ header[i]) & 0xFF] ^ (crc >> 8); + foreach (byte value in data) crc = CrcTable[(crc ^ value) & 0xFF] ^ (crc >> 8); + crc ^= 0xFFFFFFFF; + var crcBytes = new byte[4]; + WriteInt32BE(crcBytes, 0, unchecked((int)crc)); + stream.Write(crcBytes, 0, 4); + } + + private static uint[] BuildCrcTable() + { + var table = new uint[256]; + for (uint n = 0; n < 256; n++) + { + uint c = n; + for (int k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1; + table[n] = c; + } + + return table; + } + + private static void WriteInt32BE(byte[] d, int o, int v) { d[o] = (byte)(v >> 24); d[o + 1] = (byte)(v >> 16); d[o + 2] = (byte)(v >> 8); d[o + 3] = (byte)v; } + private static void WriteUInt16BE(byte[] d, int o, ushort v) { d[o] = (byte)(v >> 8); d[o + 1] = (byte)v; } + } + + /// GIF89a writer: every frame, octree-quantised to a local palette, with transparency and looping. + internal sealed class GifEncoder : IImageEncoder + { + public void Encode(Image image, Stream stream) + { + int width = image.Width, height = image.Height; + using var writer = new BinaryWriter(stream, System.Text.Encoding.ASCII, leaveOpen: true); + writer.Write(System.Text.Encoding.ASCII.GetBytes("GIF89a")); + writer.Write((ushort)width); + writer.Write((ushort)height); + writer.Write((byte)0x70); // no global colour table, 8-bit colour resolution + writer.Write((byte)0); // background colour index + writer.Write((byte)0); // pixel aspect ratio + + int frameCount = image.FrameCount; + if (frameCount > 1) + { + writer.Write(new byte[] { 0x21, 0xFF, 0x0B }); + writer.Write(System.Text.Encoding.ASCII.GetBytes("NETSCAPE2.0")); + writer.Write(new byte[] { 0x03, 0x01, 0x00, 0x00, 0x00 }); // loop forever + } + + for (int f = 0; f < frameCount; f++) + { + Rgba32[] pixels = image.GetFrameRgba32(f); + var quantizer = new OctreeQuantizer(pixels, maxColors: 255); + byte[] indices = quantizer.Map(pixels, out Rgba32[] palette, out int transparentIndex); + + int paletteBits = 1; + while ((1 << paletteBits) < palette.Length) paletteBits++; + int tableSize = 1 << paletteBits; + + int delay = image.Metadata.FrameDelays != null && f < image.Metadata.FrameDelays.Count ? image.Metadata.FrameDelays[f] : 0; + writer.Write(new byte[] { 0x21, 0xF9, 0x04 }); + writer.Write((byte)((transparentIndex >= 0 ? 0x01 : 0x00) | (frameCount > 1 ? 0x08 : 0x00))); // disposal: restore to background + writer.Write((ushort)delay); + writer.Write((byte)Math.Max(0, transparentIndex)); + writer.Write((byte)0); + + writer.Write((byte)0x2C); + writer.Write((ushort)0); + writer.Write((ushort)0); + writer.Write((ushort)width); + writer.Write((ushort)height); + writer.Write((byte)(0x80 | (paletteBits - 1))); // local colour table + + for (int i = 0; i < tableSize; i++) + { + Rgba32 c = i < palette.Length ? palette[i] : default; + writer.Write(c.R); writer.Write(c.G); writer.Write(c.B); + } + + int minimumCodeSize = Math.Max(2, paletteBits); + writer.Write((byte)minimumCodeSize); + LzwEncode(writer, indices, minimumCodeSize); + } + + writer.Write((byte)0x3B); + } + + private static void LzwEncode(BinaryWriter writer, byte[] indices, int minimumCodeSize) + { + int clearCode = 1 << minimumCodeSize; + int endCode = clearCode + 1; + int codeSize = minimumCodeSize + 1; + int nextCode = endCode + 1; + var table = new Dictionary(); + + var block = new byte[255]; + int blockLength = 0; + int bitBuffer = 0, bitCount = 0; + + void Emit(int code) + { + bitBuffer |= code << bitCount; + bitCount += codeSize; + while (bitCount >= 8) + { + block[blockLength++] = (byte)bitBuffer; + bitBuffer >>= 8; + bitCount -= 8; + if (blockLength == 255) { writer.Write((byte)255); writer.Write(block, 0, 255); blockLength = 0; } + } + } + + Emit(clearCode); + if (indices.Length > 0) + { + int prefix = indices[0]; + for (int i = 1; i < indices.Length; i++) + { + int key = (prefix << 8) | indices[i]; + if (table.TryGetValue(key, out int code)) + { + prefix = code; + continue; + } + + Emit(prefix); + + // Grow the code size after emitting, based on the entries added so far (as in the + // classic 'compress' encoder); decoders add each entry one code later. + if (nextCode > (1 << codeSize) - 1 && codeSize < 12) codeSize++; + + if (nextCode < 4096) + { + table[key] = nextCode++; + } + else + { + Emit(clearCode); + table.Clear(); + codeSize = minimumCodeSize + 1; + nextCode = endCode + 1; + } + + prefix = indices[i]; + } + + Emit(prefix); + if (nextCode > (1 << codeSize) - 1 && codeSize < 12) codeSize++; + } + + Emit(endCode); + if (bitCount > 0) { block[blockLength++] = (byte)bitBuffer; if (blockLength == 255) { writer.Write((byte)255); writer.Write(block, 0, 255); blockLength = 0; } } + if (blockLength > 0) { writer.Write((byte)blockLength); writer.Write(block, 0, blockLength); } + writer.Write((byte)0); // block terminator + } + } + + /// Octree colour quantiser; pixels with alpha below 128 map to a transparent palette entry. + internal sealed class OctreeQuantizer + { + private sealed class Node + { + public readonly Node[] Children = new Node[8]; + public bool IsLeaf; + public long R, G, B; + public int Count; + public int PaletteIndex; + public Node NextReducible; + } + + private readonly Node _root = new(); + private readonly Node[] _reducible = new Node[8]; + private int _leafCount; + private readonly int _maxColors; + + public OctreeQuantizer(Rgba32[] pixels, int maxColors) + { + _maxColors = maxColors; + foreach (Rgba32 p in pixels) + { + if (p.A < 128) continue; + Add(_root, p, 0); + while (_leafCount > _maxColors) Reduce(); + } + } + + public byte[] Map(Rgba32[] pixels, out Rgba32[] palette, out int transparentIndex) + { + var colors = new List(); + BuildPalette(_root, colors); + bool hasTransparency = Array.Exists(pixels, p => p.A < 128); + transparentIndex = -1; + if (hasTransparency || colors.Count == 0) + { + transparentIndex = colors.Count; + colors.Add(new Rgba32(0, 0, 0, 0)); + } + + palette = colors.ToArray(); + var indices = new byte[pixels.Length]; + for (int i = 0; i < pixels.Length; i++) + { + indices[i] = pixels[i].A < 128 ? (byte)transparentIndex : (byte)Lookup(pixels[i]); + } + + return indices; + } + + private static int Index(Rgba32 p, int level) + { + int shift = 7 - level; + return (((p.R >> shift) & 1) << 2) | (((p.G >> shift) & 1) << 1) | ((p.B >> shift) & 1); + } + + private void Add(Node node, Rgba32 p, int level) + { + if (node.IsLeaf || level == 8) + { + if (!node.IsLeaf) { node.IsLeaf = true; _leafCount++; } + node.R += p.R; node.G += p.G; node.B += p.B; node.Count++; + return; + } + + int index = Index(p, level); + Node child = node.Children[index]; + if (child == null) + { + child = new Node(); + node.Children[index] = child; + if (level + 1 < 8) + { + child.NextReducible = _reducible[level + 1]; + _reducible[level + 1] = child; + } + } + + Add(child, p, level + 1); + } + + private void Reduce() + { + int level = 7; + while (level > 0 && _reducible[level] == null) level--; + Node node = _reducible[level]; + if (node == null) return; + _reducible[level] = node.NextReducible; + + int removed = 0; + for (int i = 0; i < 8; i++) + { + Node child = node.Children[i]; + if (child == null) continue; + node.R += child.R; node.G += child.G; node.B += child.B; node.Count += child.Count; + if (child.IsLeaf) removed++; + node.Children[i] = null; + } + + node.IsLeaf = true; + _leafCount -= removed - 1; + } + + private static void BuildPalette(Node node, List colors) + { + if (node.IsLeaf) + { + node.PaletteIndex = colors.Count; + int count = Math.Max(1, node.Count); + colors.Add(new Rgba32((byte)(node.R / count), (byte)(node.G / count), (byte)(node.B / count))); + return; + } + + foreach (Node child in node.Children) + { + if (child != null) BuildPalette(child, colors); + } + } + + private int Lookup(Rgba32 p) + { + Node node = _root; + for (int level = 0; level < 8 && !node.IsLeaf; level++) + { + Node next = node.Children[Index(p, level)]; + if (next == null) + { + // Colour not in the tree (only possible for semi-transparent pixels): use any leaf below. + foreach (Node child in node.Children) + { + if (child != null) { next = child; break; } + } + } + + node = next; + } + + while (!node.IsLeaf) + { + foreach (Node child in node.Children) + { + if (child != null) { node = child; break; } + } + } + + return node.PaletteIndex; + } + } +} +#endif diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/Formats.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/Formats.cs new file mode 100644 index 00000000..c4e49032 --- /dev/null +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/Formats.cs @@ -0,0 +1,224 @@ +#if NETFRAMEWORK +using System; +using System.Text; + +namespace IronSoftware.Drawing.SkiaEngine +{ + internal interface IImageFormat + { + string Name { get; } + string DefaultMimeType { get; } + } + + // Names and MIME types match ImageSharp's formats. + internal sealed class PngFormat : IImageFormat { public static readonly PngFormat Instance = new(); public string Name => "PNG"; public string DefaultMimeType => "image/png"; } + internal sealed class JpegFormat : IImageFormat { public static readonly JpegFormat Instance = new(); public string Name => "JPEG"; public string DefaultMimeType => "image/jpeg"; } + internal sealed class GifFormat : IImageFormat { public static readonly GifFormat Instance = new(); public string Name => "GIF"; public string DefaultMimeType => "image/gif"; } + internal sealed class BmpFormat : IImageFormat { public static readonly BmpFormat Instance = new(); public string Name => "BMP"; public string DefaultMimeType => "image/bmp"; } + internal sealed class TiffFormat : IImageFormat { public static readonly TiffFormat Instance = new(); public string Name => "TIFF"; public string DefaultMimeType => "image/tiff"; } + internal sealed class WebpFormat : IImageFormat { public static readonly WebpFormat Instance = new(); public string Name => "Webp"; public string DefaultMimeType => "image/webp"; } + + internal static class ImageFormats + { + public static IImageFormat Detect(byte[] data) + { + if (data == null || data.Length < 8) + { + return null; + } + + if (data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 && data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A) + return PngFormat.Instance; + if (data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) + return JpegFormat.Instance; + if (data[0] == 'G' && data[1] == 'I' && data[2] == 'F' && data[3] == '8' && (data[4] == '7' || data[4] == '9') && data[5] == 'a') + return GifFormat.Instance; + if (data[0] == 'B' && data[1] == 'M' && data.Length >= 18) + { + // Like ImageSharp, also require a known DIB header size. + int headerSize = BitConverter.ToInt32(data, 14); + if (headerSize is 12 or 16 or 40 or 52 or 56 or 64 or 108 or 124) + return BmpFormat.Instance; + } + + if ((data[0] == 0x49 && data[1] == 0x49 && (data[2] == 0x2A || data[2] == 0x2B) && data[3] == 0x00) + || (data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && (data[3] == 0x2A || data[3] == 0x2B))) + return TiffFormat.Instance; + if (data.Length >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' && data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P') + return WebpFormat.Instance; + + return null; + } + } + + /// Header information SkiaSharp does not expose: source pixel layout and resolution. + internal sealed class HeaderInfo + { + public int PngBitDepth; + public int PngColorType = -1; + public bool PngHasTransparency; + public int JpegComponents; + public double? HorizontalResolution; + public double? VerticalResolution; + public PixelResolutionUnit ResolutionUnits = PixelResolutionUnit.PixelsPerInch; + + public static HeaderInfo Read(byte[] data, IImageFormat format) + { + var info = new HeaderInfo(); + try + { + switch (format) + { + case PngFormat: info.ReadPng(data); break; + case JpegFormat: info.ReadJpeg(data); break; + case BmpFormat: info.ReadBmp(data); break; + } + } + catch (Exception) + { + // Header metadata is best effort; decoding itself is done by SkiaSharp. + } + + return info; + } + + private static int ReadInt32BE(byte[] d, int o) => (d[o] << 24) | (d[o + 1] << 16) | (d[o + 2] << 8) | d[o + 3]; + private static int ReadUInt16BE(byte[] d, int o) => (d[o] << 8) | d[o + 1]; + + private void ReadPng(byte[] d) + { + int offset = 8; + while (offset + 8 <= d.Length) + { + int length = ReadInt32BE(d, offset); + string type = Encoding.ASCII.GetString(d, offset + 4, 4); + int body = offset + 8; + if (length < 0 || body + length > d.Length) break; + + switch (type) + { + case "IHDR": + PngBitDepth = d[body + 8]; + PngColorType = d[body + 9]; + break; + case "tRNS": + PngHasTransparency = true; + break; + case "pHYs" when length >= 9: + int x = ReadInt32BE(d, body); + int y = ReadInt32BE(d, body + 4); + if (x > 0 && y > 0) + { + HorizontalResolution = x; + VerticalResolution = y; + ResolutionUnits = d[body + 8] == 1 ? PixelResolutionUnit.PixelsPerMeter : PixelResolutionUnit.AspectRatio; + } + break; + } + + if (type == "IDAT" || type == "IEND") break; + offset = body + length + 4; // + CRC + } + } + + private void ReadJpeg(byte[] d) + { + double? exifX = null, exifY = null; + int exifUnit = 2; + bool jfifSet = false; + int offset = 2; + while (offset + 4 <= d.Length) + { + if (d[offset] != 0xFF) { offset++; continue; } + byte marker = d[offset + 1]; + if (marker == 0xD8 || marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7)) { offset += 2; continue; } + if (marker == 0xD9 || marker == 0xDA) break; // end of image / start of scan + int length = ReadUInt16BE(d, offset + 2); + int body = offset + 4; + if (length < 2 || offset + 2 + length > d.Length) break; + + if (marker == 0xE0 && length >= 16 && Encoding.ASCII.GetString(d, body, 4) == "JFIF") + { + int units = d[body + 7]; + int xDensity = ReadUInt16BE(d, body + 8); + int yDensity = ReadUInt16BE(d, body + 10); + if (xDensity > 0 && yDensity > 0) + { + HorizontalResolution = xDensity; + VerticalResolution = yDensity; + ResolutionUnits = units switch + { + 1 => PixelResolutionUnit.PixelsPerInch, + 2 => PixelResolutionUnit.PixelsPerCentimeter, + _ => PixelResolutionUnit.AspectRatio + }; + jfifSet = true; + } + } + else if (marker == 0xE1 && length >= 16 && Encoding.ASCII.GetString(d, body, 4) == "Exif") + { + ReadExifResolution(d, body + 6, offset + 2 + length, ref exifX, ref exifY, ref exifUnit); + } + else if (marker is 0xC0 or 0xC1 or 0xC2 or 0xC3 or 0xC5 or 0xC6 or 0xC7 or 0xC9 or 0xCA or 0xCB or 0xCD or 0xCE or 0xCF) + { + JpegComponents = d[body + 5]; + } + + offset += 2 + length; + } + + if (!jfifSet && exifX > 0 && exifY > 0) + { + HorizontalResolution = exifX; + VerticalResolution = exifY; + ResolutionUnits = exifUnit == 3 ? PixelResolutionUnit.PixelsPerCentimeter + : exifUnit == 1 ? PixelResolutionUnit.AspectRatio + : PixelResolutionUnit.PixelsPerInch; + } + } + + private static void ReadExifResolution(byte[] d, int tiffStart, int end, ref double? x, ref double? y, ref int unit) + { + bool little = d[tiffStart] == 'I'; + int U16(int o) => little ? d[o] | (d[o + 1] << 8) : (d[o] << 8) | d[o + 1]; + int U32(int o) => little ? d[o] | (d[o + 1] << 8) | (d[o + 2] << 16) | (d[o + 3] << 24) : (d[o] << 24) | (d[o + 1] << 16) | (d[o + 2] << 8) | d[o + 3]; + + int ifd = tiffStart + U32(tiffStart + 4); + if (ifd + 2 > end) return; + int count = U16(ifd); + for (int i = 0; i < count; i++) + { + int entry = ifd + 2 + (i * 12); + if (entry + 12 > end) return; + int tag = U16(entry); + if (tag == 0x011A || tag == 0x011B) + { + int valueOffset = tiffStart + U32(entry + 8); + if (valueOffset + 8 > end) continue; + double numerator = (uint)U32(valueOffset); + double denominator = (uint)U32(valueOffset + 4); + if (denominator == 0) continue; + if (tag == 0x011A) x = numerator / denominator; else y = numerator / denominator; + } + else if (tag == 0x0128) + { + unit = U16(entry + 8); + } + } + } + + private void ReadBmp(byte[] d) + { + if (d.Length < 46 || BitConverter.ToInt32(d, 14) < 40) return; + int x = BitConverter.ToInt32(d, 38); + int y = BitConverter.ToInt32(d, 42); + if (x > 0 && y > 0) + { + HorizontalResolution = x; + VerticalResolution = y; + ResolutionUnits = PixelResolutionUnit.PixelsPerMeter; + } + } + } +} +#endif diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/Image.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/Image.cs new file mode 100644 index 00000000..096ed389 --- /dev/null +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/Image.cs @@ -0,0 +1,546 @@ +#if NETFRAMEWORK +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; + +namespace IronSoftware.Drawing.SkiaEngine +{ + internal enum PixelResolutionUnit : byte + { + AspectRatio = 0, + PixelsPerInch = 1, + PixelsPerCentimeter = 2, + PixelsPerMeter = 3 + } + + internal sealed class ImageMetadata + { + // ImageSharp defaults. + public double HorizontalResolution { get; set; } = 96; + public double VerticalResolution { get; set; } = 96; + public PixelResolutionUnit ResolutionUnits { get; set; } = PixelResolutionUnit.PixelsPerInch; + + /// EXIF orientation (1 = top-left). Consumed by AutoOrient. + internal ushort ExifOrientation { get; set; } = 1; + + /// Per-frame GIF/WebP delay in 1/100 s, kept so re-encoding animations keeps timing. + internal List FrameDelays { get; set; } + + public ImageMetadata Clone() => new() + { + HorizontalResolution = HorizontalResolution, + VerticalResolution = VerticalResolution, + ResolutionUnits = ResolutionUnits, + ExifOrientation = ExifOrientation, + FrameDelays = FrameDelays == null ? null : new List(FrameDelays) + }; + } + + internal sealed class PixelTypeInfo + { + public PixelTypeInfo(int bitsPerPixel) => BitsPerPixel = bitsPerPixel; + public int BitsPerPixel { get; } + } + + internal delegate void PixelAccessorAction(PixelAccessor accessor) where TPixel : unmanaged, IPixel; + + internal readonly struct PixelAccessor where TPixel : unmanaged, IPixel + { + private readonly TPixel[] _data; + + internal PixelAccessor(TPixel[] data, int width, int height) + { + _data = data; + Width = width; + Height = height; + } + + public int Width { get; } + public int Height { get; } + public Span GetRowSpan(int rowIndex) => new(_data, rowIndex * Width, Width); + } + + /// A frame of an image, as returned by . + internal sealed class ImageFrame + { + internal ImageFrame(Image owner, int index) + { + Owner = owner; + Index = index; + } + + internal Image Owner { get; } + internal int Index { get; } + } + + internal sealed class ImageFrameCollection + { + private readonly Image _owner; + internal ImageFrameCollection(Image owner) => _owner = owner; + + public int Count => _owner.FrameCount; + public ImageFrame RootFrame => new(_owner, 0); + public Image CloneFrame(int index) => _owner.CloneFrameInternal(index); + public void AddFrame(ImageFrame frame) => _owner.AddFrameInternal(frame.Owner, frame.Index); + } + + internal abstract class Image : IDisposable + { + public abstract int Width { get; } + public abstract int Height { get; } + public ImageMetadata Metadata { get; internal set; } = new(); + public abstract PixelTypeInfo PixelType { get; } + public ImageFrameCollection Frames => new(this); + + internal abstract int FrameCount { get; } + internal abstract Image CloneFrameInternal(int index); + internal abstract void AddFrameInternal(Image source, int frameIndex); + internal abstract Rgba32[] GetFrameRgba32(int frameIndex); + internal abstract Rgba64[] GetFrameRgba64(int frameIndex); + internal abstract Image CloneInternal(); + + public abstract Image CloneAs() where TOther : unmanaged, IPixel; + + public Image Clone(Action operation) + { + Image clone = CloneInternal(); + clone.Mutate(operation); + return clone; + } + + public void Mutate(Action operation) => operation(new ImageProcessingContext(this)); + + public void Save(Stream stream, IImageEncoder encoder) => encoder.Encode(this, stream); + + public void SaveAsGif(Stream stream) => Save(stream, new GifEncoder()); + + // Processing primitives, implemented per pixel type by Image. + internal abstract void ApplyCrop(Rectangle rectangle); + internal abstract void ApplyResize(int width, int height); + internal abstract void ApplyBoxPad(int width, int height, Color padColor); + internal abstract void ApplyRotateFlip(RotateMode rotateMode, FlipMode flipMode); + internal abstract void ApplyBackgroundColor(Color color); + internal abstract void ApplyFill(Color color, Rectangle region); + + public virtual void Dispose() + { + // Pixel memory is managed; nothing to release. + } + + public static IImageFormat DetectFormat(byte[] data) => ImageFormats.Detect(data); + + public static Image Load(byte[] data) => ImageDecoder.Decode(data); + + public static Image Load(string path) => ImageDecoder.Decode(File.ReadAllBytes(path)); + + public static Image Load(byte[] data) where TPixel : unmanaged, IPixel + { + Image image = ImageDecoder.Decode(data); + return image as Image ?? image.CloneAs(); + } + + public static Image LoadPixelData(ReadOnlySpan data, int width, int height) where TPixel : unmanaged, IPixel + { + var pixels = new TPixel[width * height]; + ReadOnlySpan source = MemoryMarshal.Cast(data); + if (source.Length < pixels.Length) + { + throw new ArgumentException($"The pixel data is too small for a {width}x{height} image.", nameof(data)); + } + + source.Slice(0, pixels.Length).CopyTo(pixels); + return new Image(width, height, new List { pixels }, new ImageMetadata()); + } + } + + internal sealed class Image : Image where TPixel : unmanaged, IPixel + { + private static readonly PixelTypeInfo PixelInfo = new(PixelMath.BitsPerPixel()); + + private int _width; + private int _height; + private List _frames; + + public Image(int width, int height) + : this(width, height, new List { new TPixel[checked(width * height)] }, new ImageMetadata()) + { + } + + internal Image(int width, int height, List frames, ImageMetadata metadata) + { + if (width <= 0 || height <= 0) + { + throw new ArgumentOutOfRangeException(nameof(width), $"Invalid image size {width}x{height}."); + } + + _width = width; + _height = height; + _frames = frames; + Metadata = metadata ?? new ImageMetadata(); + } + + public override int Width => _width; + public override int Height => _height; + public override PixelTypeInfo PixelType => PixelInfo; + internal override int FrameCount => _frames.Count; + internal TPixel[] RootPixels => _frames[0]; + + public TPixel this[int x, int y] + { + get => _frames[0][(y * _width) + x]; + set => _frames[0][(y * _width) + x] = value; + } + + public void ProcessPixelRows(PixelAccessorAction processPixels) => + processPixels(new PixelAccessor(_frames[0], _width, _height)); + + public void CopyPixelDataTo(Span destination) => + MemoryMarshal.AsBytes(_frames[0].AsSpan()).CopyTo(destination); + + public override Image CloneAs() + { + var frames = new List(_frames.Count); + foreach (TPixel[] frame in _frames) + { + frames.Add(Convert(frame)); + } + + return new Image(_width, _height, frames, Metadata.Clone()); + } + + internal override Image CloneInternal() => + new Image(_width, _height, _frames.Select(f => (TPixel[])f.Clone()).ToList(), Metadata.Clone()); + + internal override Image CloneFrameInternal(int index) => + new Image(_width, _height, new List { (TPixel[])_frames[index].Clone() }, Metadata.Clone()); + + internal override void AddFrameInternal(Image source, int frameIndex) + { + if (source.Width != _width || source.Height != _height) + { + throw new ArgumentException("Frame must have the same dimensions as the image.", nameof(source)); + } + + _frames.Add(source is Image same + ? (TPixel[])same._frames[frameIndex].Clone() + : FromRgba64(source.GetFrameRgba64(frameIndex))); + } + + internal override Rgba32[] GetFrameRgba32(int frameIndex) + { + TPixel[] frame = _frames[frameIndex]; + if (frame is Rgba32[] rgba) + { + return (Rgba32[])rgba.Clone(); + } + + var result = new Rgba32[frame.Length]; + for (int i = 0; i < frame.Length; i++) + { + result[i] = frame[i].ToRgba32(); + } + + return result; + } + + internal override Rgba64[] GetFrameRgba64(int frameIndex) + { + TPixel[] frame = _frames[frameIndex]; + var result = new Rgba64[frame.Length]; + for (int i = 0; i < frame.Length; i++) + { + result[i] = frame[i].ToRgba64(); + } + + return result; + } + + private static TOther[] Convert(TPixel[] source) where TOther : unmanaged, IPixel + { + if (source is TOther[] same) + { + return (TOther[])same.Clone(); + } + + var result = new TOther[source.Length]; + if (Is16Bit() || Is16Bit()) + { + for (int i = 0; i < source.Length; i++) + { + result[i].FromRgba64(source[i].ToRgba64()); + } + } + else + { + for (int i = 0; i < source.Length; i++) + { + result[i].FromRgba32(source[i].ToRgba32()); + } + } + + return result; + } + + internal static bool Is16Bit() => + typeof(T) == typeof(Rgba64) || typeof(T) == typeof(Rgb48) || typeof(T) == typeof(L16) || typeof(T) == typeof(La32); + + private static TPixel[] FromRgba32(Rgba32[] source) + { + if (source is TPixel[] same) + { + return same; + } + + var result = new TPixel[source.Length]; + for (int i = 0; i < source.Length; i++) + { + result[i].FromRgba32(source[i]); + } + + return result; + } + + private static TPixel[] FromRgba64(Rgba64[] source) + { + if (source is TPixel[] same) + { + return same; + } + + var result = new TPixel[source.Length]; + for (int i = 0; i < source.Length; i++) + { + result[i].FromRgba64(source[i]); + } + + return result; + } + + internal override void ApplyCrop(Rectangle rectangle) + { + if (rectangle.Width <= 0 || rectangle.Height <= 0 || rectangle.X < 0 || rectangle.Y < 0 + || rectangle.X + rectangle.Width > _width || rectangle.Y + rectangle.Height > _height) + { + throw new ArgumentException("Crop rectangle should be smaller than the source bounds.", nameof(rectangle)); + } + + for (int f = 0; f < _frames.Count; f++) + { + TPixel[] source = _frames[f]; + var cropped = new TPixel[rectangle.Width * rectangle.Height]; + for (int y = 0; y < rectangle.Height; y++) + { + Array.Copy(source, ((rectangle.Y + y) * _width) + rectangle.X, cropped, y * rectangle.Width, rectangle.Width); + } + + _frames[f] = cropped; + } + + _width = rectangle.Width; + _height = rectangle.Height; + } + + internal override void ApplyResize(int width, int height) + { + // ImageSharp keeps the aspect ratio when one dimension is 0. + if (width == 0 && height > 0) width = Math.Max(1, (int)Math.Round(_width * (height / (double)_height))); + if (height == 0 && width > 0) height = Math.Max(1, (int)Math.Round(_height * (width / (double)_width))); + if (width <= 0 || height <= 0) + { + throw new ArgumentOutOfRangeException(nameof(width), "Resize dimensions must be greater than 0."); + } + + if (width == _width && height == _height) + { + return; + } + + bool is16 = Is16Bit(); + for (int f = 0; f < _frames.Count; f++) + { + _frames[f] = is16 + ? FromRgba64(SkiaOps.Resize(GetFrameRgba64(f), _width, _height, width, height)) + : FromRgba32(SkiaOps.Resize(GetFrameRgba32(f), _width, _height, width, height)); + } + + _width = width; + _height = height; + } + + internal override void ApplyBoxPad(int width, int height, Color padColor) + { + // ResizeMode.BoxPad: pad without scaling when the source fits; otherwise scale down to fit + // (keeping the aspect ratio) and pad. The image is centred. + int scaledWidth = _width; + int scaledHeight = _height; + if (_width > width || _height > height) + { + double ratio = Math.Min(width / (double)_width, height / (double)_height); + scaledWidth = Math.Max(1, (int)Math.Round(_width * ratio)); + scaledHeight = Math.Max(1, (int)Math.Round(_height * ratio)); + } + + ApplyResize(scaledWidth, scaledHeight); + + if (scaledWidth == width && scaledHeight == height) + { + return; + } + + int offsetX = (width - scaledWidth) / 2; + int offsetY = (height - scaledHeight) / 2; + TPixel pad = default; + pad.FromRgba32(padColor); + + for (int f = 0; f < _frames.Count; f++) + { + TPixel[] source = _frames[f]; + var padded = new TPixel[width * height]; + padded.AsSpan().Fill(pad); + for (int y = 0; y < scaledHeight; y++) + { + Array.Copy(source, y * scaledWidth, padded, ((offsetY + y) * width) + offsetX, scaledWidth); + } + + _frames[f] = padded; + } + + _width = width; + _height = height; + } + + internal override void ApplyRotateFlip(RotateMode rotateMode, FlipMode flipMode) + { + int degrees = ((int)rotateMode % 360 + 360) % 360; + if (degrees != 0) + { + int newWidth = degrees == 180 ? _width : _height; + int newHeight = degrees == 180 ? _height : _width; + for (int f = 0; f < _frames.Count; f++) + { + TPixel[] source = _frames[f]; + var rotated = new TPixel[source.Length]; + for (int y = 0; y < _height; y++) + { + int row = y * _width; + for (int x = 0; x < _width; x++) + { + int destination = degrees switch + { + 90 => (x * newWidth) + (_height - 1 - y), + 180 => ((_height - 1 - y) * newWidth) + (_width - 1 - x), + _ => ((_width - 1 - x) * newWidth) + y, // 270 + }; + rotated[destination] = source[row + x]; + } + } + + _frames[f] = rotated; + } + + _width = newWidth; + _height = newHeight; + } + + if (flipMode == FlipMode.None) + { + return; + } + + foreach (TPixel[] frame in _frames) + { + if (flipMode == FlipMode.Horizontal) + { + for (int y = 0; y < _height; y++) + { + Array.Reverse(frame, y * _width, _width); + } + } + else + { + var rowBuffer = new TPixel[_width]; + for (int y = 0; y < _height / 2; y++) + { + int top = y * _width; + int bottom = (_height - 1 - y) * _width; + Array.Copy(frame, top, rowBuffer, 0, _width); + Array.Copy(frame, bottom, frame, top, _width); + Array.Copy(rowBuffer, 0, frame, bottom, _width); + } + } + } + } + + internal override void ApplyBackgroundColor(Color color) + { + // Source-over composition of the image onto the background colour (ImageSharp BackgroundColor). + for (int f = 0; f < _frames.Count; f++) + { + Rgba32[] pixels = GetFrameRgba32(f); + for (int i = 0; i < pixels.Length; i++) + { + pixels[i] = Blend(pixels[i], color, pixels[i].A / 255f, color.A / 255f, sourceOnTop: true); + } + + _frames[f] = FromRgba32(pixels); + } + } + + internal override void ApplyFill(Color color, Rectangle region) + { + int x0 = 0, y0 = 0, x1 = _width, y1 = _height; + if (region != null) + { + Rectangle r = region; + x0 = Math.Max(0, r.X); + y0 = Math.Max(0, r.Y); + x1 = Math.Min(_width, r.X + r.Width); + y1 = Math.Min(_height, r.Y + r.Height); + if (x0 >= x1 || y0 >= y1) + { + return; + } + } + + float alpha = color.A / 255f; + TPixel opaque = default; + opaque.FromRgba32(color); + + foreach (TPixel[] frame in _frames) + { + for (int y = y0; y < y1; y++) + { + int row = y * _width; + for (int x = x0; x < x1; x++) + { + if (color.A == 255) + { + frame[row + x] = opaque; + } + else + { + Rgba32 destination = frame[row + x].ToRgba32(); + frame[row + x].FromRgba32(Blend(color, destination, alpha, destination.A / 255f, sourceOnTop: true)); + } + } + } + } + } + + /// Porter-Duff source-over: over . + private static Rgba32 Blend(Rgba32 top, Rgba32 bottom, float topAlpha, float bottomAlpha, bool sourceOnTop) + { + float outAlpha = topAlpha + (bottomAlpha * (1 - topAlpha)); + if (outAlpha <= 0) + { + return new Rgba32(0, 0, 0, 0); + } + + byte Channel(byte t, byte b) => (byte)Math.Round(((t * topAlpha) + (b * bottomAlpha * (1 - topAlpha))) / outAlpha); + return new Rgba32(Channel(top.R, bottom.R), Channel(top.G, bottom.G), Channel(top.B, bottom.B), (byte)Math.Round(outAlpha * 255)); + } + } +} +#endif diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/ImageDecoder.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/ImageDecoder.cs new file mode 100644 index 00000000..f5f8f3f9 --- /dev/null +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/ImageDecoder.cs @@ -0,0 +1,292 @@ +#if NETFRAMEWORK +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using BitMiracle.LibTiff.Classic; +using SkiaSharp; + +namespace IronSoftware.Drawing.SkiaEngine +{ + internal static class ImageDecoder + { + private enum PixelKind { Rgba32, Rgb24, L8, L16, La16, La32, Rgb48, Rgba64 } + + public static Image Decode(byte[] data) + { + IImageFormat format = ImageFormats.Detect(data) + ?? throw new NotSupportedException("Image cannot be loaded. The image format is not recognised."); + + if (format is TiffFormat) + { + return TiffDecoder.Decode(data); + } + + HeaderInfo header = HeaderInfo.Read(data, format); + using SKData skData = SKData.CreateCopy(data); + using SKCodec codec = SKCodec.Create(skData) + ?? throw new NotSupportedException($"Image cannot be loaded. The {format.Name} data could not be decoded."); + + int width = codec.Info.Width; + int height = codec.Info.Height; + bool animated = format is GifFormat or WebpFormat; + int frameCount = animated ? Math.Max(1, codec.FrameCount) : 1; + SKCodecFrameInfo[] frameInfo = animated && codec.FrameCount > 0 ? codec.FrameInfo : Array.Empty(); + + var metadata = new ImageMetadata { ExifOrientation = (ushort)codec.EncodedOrigin }; + if (metadata.ExifOrientation is < 1 or > 8) + { + metadata.ExifOrientation = 1; + } + + if (header.HorizontalResolution > 0 && header.VerticalResolution > 0) + { + metadata.HorizontalResolution = header.HorizontalResolution.Value; + metadata.VerticalResolution = header.VerticalResolution.Value; + metadata.ResolutionUnits = header.ResolutionUnits; + } + + if (frameInfo.Length > 0) + { + metadata.FrameDelays = frameInfo.Select(f => Math.Max(0, f.Duration / 10)).ToList(); + } + + PixelKind kind = ChoosePixelKind(format, header); + bool sixteenBit = kind is PixelKind.L16 or PixelKind.La32 or PixelKind.Rgb48 or PixelKind.Rgba64; + + if (sixteenBit) + { + List frames; + try + { + frames = DecodeFrames(codec, width, height, frameCount, frameInfo, SKColorType.Rgba16161616); + } + catch (InvalidConversionException) + { + // Some codecs cannot convert straight to 16-bit integer RGBA; go through half floats instead. + frames = DecodeFrames(codec, width, height, frameCount, frameInfo, SKColorType.RgbaF16).Select(HalfToRgba64).ToList(); + } + return kind switch + { + PixelKind.L16 => Build(width, height, frames, metadata), + PixelKind.La32 => Build(width, height, frames, metadata), + PixelKind.Rgb48 => Build(width, height, frames, metadata), + _ => new Image(width, height, frames, metadata), + }; + } + + List frames8 = DecodeFrames(codec, width, height, frameCount, frameInfo, SKColorType.Rgba8888); + return kind switch + { + PixelKind.Rgb24 => Build(width, height, frames8, metadata), + PixelKind.L8 => Build(width, height, frames8, metadata), + PixelKind.La16 => Build(width, height, frames8, metadata), + _ => new Image(width, height, frames8, metadata), + }; + } + + /// The pixel type ImageSharp's non-generic Image.Load picks for the source. + private static PixelKind ChoosePixelKind(IImageFormat format, HeaderInfo header) + { + if (format is JpegFormat) + { + return header.JpegComponents == 1 ? PixelKind.L8 : PixelKind.Rgb24; + } + + if (format is not PngFormat || header.PngColorType < 0) + { + return PixelKind.Rgba32; + } + + bool sixteen = header.PngBitDepth == 16; + bool transparent = header.PngHasTransparency; + return header.PngColorType switch + { + 0 => sixteen ? (transparent ? PixelKind.La32 : PixelKind.L16) : (transparent ? PixelKind.La16 : PixelKind.L8), + 2 => sixteen ? (transparent ? PixelKind.Rgba64 : PixelKind.Rgb48) : (transparent ? PixelKind.Rgba32 : PixelKind.Rgb24), + 4 => sixteen ? PixelKind.La32 : PixelKind.La16, + 6 => sixteen ? PixelKind.Rgba64 : PixelKind.Rgba32, + _ => PixelKind.Rgba32, // palette + }; + } + + private static List DecodeFrames(SKCodec codec, int width, int height, int frameCount, SKCodecFrameInfo[] frameInfo, SKColorType colorType) + where T : unmanaged + { + var info = new SKImageInfo(width, height, colorType, SKAlphaType.Unpremul); + var frames = new List(frameCount); + for (int i = 0; i < frameCount; i++) + { + var pixels = new T[width * height]; + int required = i < frameInfo.Length ? frameInfo[i].RequiredFrame : -1; + if (required >= 0 && required < frames.Count) + { + Array.Copy(frames[required], pixels, pixels.Length); + } + + var options = new SKCodecOptions(i, required); + GCHandle handle = GCHandle.Alloc(pixels, GCHandleType.Pinned); + try + { + SKCodecResult result = codec.GetPixels(info, handle.AddrOfPinnedObject(), options); + if (result == SKCodecResult.InvalidConversion) + { + throw new InvalidConversionException(); + } + + if (result != SKCodecResult.Success && result != SKCodecResult.IncompleteInput) + { + throw new NotSupportedException($"Image cannot be loaded. Decoding failed: {result}."); + } + } + finally + { + handle.Free(); + } + + frames.Add(pixels); + } + + return frames; + } + + private sealed class InvalidConversionException : Exception + { + } + + private static Rgba64[] HalfToRgba64(ulong[] halves) + { + var pixels = new Rgba64[halves.Length]; + for (int i = 0; i < halves.Length; i++) + { + ulong h = halves[i]; + pixels[i] = new Rgba64(HalfToUInt16((ushort)h), HalfToUInt16((ushort)(h >> 16)), HalfToUInt16((ushort)(h >> 32)), HalfToUInt16((ushort)(h >> 48))); + } + + return pixels; + } + + /// Converts an IEEE 754 half holding a 0..1 colour value to a 16-bit channel. + private static ushort HalfToUInt16(ushort half) + { + if ((half & 0x8000) != 0) + { + return 0; + } + + int exponent = (half >> 10) & 0x1F; + int mantissa = half & 0x3FF; + double value = exponent == 0 + ? mantissa / 1024.0 * Math.Pow(2, -14) + : exponent == 31 ? 1.0 : (1 + (mantissa / 1024.0)) * Math.Pow(2, exponent - 15); + return (ushort)Math.Round(Math.Min(1.0, value) * 65535); + } + + private static Image Build(int width, int height, List frames, ImageMetadata metadata) where TPixel : unmanaged, IPixel + { + var converted = new List(frames.Count); + foreach (Rgba32[] frame in frames) + { + var pixels = new TPixel[frame.Length]; + for (int i = 0; i < frame.Length; i++) + { + pixels[i].FromRgba32(frame[i]); + } + + converted.Add(pixels); + } + + return new Image(width, height, converted, metadata); + } + + private static Image Build(int width, int height, List frames, ImageMetadata metadata) where TPixel : unmanaged, IPixel + { + var converted = new List(frames.Count); + foreach (Rgba64[] frame in frames) + { + var pixels = new TPixel[frame.Length]; + for (int i = 0; i < frame.Length; i++) + { + pixels[i].FromRgba64(frame[i]); + } + + converted.Add(pixels); + } + + return new Image(width, height, converted, metadata); + } + } + + /// TIFF decoding through BitMiracle.LibTiff (all pages, decoded to Rgba32). + internal static class TiffDecoder + { + private sealed class QuietErrorHandler : TiffErrorHandler + { + public override void WarningHandler(Tiff tif, string method, string format, params object[] args) { } + public override void WarningHandlerExt(Tiff tif, object clientData, string method, string format, params object[] args) { } + } + + public static Image Decode(byte[] data) + { + Tiff.SetErrorHandler(new QuietErrorHandler()); + using var stream = new MemoryStream(data); + using Tiff tiff = Tiff.ClientOpen("in-memory", "r", stream, new TiffStream()) + ?? throw new NotSupportedException("Image cannot be loaded. The TIFF data could not be opened."); + + Image result = null; + do + { + FieldValue[] subFileType = tiff.GetField(TiffTag.SUBFILETYPE); + if (subFileType != null && subFileType.Length > 0 && (FileType)subFileType[0].Value == FileType.REDUCEDIMAGE) + { + continue; // thumbnail + } + + int width = tiff.GetField(TiffTag.IMAGEWIDTH)[0].ToInt(); + int height = tiff.GetField(TiffTag.IMAGELENGTH)[0].ToInt(); + var raster = new int[width * height]; + if (!tiff.ReadRGBAImageOriented(width, height, raster, Orientation.TOPLEFT)) + { + throw new NotSupportedException("Image cannot be loaded. The TIFF page could not be decoded."); + } + + // LibTiff packs each pixel as ABGR in an int: little-endian bytes R, G, B, A = Rgba32 layout. + var pixels = new Rgba32[width * height]; + MemoryMarshal.AsBytes(raster.AsSpan()).CopyTo(MemoryMarshal.AsBytes(pixels.AsSpan())); + + if (result == null) + { + var metadata = new ImageMetadata(); + FieldValue[] x = tiff.GetField(TiffTag.XRESOLUTION); + FieldValue[] y = tiff.GetField(TiffTag.YRESOLUTION); + FieldValue[] unit = tiff.GetField(TiffTag.RESOLUTIONUNIT); + if (x != null && y != null && x[0].ToDouble() > 0 && y[0].ToDouble() > 0) + { + metadata.HorizontalResolution = x[0].ToDouble(); + metadata.VerticalResolution = y[0].ToDouble(); + metadata.ResolutionUnits = unit != null && (ResUnit)unit[0].ToInt() == ResUnit.CENTIMETER + ? PixelResolutionUnit.PixelsPerCentimeter + : unit != null && (ResUnit)unit[0].ToInt() == ResUnit.NONE ? PixelResolutionUnit.AspectRatio : PixelResolutionUnit.PixelsPerInch; + } + + result = new Image(width, height, new List { pixels }, metadata); + } + else + { + var page = new Image(width, height, new List { pixels }, new ImageMetadata()); + if (width != result.Width || height != result.Height) + { + page.ApplyBoxPad(result.Width, result.Height, Color.Transparent); + } + + result.AddFrameInternal(page, 0); + } + } + while (tiff.ReadDirectory()); + + return result ?? throw new NotSupportedException("Image cannot be loaded. The TIFF contains no image pages."); + } + } +} +#endif diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/PixelFormats.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/PixelFormats.cs new file mode 100644 index 00000000..b97ccaf6 --- /dev/null +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/PixelFormats.cs @@ -0,0 +1,190 @@ +#if NETFRAMEWORK +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// .NET Framework image engine (SkiaSharp + LibTiff), used instead of SixLabors.ImageSharp, which is +// end-of-life for .NET Framework. These internal types mirror the subset of the ImageSharp API that +// AnyBitmap uses, so AnyBitmap's logic is shared between the ImageSharp and SkiaSharp builds. +// Byte layouts match ImageSharp's pixel structs (AnyBitmap copies raw pixel bytes). +namespace IronSoftware.Drawing.SkiaEngine +{ + internal interface IPixel where TSelf : unmanaged, IPixel + { + Rgba32 ToRgba32(); + void FromRgba32(Rgba32 source); + Rgba64 ToRgba64(); + void FromRgba64(Rgba64 source); + } + + internal static class PixelMath + { + // Same rounding as ImageSharp's ColorNumerics. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte To8Bit(ushort component) => (byte)(((component * 255) + 32895) >> 16); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ushort To16Bit(byte component) => (ushort)(component * 257); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte Luminance8(byte r, byte g, byte b) => (byte)((r * .2126F) + (g * .7152F) + (b * .0722F) + 0.5F); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ushort Luminance16(ushort r, ushort g, ushort b) => (ushort)((r * .2126F) + (g * .7152F) + (b * .0722F) + 0.5F); + + public static int BitsPerPixel() where TPixel : unmanaged, IPixel => Unsafe.SizeOf() * 8; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct Rgba32 : IPixel + { + public byte R, G, B, A; + public Rgba32(byte r, byte g, byte b, byte a = 255) { R = r; G = g; B = b; A = a; } + public Rgba32 ToRgba32() => this; + public void FromRgba32(Rgba32 s) => this = s; + public Rgba64 ToRgba64() => new(PixelMath.To16Bit(R), PixelMath.To16Bit(G), PixelMath.To16Bit(B), PixelMath.To16Bit(A)); + public void FromRgba64(Rgba64 s) => this = s.ToRgba32(); + public static implicit operator Color(Rgba32 p) => new(p.A, p.R, p.G, p.B); + public static implicit operator Rgba32(Color c) => new(c.R, c.G, c.B, c.A); + } + + [StructLayout(LayoutKind.Sequential)] + internal struct Bgra32 : IPixel + { + public byte B, G, R, A; + public Rgba32 ToRgba32() => new(R, G, B, A); + public void FromRgba32(Rgba32 s) { R = s.R; G = s.G; B = s.B; A = s.A; } + public Rgba64 ToRgba64() => ToRgba32().ToRgba64(); + public void FromRgba64(Rgba64 s) => FromRgba32(s.ToRgba32()); + public static implicit operator Color(Bgra32 p) => new(p.A, p.R, p.G, p.B); + public static implicit operator Bgra32(Color c) => new() { R = c.R, G = c.G, B = c.B, A = c.A }; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct Abgr32 : IPixel + { + public byte A, B, G, R; + public Rgba32 ToRgba32() => new(R, G, B, A); + public void FromRgba32(Rgba32 s) { R = s.R; G = s.G; B = s.B; A = s.A; } + public Rgba64 ToRgba64() => ToRgba32().ToRgba64(); + public void FromRgba64(Rgba64 s) => FromRgba32(s.ToRgba32()); + public static implicit operator Color(Abgr32 p) => new(p.A, p.R, p.G, p.B); + public static implicit operator Abgr32(Color c) => new() { R = c.R, G = c.G, B = c.B, A = c.A }; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct Argb32 : IPixel + { + public byte A, R, G, B; + public Rgba32 ToRgba32() => new(R, G, B, A); + public void FromRgba32(Rgba32 s) { R = s.R; G = s.G; B = s.B; A = s.A; } + public Rgba64 ToRgba64() => ToRgba32().ToRgba64(); + public void FromRgba64(Rgba64 s) => FromRgba32(s.ToRgba32()); + public static implicit operator Color(Argb32 p) => new(p.A, p.R, p.G, p.B); + public static implicit operator Argb32(Color c) => new() { R = c.R, G = c.G, B = c.B, A = c.A }; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct Rgb24 : IPixel + { + public byte R, G, B; + public Rgba32 ToRgba32() => new(R, G, B, 255); + public void FromRgba32(Rgba32 s) { R = s.R; G = s.G; B = s.B; } + public Rgba64 ToRgba64() => ToRgba32().ToRgba64(); + public void FromRgba64(Rgba64 s) => FromRgba32(s.ToRgba32()); + public static implicit operator Color(Rgb24 p) => new(255, p.R, p.G, p.B); + public static implicit operator Rgb24(Color c) => new() { R = c.R, G = c.G, B = c.B }; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct Bgr24 : IPixel + { + public byte B, G, R; + public Rgba32 ToRgba32() => new(R, G, B, 255); + public void FromRgba32(Rgba32 s) { R = s.R; G = s.G; B = s.B; } + public Rgba64 ToRgba64() => ToRgba32().ToRgba64(); + public void FromRgba64(Rgba64 s) => FromRgba32(s.ToRgba32()); + public static implicit operator Color(Bgr24 p) => new(255, p.R, p.G, p.B); + public static implicit operator Bgr24(Color c) => new() { R = c.R, G = c.G, B = c.B }; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct L8 : IPixel + { + public byte PackedValue; + public Rgba32 ToRgba32() => new(PackedValue, PackedValue, PackedValue, 255); + public void FromRgba32(Rgba32 s) => PackedValue = PixelMath.Luminance8(s.R, s.G, s.B); + public Rgba64 ToRgba64() { ushort v = PixelMath.To16Bit(PackedValue); return new(v, v, v, ushort.MaxValue); } + public void FromRgba64(Rgba64 s) => PackedValue = PixelMath.To8Bit(PixelMath.Luminance16(s.R, s.G, s.B)); + } + + [StructLayout(LayoutKind.Sequential)] + internal struct L16 : IPixel + { + public ushort PackedValue; + public Rgba32 ToRgba32() => ToRgba64().ToRgba32(); + public void FromRgba32(Rgba32 s) => FromRgba64(s.ToRgba64()); + public Rgba64 ToRgba64() => new(PackedValue, PackedValue, PackedValue, ushort.MaxValue); + public void FromRgba64(Rgba64 s) => PackedValue = PixelMath.Luminance16(s.R, s.G, s.B); + } + + [StructLayout(LayoutKind.Sequential)] + internal struct La16 : IPixel + { + public byte L, A; + public Rgba32 ToRgba32() => new(L, L, L, A); + public void FromRgba32(Rgba32 s) { L = PixelMath.Luminance8(s.R, s.G, s.B); A = s.A; } + public Rgba64 ToRgba64() => ToRgba32().ToRgba64(); + public void FromRgba64(Rgba64 s) => FromRgba32(s.ToRgba32()); + } + + [StructLayout(LayoutKind.Sequential)] + internal struct La32 : IPixel + { + public ushort L, A; + public Rgba32 ToRgba32() => ToRgba64().ToRgba32(); + public void FromRgba32(Rgba32 s) => FromRgba64(s.ToRgba64()); + public Rgba64 ToRgba64() => new(L, L, L, A); + public void FromRgba64(Rgba64 s) { L = PixelMath.Luminance16(s.R, s.G, s.B); A = s.A; } + } + + [StructLayout(LayoutKind.Sequential)] + internal struct A8 : IPixel + { + public byte PackedValue; + public Rgba32 ToRgba32() => new(0, 0, 0, PackedValue); + public void FromRgba32(Rgba32 s) => PackedValue = s.A; + public Rgba64 ToRgba64() => new(0, 0, 0, PixelMath.To16Bit(PackedValue)); + public void FromRgba64(Rgba64 s) => PackedValue = PixelMath.To8Bit(s.A); + } + + [StructLayout(LayoutKind.Sequential)] + internal struct Rgb48 : IPixel + { + public ushort R, G, B; + public Rgba32 ToRgba32() => ToRgba64().ToRgba32(); + public void FromRgba32(Rgba32 s) => FromRgba64(s.ToRgba64()); + public Rgba64 ToRgba64() => new(R, G, B, ushort.MaxValue); + public void FromRgba64(Rgba64 s) { R = s.R; G = s.G; B = s.B; } + + // Same conversions as Color's ImageSharp operators (Color.cs). + public static implicit operator Color(Rgb48 p) => new(255, (byte)(p.R >> 8), (byte)(p.G >> 8), (byte)(p.B >> 8)); + public static implicit operator Rgb48(Color c) { var r = new Rgb48(); r.FromRgba64(((Rgba32)c).ToRgba64()); return r; } + } + + [StructLayout(LayoutKind.Sequential)] + internal struct Rgba64 : IPixel + { + public ushort R, G, B, A; + public Rgba64(ushort r, ushort g, ushort b, ushort a) { R = r; G = g; B = b; A = a; } + public Rgba32 ToRgba32() => new(PixelMath.To8Bit(R), PixelMath.To8Bit(G), PixelMath.To8Bit(B), PixelMath.To8Bit(A)); + public void FromRgba32(Rgba32 s) => this = s.ToRgba64(); + public Rgba64 ToRgba64() => this; + public void FromRgba64(Rgba64 s) => this = s; + + // Same conversions as Color's ImageSharp operators (Color.cs): the 16-bit values are passed + // to Color's (int, int, int, int) constructor, which truncates them to bytes. + public static implicit operator Color(Rgba64 p) => new(p.A, p.R, p.G, p.B); + public static implicit operator Rgba64(Color c) => ((Rgba32)c).ToRgba64(); + } +} +#endif diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/Processing.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/Processing.cs new file mode 100644 index 00000000..3348fd8a --- /dev/null +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/SkiaEngine/Processing.cs @@ -0,0 +1,178 @@ +#if NETFRAMEWORK +using System; +using System.Runtime.InteropServices; +using SkiaSharp; + +namespace IronSoftware.Drawing.SkiaEngine +{ + internal enum RotateMode + { + None = 0, + Rotate90 = 90, + Rotate180 = 180, + Rotate270 = 270 + } + + internal enum FlipMode + { + None = 0, + Horizontal = 1, + Vertical = 2 + } + + internal enum ResizeMode + { + Crop, + Pad, + BoxPad, + Max, + Min, + Stretch, + Manual + } + + internal enum AnchorPositionMode + { + Center, + Top, + Bottom, + Left, + Right, + TopLeft, + TopRight, + BottomRight, + BottomLeft + } + + internal sealed class ResizeOptions + { + public Size Size { get; set; } + public ResizeMode Mode { get; set; } = ResizeMode.Crop; + public Color PadColor { get; set; } = Color.Transparent; + public AnchorPositionMode Position { get; set; } = AnchorPositionMode.Center; + } + + internal sealed class SolidBrush + { + public SolidBrush(Color color) => Color = color; + public Color Color { get; } + } + + internal interface IImageProcessingContext + { + IImageProcessingContext Crop(Rectangle rectangle); + IImageProcessingContext Resize(int width, int height); + IImageProcessingContext Resize(ResizeOptions options); + IImageProcessingContext RotateFlip(RotateMode rotateMode, FlipMode flipMode); + IImageProcessingContext AutoOrient(); + IImageProcessingContext BackgroundColor(Color color); + IImageProcessingContext Fill(Color color); + IImageProcessingContext Fill(SolidBrush brush, Rectangle rectangle); + } + + internal sealed class ImageProcessingContext : IImageProcessingContext + { + private readonly Image _image; + internal ImageProcessingContext(Image image) => _image = image; + + public IImageProcessingContext Crop(Rectangle rectangle) { _image.ApplyCrop(rectangle); return this; } + + public IImageProcessingContext Resize(int width, int height) { _image.ApplyResize(width, height); return this; } + + public IImageProcessingContext Resize(ResizeOptions options) + { + if (options.Mode == ResizeMode.BoxPad && options.Position == AnchorPositionMode.Center) + { + _image.ApplyBoxPad(options.Size.Width, options.Size.Height, options.PadColor); + } + else if (options.Mode == ResizeMode.Stretch) + { + _image.ApplyResize(options.Size.Width, options.Size.Height); + } + else + { + throw new NotSupportedException($"Resize mode {options.Mode} with anchor {options.Position} is not supported."); + } + + return this; + } + + public IImageProcessingContext RotateFlip(RotateMode rotateMode, FlipMode flipMode) { _image.ApplyRotateFlip(rotateMode, flipMode); return this; } + + public IImageProcessingContext AutoOrient() + { + // EXIF orientation values 1-8 (same handling as ImageSharp's AutoOrient). + switch (_image.Metadata.ExifOrientation) + { + case 2: _image.ApplyRotateFlip(RotateMode.None, FlipMode.Horizontal); break; + case 3: _image.ApplyRotateFlip(RotateMode.Rotate180, FlipMode.None); break; + case 4: _image.ApplyRotateFlip(RotateMode.None, FlipMode.Vertical); break; + case 5: _image.ApplyRotateFlip(RotateMode.Rotate90, FlipMode.Horizontal); break; + case 6: _image.ApplyRotateFlip(RotateMode.Rotate90, FlipMode.None); break; + case 7: _image.ApplyRotateFlip(RotateMode.Rotate270, FlipMode.Horizontal); break; + case 8: _image.ApplyRotateFlip(RotateMode.Rotate270, FlipMode.None); break; + } + + _image.Metadata.ExifOrientation = 1; + return this; + } + + public IImageProcessingContext BackgroundColor(Color color) { _image.ApplyBackgroundColor(color); return this; } + + public IImageProcessingContext Fill(Color color) { _image.ApplyFill(color, null); return this; } + + public IImageProcessingContext Fill(SolidBrush brush, Rectangle rectangle) { _image.ApplyFill(brush.Color, rectangle); return this; } + } + + /// SkiaSharp-backed pixel operations. + internal static class SkiaOps + { + // ImageSharp's default resampler is bicubic (Catmull-Rom); resampling happens in premultiplied space. + private static readonly SKSamplingOptions Bicubic = new(SKCubicResampler.CatmullRom); + + public static Rgba32[] Resize(Rgba32[] pixels, int width, int height, int newWidth, int newHeight) + { + var result = new Rgba32[newWidth * newHeight]; + ResizeCore(pixels, result, width, height, newWidth, newHeight, SKColorType.Rgba8888, 4); + return result; + } + + public static Rgba64[] Resize(Rgba64[] pixels, int width, int height, int newWidth, int newHeight) + { + var result = new Rgba64[newWidth * newHeight]; + ResizeCore(pixels, result, width, height, newWidth, newHeight, SKColorType.Rgba16161616, 8); + return result; + } + + private static void ResizeCore(T[] source, T[] destination, int width, int height, int newWidth, int newHeight, SKColorType colorType, int bytesPerPixel) + where T : unmanaged + { + var sourceHandle = GCHandle.Alloc(source, GCHandleType.Pinned); + var destinationHandle = GCHandle.Alloc(destination, GCHandleType.Pinned); + try + { + var sourceInfo = new SKImageInfo(width, height, colorType, SKAlphaType.Unpremul); + var destinationInfo = new SKImageInfo(newWidth, newHeight, colorType, SKAlphaType.Unpremul); + using SKImage image = SKImage.FromPixels(sourceInfo, sourceHandle.AddrOfPinnedObject(), width * bytesPerPixel); + using var surface = SKSurface.Create(new SKImageInfo(newWidth, newHeight, colorType, SKAlphaType.Premul)); + if (image == null || surface == null) + { + throw new InvalidOperationException("SkiaSharp could not allocate the resize buffers."); + } + + using var paint = new SKPaint { BlendMode = SKBlendMode.Src }; + surface.Canvas.DrawImage(image, new SKRect(0, 0, width, height), new SKRect(0, 0, newWidth, newHeight), Bicubic, paint); + if (!surface.ReadPixels(destinationInfo, destinationHandle.AddrOfPinnedObject(), newWidth * bytesPerPixel, 0, 0)) + { + throw new InvalidOperationException("SkiaSharp could not read the resized pixels."); + } + } + finally + { + sourceHandle.Free(); + destinationHandle.Free(); + } + } + } +} +#endif diff --git a/IronSoftware.Drawing/SystemPackages.props b/IronSoftware.Drawing/SystemPackages.props index adb2b2c1..b85b29a4 100644 --- a/IronSoftware.Drawing/SystemPackages.props +++ b/IronSoftware.Drawing/SystemPackages.props @@ -1,13 +1,13 @@ + + + + + + + + + + @@ -79,6 +96,7 @@ Dependencies +