mirror of
https://github.com/cyanfish/naps2.git
synced 2024-08-16 18:50:45 +03:00
Gtk: Tiff saving
This commit is contained in:
parent
e628127547
commit
76a8778752
@ -1,5 +1,6 @@
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using Gdk;
|
using Gdk;
|
||||||
|
using NAPS2.Images.Bitwise;
|
||||||
|
|
||||||
namespace NAPS2.Images.Gtk;
|
namespace NAPS2.Images.Gtk;
|
||||||
|
|
||||||
@ -63,42 +64,138 @@ public class GtkImageContext : ImageContext
|
|||||||
return new[] { Load(path) };
|
return new[] { Load(path) };
|
||||||
}
|
}
|
||||||
|
|
||||||
public override bool SaveTiff(IList<IMemoryImage> images, string path, TiffCompressionType compression = TiffCompressionType.Auto,
|
public override bool SaveTiff(IList<IMemoryImage> images, string path,
|
||||||
|
TiffCompressionType compression = TiffCompressionType.Auto,
|
||||||
Action<int, int>? progressCallback = null, CancellationToken cancelToken = default)
|
Action<int, int>? progressCallback = null, CancellationToken cancelToken = default)
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
var tiff = LibTiff.TIFFOpen(path, "w");
|
||||||
|
return WriteTiff(tiff, null, images, compression, progressCallback, cancelToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override bool SaveTiff(IList<IMemoryImage> images, Stream stream, TiffCompressionType compression = TiffCompressionType.Auto,
|
public override bool SaveTiff(IList<IMemoryImage> images, Stream stream,
|
||||||
|
TiffCompressionType compression = TiffCompressionType.Auto,
|
||||||
Action<int, int>? progressCallback = null, CancellationToken cancelToken = default)
|
Action<int, int>? progressCallback = null, CancellationToken cancelToken = default)
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
var client = new LibTiffStreamClient(stream);
|
||||||
|
var tiff = client.TIFFClientOpen("w");
|
||||||
|
return WriteTiff(tiff, client, images, compression, progressCallback, cancelToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool WriteTiff(IntPtr tiff, LibTiffStreamClient client, IList<IMemoryImage> images,
|
||||||
|
TiffCompressionType compression, Action<int, int>? progressCallback, CancellationToken cancelToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int i = 0;
|
||||||
|
progressCallback?.Invoke(0, images.Count);
|
||||||
|
foreach (var image in images)
|
||||||
|
{
|
||||||
|
if (cancelToken.IsCancellationRequested) return false;
|
||||||
|
var pixelFormat =
|
||||||
|
image.LogicalPixelFormat == ImagePixelFormat.BW1 || compression == TiffCompressionType.Ccitt4
|
||||||
|
? ImagePixelFormat.BW1
|
||||||
|
: image.LogicalPixelFormat;
|
||||||
|
WriteTiffMetadata(tiff, pixelFormat, compression, image);
|
||||||
|
WriteTiffImageData(tiff, pixelFormat, image);
|
||||||
|
if (images.Count > 1)
|
||||||
|
{
|
||||||
|
LibTiff.TIFFWriteDirectory(tiff);
|
||||||
|
}
|
||||||
|
progressCallback?.Invoke(++i, images.Count);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
LibTiff.TIFFClose(tiff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe void WriteTiffImageData(IntPtr tiff, ImagePixelFormat pixelFormat, IMemoryImage image)
|
||||||
|
{
|
||||||
|
var bufferInfo = new PixelInfo(image.Width, image.Height, pixelFormat switch
|
||||||
|
{
|
||||||
|
ImagePixelFormat.ARGB32 => SubPixelType.Rgba,
|
||||||
|
ImagePixelFormat.RGB24 => SubPixelType.Rgb,
|
||||||
|
ImagePixelFormat.Gray8 => SubPixelType.Gray,
|
||||||
|
ImagePixelFormat.BW1 => SubPixelType.Bit
|
||||||
|
});
|
||||||
|
var buffer = new byte[bufferInfo.Length];
|
||||||
|
new CopyBitwiseImageOp().Perform(image, buffer, bufferInfo);
|
||||||
|
fixed (byte* buf = buffer)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < image.Height; i++)
|
||||||
|
{
|
||||||
|
LibTiff.TIFFWriteScanline(tiff, (IntPtr) (buf + bufferInfo.Stride * i), i, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteTiffMetadata(IntPtr tiff, ImagePixelFormat pixelFormat,
|
||||||
|
TiffCompressionType compression, IMemoryImage image)
|
||||||
|
{
|
||||||
|
LibTiff.TIFFSetField(tiff, TiffTag.ImageWidth, image.Width);
|
||||||
|
LibTiff.TIFFSetField(tiff, TiffTag.ImageHeight, image.Height);
|
||||||
|
LibTiff.TIFFSetField(tiff, TiffTag.PlanarConfig, 1);
|
||||||
|
// TODO: Test setting g4 compression when it's not a BW image
|
||||||
|
LibTiff.TIFFSetField(tiff, TiffTag.Compression, (int) (compression switch
|
||||||
|
{
|
||||||
|
TiffCompressionType.Auto => pixelFormat == ImagePixelFormat.BW1
|
||||||
|
? TiffCompression.G4
|
||||||
|
: TiffCompression.Lzw,
|
||||||
|
TiffCompressionType.Ccitt4 => TiffCompression.G4,
|
||||||
|
TiffCompressionType.Lzw => TiffCompression.Lzw,
|
||||||
|
TiffCompressionType.None => TiffCompression.None
|
||||||
|
}));
|
||||||
|
LibTiff.TIFFSetField(tiff, TiffTag.Orientation, 1);
|
||||||
|
LibTiff.TIFFSetField(tiff, TiffTag.BitsPerSample, pixelFormat == ImagePixelFormat.BW1 ? 1 : 8);
|
||||||
|
LibTiff.TIFFSetField(tiff, TiffTag.SamplesPerPixel, pixelFormat switch
|
||||||
|
{
|
||||||
|
ImagePixelFormat.RGB24 => 3,
|
||||||
|
ImagePixelFormat.ARGB32 => 4,
|
||||||
|
_ => 1
|
||||||
|
});
|
||||||
|
LibTiff.TIFFSetField(tiff, TiffTag.Photometric, (int) (pixelFormat switch
|
||||||
|
{
|
||||||
|
ImagePixelFormat.RGB24 or ImagePixelFormat.ARGB32 => TiffPhotometric.Rgb,
|
||||||
|
_ => TiffPhotometric.MinIsBlack
|
||||||
|
}));
|
||||||
|
if (pixelFormat == ImagePixelFormat.ARGB32)
|
||||||
|
{
|
||||||
|
LibTiff.TIFFSetField(tiff, TiffTag.ExtraSamples, 1);
|
||||||
|
}
|
||||||
|
if (image.HorizontalResolution != 0 && image.VerticalResolution != 0)
|
||||||
|
{
|
||||||
|
LibTiff.TIFFSetField(tiff, TiffTag.ResolutionUnit, 2);
|
||||||
|
LibTiff.TIFFSetField(tiff, TiffTag.XResolution, (int) image.HorizontalResolution);
|
||||||
|
LibTiff.TIFFSetField(tiff, TiffTag.YResolution, (int) image.VerticalResolution);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private IEnumerable<IMemoryImage> LoadTiff(Stream stream, out int count)
|
private IEnumerable<IMemoryImage> LoadTiff(Stream stream, out int count)
|
||||||
{
|
{
|
||||||
var c = new LibTiffStreamClient(stream);
|
var client = new LibTiffStreamClient(stream);
|
||||||
var tiff = c.TIFFClientOpen("r");
|
var tiff = client.TIFFClientOpen("r");
|
||||||
count = LibTiff.TIFFNumberOfDirectories(tiff);
|
count = LibTiff.TIFFNumberOfDirectories(tiff);
|
||||||
return EnumerateTiffFrames(tiff, c);
|
return EnumerateTiffFrames(tiff, client);
|
||||||
}
|
}
|
||||||
|
|
||||||
private IEnumerable<IMemoryImage> LoadTiff(string path, out int count)
|
private IEnumerable<IMemoryImage> LoadTiff(string path, out int count)
|
||||||
{
|
{
|
||||||
var tiff = LibTiff.TIFFOpen(path, "r");
|
var tiff = LibTiff.TIFFOpen(path, "r");
|
||||||
count = LibTiff.TIFFNumberOfDirectories(tiff);
|
count = LibTiff.TIFFNumberOfDirectories(tiff);
|
||||||
return EnumerateTiffFrames(tiff, null);
|
return EnumerateTiffFrames(tiff);
|
||||||
}
|
}
|
||||||
|
|
||||||
private IEnumerable<IMemoryImage> EnumerateTiffFrames(IntPtr tiff, LibTiffStreamClient? client)
|
private IEnumerable<IMemoryImage> EnumerateTiffFrames(IntPtr tiff, LibTiffStreamClient? client = null)
|
||||||
{
|
{
|
||||||
// We keep a reference to the client to avoid garbage collection
|
// We keep a reference to the client to avoid garbage collection
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
do
|
do
|
||||||
{
|
{
|
||||||
LibTiff.TIFFGetField(tiff, 256, out var w);
|
LibTiff.TIFFGetField(tiff, TiffTag.ImageWidth, out var w);
|
||||||
LibTiff.TIFFGetField(tiff, 257, out var h);
|
LibTiff.TIFFGetField(tiff, TiffTag.ImageHeight, out var h);
|
||||||
var img = Create(w, h, ImagePixelFormat.ARGB32);
|
var img = Create(w, h, ImagePixelFormat.ARGB32);
|
||||||
img.OriginalFileFormat = ImageFileFormat.Tiff;
|
img.OriginalFileFormat = ImageFileFormat.Tiff;
|
||||||
using var imageLock = img.Lock(LockMode.WriteOnly, out var data);
|
using var imageLock = img.Lock(LockMode.WriteOnly, out var data);
|
||||||
|
@ -6,7 +6,7 @@ using tdata_t = System.IntPtr;
|
|||||||
|
|
||||||
namespace NAPS2.Images.Gtk;
|
namespace NAPS2.Images.Gtk;
|
||||||
|
|
||||||
public static class LibTiff
|
internal static class LibTiff
|
||||||
{
|
{
|
||||||
// TODO: String marshalling?
|
// TODO: String marshalling?
|
||||||
[DllImport("libtiff.so.5")]
|
[DllImport("libtiff.so.5")]
|
||||||
@ -54,7 +54,17 @@ public static class LibTiff
|
|||||||
public static extern int TIFFReadDirectory(IntPtr tiff);
|
public static extern int TIFFReadDirectory(IntPtr tiff);
|
||||||
|
|
||||||
[DllImport("libtiff.so.5")]
|
[DllImport("libtiff.so.5")]
|
||||||
public static extern int TIFFGetField(IntPtr tiff, int tag, out int field);
|
public static extern int TIFFWriteDirectory(IntPtr tiff);
|
||||||
|
|
||||||
|
[DllImport("libtiff.so.5")]
|
||||||
|
public static extern int TIFFGetField(IntPtr tiff, TiffTag tag, out int field);
|
||||||
|
|
||||||
|
[DllImport("libtiff.so.5")]
|
||||||
|
public static extern int TIFFSetField(IntPtr tiff, TiffTag tag, int field);
|
||||||
|
|
||||||
|
[DllImport("libtiff.so.5")]
|
||||||
|
public static extern int TIFFWriteScanline(
|
||||||
|
IntPtr tiff, tdata_t buf, int row, short sample);
|
||||||
|
|
||||||
[DllImport("libtiff.so.5")]
|
[DllImport("libtiff.so.5")]
|
||||||
public static extern int TIFFReadRGBAImage(
|
public static extern int TIFFReadRGBAImage(
|
||||||
|
@ -6,7 +6,7 @@ using tdata_t = System.IntPtr;
|
|||||||
|
|
||||||
namespace NAPS2.Images.Gtk;
|
namespace NAPS2.Images.Gtk;
|
||||||
|
|
||||||
public class LibTiffStreamClient
|
internal class LibTiffStreamClient
|
||||||
{
|
{
|
||||||
private readonly Stream _stream;
|
private readonly Stream _stream;
|
||||||
private readonly LibTiff.TIFFErrorHandler _error;
|
private readonly LibTiff.TIFFErrorHandler _error;
|
||||||
|
11
NAPS2.Images.Gtk/TiffCompression.cs
Normal file
11
NAPS2.Images.Gtk/TiffCompression.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
namespace NAPS2.Images.Gtk;
|
||||||
|
|
||||||
|
internal enum TiffCompression
|
||||||
|
{
|
||||||
|
None = 1,
|
||||||
|
G4 = 4,
|
||||||
|
Lzw = 5,
|
||||||
|
Jpeg = 7,
|
||||||
|
Deflate = 32946,
|
||||||
|
Lzma = 34925
|
||||||
|
}
|
7
NAPS2.Images.Gtk/TiffPhotometric.cs
Normal file
7
NAPS2.Images.Gtk/TiffPhotometric.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace NAPS2.Images.Gtk;
|
||||||
|
|
||||||
|
internal enum TiffPhotometric
|
||||||
|
{
|
||||||
|
MinIsBlack = 1,
|
||||||
|
Rgb = 2
|
||||||
|
}
|
22
NAPS2.Images.Gtk/TiffTag.cs
Normal file
22
NAPS2.Images.Gtk/TiffTag.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
namespace NAPS2.Images.Gtk;
|
||||||
|
|
||||||
|
internal enum TiffTag
|
||||||
|
{
|
||||||
|
SubFileType = 254,
|
||||||
|
ImageWidth = 256,
|
||||||
|
ImageHeight = 257,
|
||||||
|
BitsPerSample = 258,
|
||||||
|
Compression = 259,
|
||||||
|
Photometric = 262,
|
||||||
|
DocumentName = 269,
|
||||||
|
ImageDescription = 270,
|
||||||
|
Make = 271,
|
||||||
|
Model = 272,
|
||||||
|
Orientation = 274,
|
||||||
|
SamplesPerPixel = 277,
|
||||||
|
XResolution = 282,
|
||||||
|
YResolution = 283,
|
||||||
|
PlanarConfig = 284,
|
||||||
|
ResolutionUnit = 296,
|
||||||
|
ExtraSamples = 338
|
||||||
|
}
|
@ -162,6 +162,7 @@ public abstract class ImageContext
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public abstract IEnumerable<IMemoryImage> LoadFrames(string path, out int count);
|
public abstract IEnumerable<IMemoryImage> LoadFrames(string path, out int count);
|
||||||
|
|
||||||
|
// TODO: Instead of having these methods directly here, instead have a TiffWriter property
|
||||||
public abstract bool SaveTiff(IList<IMemoryImage> images, string path,
|
public abstract bool SaveTiff(IList<IMemoryImage> images, string path,
|
||||||
TiffCompressionType compression = TiffCompressionType.Auto, Action<int, int>? progressCallback = null,
|
TiffCompressionType compression = TiffCompressionType.Auto, Action<int, int>? progressCallback = null,
|
||||||
CancellationToken cancelToken = default);
|
CancellationToken cancelToken = default);
|
||||||
|
@ -6,7 +6,8 @@ namespace NAPS2.Sdk.Tests.Images;
|
|||||||
|
|
||||||
public class LoadSaveTests : ContextualTests
|
public class LoadSaveTests : ContextualTests
|
||||||
{
|
{
|
||||||
// TODO: Add tests for error/edge cases (e.g. invalid files, mismatched extensions/format (?), tiff progress)
|
// TODO: Add tests for error/edge cases (e.g. invalid files, mismatched extensions/format (?), tiff progress, saving 0/null images, unicode file names)
|
||||||
|
// TODO: Verify rough expected file sizes to ensure compression is as expected (especially for tiff saving)
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[MemberData(nameof(TestCases))]
|
[MemberData(nameof(TestCases))]
|
||||||
@ -89,12 +90,7 @@ public class LoadSaveTests : ContextualTests
|
|||||||
var original = LoadImage(ImageResources.color_image);
|
var original = LoadImage(ImageResources.color_image);
|
||||||
|
|
||||||
ImageContext.SaveTiff(new[] { original }, path);
|
ImageContext.SaveTiff(new[] { original }, path);
|
||||||
var actual = ImageContext.LoadFrames(path, out var count).ToArray();
|
AssertTiff(path, ImageResources.color_image);
|
||||||
|
|
||||||
Assert.Equal(1, count);
|
|
||||||
Assert.Single(actual);
|
|
||||||
Assert.Equal(ImageFileFormat.Tiff, actual[0].OriginalFileFormat);
|
|
||||||
ImageAsserts.Similar(ImageResources.color_image, actual[0]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@ -104,21 +100,12 @@ public class LoadSaveTests : ContextualTests
|
|||||||
var original = new[]
|
var original = new[]
|
||||||
{
|
{
|
||||||
LoadImage(ImageResources.color_image),
|
LoadImage(ImageResources.color_image),
|
||||||
LoadImage(ImageResources.color_image_h_p300),
|
LoadImage(ImageResources.color_image_bw).PerformTransform(new BlackWhiteTransform()),
|
||||||
LoadImage(ImageResources.stock_cat)
|
LoadImage(ImageResources.stock_cat)
|
||||||
};
|
};
|
||||||
|
|
||||||
ImageContext.SaveTiff(original, path);
|
ImageContext.SaveTiff(original, path);
|
||||||
var actual = ImageContext.LoadFrames(path, out var count).ToArray();
|
AssertTiff(path, ImageResources.color_image, ImageResources.color_image_bw, ImageResources.stock_cat);
|
||||||
|
|
||||||
Assert.Equal(3, count);
|
|
||||||
Assert.Equal(3, original.Length);
|
|
||||||
Assert.Equal(ImageFileFormat.Tiff, actual[0].OriginalFileFormat);
|
|
||||||
ImageAsserts.Similar(ImageResources.color_image, actual[0]);
|
|
||||||
Assert.Equal(ImageFileFormat.Tiff, actual[1].OriginalFileFormat);
|
|
||||||
ImageAsserts.Similar(ImageResources.color_image_h_p300, actual[1]);
|
|
||||||
Assert.Equal(ImageFileFormat.Tiff, actual[2].OriginalFileFormat);
|
|
||||||
ImageAsserts.Similar(ImageResources.stock_cat, actual[2]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@ -128,13 +115,7 @@ public class LoadSaveTests : ContextualTests
|
|||||||
var original = LoadImage(ImageResources.color_image);
|
var original = LoadImage(ImageResources.color_image);
|
||||||
|
|
||||||
ImageContext.SaveTiff(new[] { original }, stream);
|
ImageContext.SaveTiff(new[] { original }, stream);
|
||||||
stream.Seek(0, SeekOrigin.Begin);
|
AssertTiff(stream, ImageResources.color_image);
|
||||||
var actual = ImageContext.LoadFrames(stream, out var count).ToArray();
|
|
||||||
|
|
||||||
Assert.Equal(1, count);
|
|
||||||
Assert.Single(actual);
|
|
||||||
Assert.Equal(ImageFileFormat.Tiff, actual[0].OriginalFileFormat);
|
|
||||||
ImageAsserts.Similar(ImageResources.color_image, actual[0]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@ -144,22 +125,57 @@ public class LoadSaveTests : ContextualTests
|
|||||||
var original = new[]
|
var original = new[]
|
||||||
{
|
{
|
||||||
LoadImage(ImageResources.color_image),
|
LoadImage(ImageResources.color_image),
|
||||||
LoadImage(ImageResources.color_image_h_p300),
|
LoadImage(ImageResources.color_image_bw).PerformTransform(new BlackWhiteTransform()),
|
||||||
LoadImage(ImageResources.stock_cat)
|
LoadImage(ImageResources.stock_cat)
|
||||||
};
|
};
|
||||||
|
|
||||||
ImageContext.SaveTiff(original, stream);
|
ImageContext.SaveTiff(original, stream);
|
||||||
|
AssertTiff(stream, ImageResources.color_image, ImageResources.color_image_bw, ImageResources.stock_cat);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaveBlackAndWhiteTiff()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(FolderPath, "image.tiff");
|
||||||
|
var original = LoadImage(ImageResources.color_image_bw);
|
||||||
|
original = original.PerformTransform(new BlackWhiteTransform());
|
||||||
|
|
||||||
|
ImageContext.SaveTiff(new[] { original }, path);
|
||||||
|
AssertTiff(path, ImageResources.color_image_bw);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaveColorTiffWithG4()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(FolderPath, "image.tiff");
|
||||||
|
var original = LoadImage(ImageResources.color_image);
|
||||||
|
|
||||||
|
ImageContext.SaveTiff(new[] { original }, path, TiffCompressionType.Ccitt4);
|
||||||
|
AssertTiff(path, ImageResources.color_image_bw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AssertTiff(string path, params byte[][] expectedImages)
|
||||||
|
{
|
||||||
|
var actual = ImageContext.LoadFrames(path, out var count).ToArray();
|
||||||
|
DoAssertTiff(actual, count, expectedImages);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AssertTiff(Stream stream, params byte[][] expectedImages)
|
||||||
|
{
|
||||||
stream.Seek(0, SeekOrigin.Begin);
|
stream.Seek(0, SeekOrigin.Begin);
|
||||||
var actual = ImageContext.LoadFrames(stream, out var count).ToArray();
|
var actual = ImageContext.LoadFrames(stream, out var count).ToArray();
|
||||||
|
DoAssertTiff(actual, count, expectedImages);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(3, count);
|
private static void DoAssertTiff(IMemoryImage[] actual, int count, byte[][] expectedImages)
|
||||||
Assert.Equal(3, original.Length);
|
{
|
||||||
Assert.Equal(ImageFileFormat.Tiff, actual[0].OriginalFileFormat);
|
Assert.Equal(expectedImages.Length, count);
|
||||||
ImageAsserts.Similar(ImageResources.color_image, actual[0]);
|
Assert.Equal(expectedImages.Length, actual.Length);
|
||||||
Assert.Equal(ImageFileFormat.Tiff, actual[1].OriginalFileFormat);
|
for (int i = 0; i < expectedImages.Length; i++)
|
||||||
ImageAsserts.Similar(ImageResources.color_image_h_p300, actual[1]);
|
{
|
||||||
Assert.Equal(ImageFileFormat.Tiff, actual[2].OriginalFileFormat);
|
Assert.Equal(ImageFileFormat.Tiff, actual[i].OriginalFileFormat);
|
||||||
ImageAsserts.Similar(ImageResources.stock_cat, actual[2]);
|
ImageAsserts.Similar(expectedImages[i], actual[i]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] GetResource(string resource) =>
|
private static byte[] GetResource(string resource) =>
|
||||||
|
Loading…
Reference in New Issue
Block a user