From a5d73f58220be7acaaf9075ee9fe28cd94dcbb5a Mon Sep 17 00:00:00 2001 From: Henry Date: Tue, 21 Jun 2022 16:37:59 +0200 Subject: [PATCH 1/4] add imagesharp to qrcoder + tests using MSTests (Xunit has been dead since 2021). --- QRCoder.ImageSharp/ArtQRCode.cs | 237 ++++++++++++++++++ QRCoder.ImageSharp/QRCoder.ImageSharp.csproj | 18 ++ .../ArtQRCodeRendererTests.cs | 115 +++++++++ .../Helpers/HelperFunctions.cs | 94 +++++++ .../QRCoder.ImageSharpTests.csproj | 34 +++ QRCoder.ImageSharpTests/Usings.cs | 1 + .../assets/noun_Scientist_2909361.svg | 1 + .../assets/noun_software engineer_2909346.png | Bin 0 -> 3611 bytes QRCoder.sln | 36 +++ QRCoderTests/ArtQRCodeRendererTests.cs | 6 +- QRCoderTests/Helpers/HelperFunctions.cs | 1 + QRCoderTests/QRCoderTests.csproj | 3 + 12 files changed, 543 insertions(+), 3 deletions(-) create mode 100644 QRCoder.ImageSharp/ArtQRCode.cs create mode 100644 QRCoder.ImageSharp/QRCoder.ImageSharp.csproj create mode 100644 QRCoder.ImageSharpTests/ArtQRCodeRendererTests.cs create mode 100644 QRCoder.ImageSharpTests/Helpers/HelperFunctions.cs create mode 100644 QRCoder.ImageSharpTests/QRCoder.ImageSharpTests.csproj create mode 100644 QRCoder.ImageSharpTests/Usings.cs create mode 100644 QRCoder.ImageSharpTests/assets/noun_Scientist_2909361.svg create mode 100644 QRCoder.ImageSharpTests/assets/noun_software engineer_2909346.png diff --git a/QRCoder.ImageSharp/ArtQRCode.cs b/QRCoder.ImageSharp/ArtQRCode.cs new file mode 100644 index 00000000..74a8b9c2 --- /dev/null +++ b/QRCoder.ImageSharp/ArtQRCode.cs @@ -0,0 +1,237 @@ +#if NET5_0 || NET6_0 || NETSTANDARD2_1_OR_GREATER + +using System; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using static QRCoder.ImageSharp.ArtQRCode; +using static QRCoder.QRCodeGenerator; +// pull request raised to extend library used. +namespace QRCoder.ImageSharp +{ + public class ArtQRCode : AbstractQRCode, IDisposable + { + /// + /// Constructor without params to be used in COM Objects connections + /// + public ArtQRCode() { } + + /// + /// Creates new ArtQrCode object + /// + /// QRCodeData generated by the QRCodeGenerator + public ArtQRCode(QRCodeData data) : base(data) { } + + /// + /// Renders an art-style QR code with dots as modules. (With default settings: DarkColor=Black, LightColor=White, Background=Transparent, QuietZone=true) + /// + /// Amount of px each dark/light module of the QR code shall take place in the final QR code image + /// QRCode graphic as bitmap + public Image GetGraphic(int pixelsPerModule) + { + return this.GetGraphic(pixelsPerModule, Color.Black, Color.White, Color.Transparent); + } + + /// + /// Renders an art-style QR code with dots as modules and a background image (With default settings: DarkColor=Black, LightColor=White, Background=Transparent, QuietZone=true) + /// + /// A bitmap object that will be used as background picture + /// QRCode graphic as bitmap + public Image GetGraphic(Image? backgroundImage = null) + { + return this.GetGraphic(10, Color.Black, Color.White, Color.Transparent, backgroundImage: backgroundImage); + } + + /// + /// Renders an art-style QR code with dots as modules and various user settings + /// + /// Amount of px each dark/light module of the QR code shall take place in the final QR code image + /// Color of the dark modules + /// Color of the light modules + /// Color of the background + /// A bitmap object that will be used as background picture + /// Value between 0.0 to 1.0 that defines how big the module dots are. The bigger the value, the less round the dots will be. + /// If true a white border is drawn around the whole QR Code + /// Style of the quiet zones + /// Style of the background image (if set). Fill=spanning complete graphic; DataAreaOnly=Don't paint background into quietzone + /// Optional image that should be used instead of the default finder patterns + /// QRCode graphic as bitmap + public Image GetGraphic(int pixelsPerModule, Color darkColor, Color lightColor, Color backgroundColor, Image? backgroundImage = null, double pixelSizeFactor = 0.8, + bool drawQuietZones = true, QuietZoneStyle quietZoneRenderingStyle = QuietZoneStyle.Dotted, + BackgroundImageStyle backgroundImageStyle = BackgroundImageStyle.DataAreaOnly, Image? finderPatternImage = null) + { + if (pixelSizeFactor > 1) + throw new ArgumentException("The parameter pixelSize must be between 0 and 1. (0-100%)"); + + int pixelSize = (int)Math.Min(pixelsPerModule, Math.Floor(pixelsPerModule / pixelSizeFactor)); + + var numModules = QrCodeData.ModuleMatrix.Count - (drawQuietZones ? 0 : 8); + var offset = (drawQuietZones ? 0 : 4); + var size = numModules * pixelsPerModule; + + var image = new Image(size, size); + + var options = new DrawingOptions + { + GraphicsOptions = new GraphicsOptions + { + Antialias = true, + AntialiasSubpixelDepth = 2 + } + }; + + IBrush lightBrush = Brushes.Solid(lightColor); + IBrush darkBrush = Brushes.Solid(darkColor); + + IBrush backgroundBrush = Brushes.Solid(backgroundColor); + + //background rectangle: + IPath backgroundRectangle = new RectangularPolygon(0, 0, size, size); + + image.Mutate(x => x.Fill(options, brush: backgroundBrush, path: backgroundRectangle)); + + if(backgroundImage != null) + { + switch (backgroundImageStyle) + { + case BackgroundImageStyle.Fill: + backgroundImage = backgroundImage.Clone(x => x.Resize(size, size)); + image.Mutate(x => x.DrawImage(backgroundImage, new Point(0, 0), 1)); + break; + case BackgroundImageStyle.DataAreaOnly: + var bgOffset = 4 - offset; + backgroundImage = backgroundImage.Clone(x => x.Resize(size - (2 * bgOffset * pixelsPerModule), size - (2 * bgOffset * pixelsPerModule))); + image.Mutate(x => x.DrawImage(backgroundImage, new Point(bgOffset * pixelsPerModule, bgOffset * pixelsPerModule), 1)); + break; + } + } + + for (var x = 0; x < numModules; x += 1) + { + for (var y = 0; y < numModules; y += 1) + { + var rectangleF = new RectangularPolygon(x * pixelsPerModule, y * pixelsPerModule, pixelsPerModule, pixelsPerModule); + var elipse = new EllipsePolygon(x * pixelsPerModule + pixelSize / 2, y * pixelsPerModule + pixelSize / 2, pixelsPerModule, pixelsPerModule); + + var pixelIsDark = this.QrCodeData.ModuleMatrix[offset + y][offset + x]; + var solidBrush = pixelIsDark ? darkBrush : lightBrush; + //var pixelImage = pixelIsDark ? darkModulePixel : lightModulePixel; + + if (!IsPartOfFinderPattern(x, y, numModules, offset)) + if (drawQuietZones && quietZoneRenderingStyle == QuietZoneStyle.Flat && IsPartOfQuietZone(x, y, numModules)) + image.Mutate(im => im.Fill(options, solidBrush, rectangleF)); + else + image.Mutate(im => im.Fill(options, solidBrush, elipse)); + else if (finderPatternImage == null) + image.Mutate(im => im.Fill(options, solidBrush, rectangleF)); + } + } + + if (finderPatternImage != null) + { + var finderPatternSize = 7 * pixelsPerModule; + + finderPatternImage = finderPatternImage.Clone(x => x.Resize(finderPatternSize, finderPatternSize)); + + image.Mutate(x => x.DrawImage(finderPatternImage, 1)); //default position is 0,0 //new Rectangle(0, 0, finderPatternSize, finderPatternSize) + image.Mutate(x => x.DrawImage(finderPatternImage, new Point(size - finderPatternSize, 0), 1)); + image.Mutate(x => x.DrawImage(finderPatternImage, new Point(0, size - finderPatternSize), 1)); + //graphics.DrawImage(finderPatternImage, new Rectangle(0, size - finderPatternSize, finderPatternSize, finderPatternSize)); + } + return image; + } + + /// + /// Checks if a given module(-position) is part of the quietzone of a QR code + /// + /// X position + /// Y position + /// Total number of modules per row + /// true, if position is part of quiet zone + private bool IsPartOfQuietZone(int x, int y, int numModules) + { + return + x < 4 || //left + y < 4 || //top + x > numModules - 5 || //right + y > numModules - 5; //bottom + } + + + /// + /// Checks if a given module(-position) is part of one of the three finder patterns of a QR code + /// + /// X position + /// Y position + /// Total number of modules per row + /// Offset in modules (usually depending on drawQuietZones parameter) + /// true, if position is part of any finder pattern + private bool IsPartOfFinderPattern(int x, int y, int numModules, int offset) + { + var cornerSize = 11 - offset; + var outerLimitLow = (numModules - cornerSize - 1); + var outerLimitHigh = outerLimitLow + 8; + var invertedOffset = 4 - offset; + return + (x >= invertedOffset && x < cornerSize && y >= invertedOffset && y < cornerSize) || //Top-left finder pattern + (x > outerLimitLow && x < outerLimitHigh && y >= invertedOffset && y < cornerSize) || //Top-right finder pattern + (x >= invertedOffset && x < cornerSize && y > outerLimitLow && y < outerLimitHigh); //Bottom-left finder pattern + } + + /// + /// Defines how the quiet zones shall be rendered. + /// + public enum QuietZoneStyle + { + Dotted, + Flat + } + + /// + /// Defines how the background image (if set) shall be rendered. + /// + public enum BackgroundImageStyle + { + Fill, + DataAreaOnly + } + } + + public static class ArtQRCodeHelper + { + /// + /// Helper function to create an ArtQRCode graphic with a single function call + /// + /// Text/payload to be encoded inside the QR code + /// Amount of px each dark/light module of the QR code shall take place in the final QR code image + /// Color of the dark modules + /// Color of the light modules + /// Color of the background + /// The level of error correction data + /// Shall the generator be forced to work in UTF-8 mode? + /// Should the byte-order-mark be used? + /// Which ECI mode shall be used? + /// Set fixed QR code target version. + /// A bitmap object that will be used as background picture + /// Value between 0.0 to 1.0 that defines how big the module dots are. The bigger the value, the less round the dots will be. + /// If true a white border is drawn around the whole QR Code + /// Style of the quiet zones + /// Style of the background image (if set). Fill=spanning complete graphic; DataAreaOnly=Don't paint background into quietzone + /// Optional image that should be used instead of the default finder patterns + /// QRCode graphic as bitmap + public static Image GetQRCode(string plainText, int pixelsPerModule, Color darkColor, Color lightColor, Color backgroundColor, ECCLevel eccLevel, bool forceUtf8 = false, + bool utf8BOM = false, EciMode eciMode = EciMode.Default, int requestedVersion = -1, Image? backgroundImage = null, double pixelSizeFactor = 0.8, + bool drawQuietZones = true, QuietZoneStyle quietZoneRenderingStyle = QuietZoneStyle.Flat, + BackgroundImageStyle backgroundImageStyle = BackgroundImageStyle.DataAreaOnly, Image? finderPatternImage = null) + { + using (var qrGenerator = new QRCodeGenerator()) + using (var qrCodeData = qrGenerator.CreateQrCode(plainText, eccLevel, forceUtf8, utf8BOM, eciMode, requestedVersion)) + using (var qrCode = new ArtQRCode(qrCodeData)) + return qrCode.GetGraphic(pixelsPerModule, darkColor, lightColor, backgroundColor, backgroundImage, pixelSizeFactor, drawQuietZones, quietZoneRenderingStyle, backgroundImageStyle, finderPatternImage); + } + } +} + +#endif \ No newline at end of file diff --git a/QRCoder.ImageSharp/QRCoder.ImageSharp.csproj b/QRCoder.ImageSharp/QRCoder.ImageSharp.csproj new file mode 100644 index 00000000..d71d2701 --- /dev/null +++ b/QRCoder.ImageSharp/QRCoder.ImageSharp.csproj @@ -0,0 +1,18 @@ + + + + netstandard2.1;net5.0;net5.0-windows;net6.0;net6.0-windows + disable + enable + + + + + + + + + + + + diff --git a/QRCoder.ImageSharpTests/ArtQRCodeRendererTests.cs b/QRCoder.ImageSharpTests/ArtQRCodeRendererTests.cs new file mode 100644 index 00000000..20d2fd63 --- /dev/null +++ b/QRCoder.ImageSharpTests/ArtQRCodeRendererTests.cs @@ -0,0 +1,115 @@ + +#if !NET35 && NET6_0 +using QRCoder; +using QRCoderTests.Helpers; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using FluentAssertions; +#if NET6_0 +using QRCoder.ImageSharp; +#endif + +namespace QRCoderTests +{ + [TestClass] + public class ImageSharpArtQRCodeRendererTests + { + + + [TestMethod] + [TestCategory("QRRenderer/ArtQRCode")] + public void can_create_standard_qrcode_graphic() + { + var gen = new QRCodeGenerator(); + var data = gen.CreateQrCode("This is a quick test! 123#?", QRCodeGenerator.ECCLevel.H); + var bmp = new ArtQRCode(data).GetGraphic(10); + var result = HelperFunctions.ImageToHash(bmp); + //bmp.SaveAsPng("qrcode_imagesharp.png"); + result.Should().Be("e8de533db63b5784de075c4e4cc3e0c9"); //different hash than the System.Drawing example since the algorithm is slighty different -> (anti-aliasing). + } + + [TestMethod] + [TestCategory("QRRenderer/ArtQRCode")] + public void can_create_standard_qrcode_graphic_with_custom_finder() + { + var gen = new QRCodeGenerator(); + var data = gen.CreateQrCode("This is a quick test! 123#?", QRCodeGenerator.ECCLevel.H); + var finder = new Image(15, 15); + var bmp = new ArtQRCode(data).GetGraphic(10, Color.Black, Color.White, Color.Transparent, finderPatternImage: finder); + //bmp.SaveAsPng("finder_custom.png"); + var result = HelperFunctions.ImageToHash(bmp); + + result.Should().Be("63dcb31fd8910a10aba57929b6327790"); + + } + + [TestMethod] + [TestCategory("QRRenderer/ArtQRCode")] + public void can_create_standard_qrcode_graphic_without_quietzone() + { + var gen = new QRCodeGenerator(); + var data = gen.CreateQrCode("This is a quick test! 123#?", QRCodeGenerator.ECCLevel.H); + var bmp = new ArtQRCode(data).GetGraphic(10, Color.Black, Color.White, Color.Transparent, drawQuietZones: false); + + var result = HelperFunctions.ImageToHash(bmp); + + //bmp.SaveAsPng("without_quietzone.png"); + + result.Should().Be("d96f9c8c64cdb5c651dd59bab6b564d7"); + + } + + [TestMethod] + [TestCategory("QRRenderer/ArtQRCode")] + public void can_create_standard_qrcode_graphic_with_background() + { + var gen = new QRCodeGenerator(); + var data = gen.CreateQrCode("This is a quick test! 123#?", QRCodeGenerator.ECCLevel.H); + var bmp = new ArtQRCode(data).GetGraphic(Image.Load(HelperFunctions.GetAssemblyPath() + "\\assets\\noun_software engineer_2909346.png")); + //Used logo is licensed under public domain. Ref.: https://thenounproject.com/Iconathon1/collection/redefining-women/?i=2909346 + + var result = HelperFunctions.ImageToHash(bmp); + + bmp.SaveAsPng("custom_background.png"); + + result.Should().Be("aea31c69506b0d933fd49205e7b37f33"); + + } + + [TestMethod] + [TestCategory("QRRenderer/ArtQRCode")] + public void should_throw_pixelfactor_oor_exception() + { + var gen = new QRCodeGenerator(); + var data = gen.CreateQrCode("This is a quick test! 123#?", QRCodeGenerator.ECCLevel.H); + var aCode = new ArtQRCode(data); + + var exception = Assert.ThrowsException(() => aCode.GetGraphic(10, Color.Black, Color.White, Color.Transparent, pixelSizeFactor: 2)); + + exception.Message.Should().Be("The parameter pixelSize must be between 0 and 1. (0-100%)"); + } + + [TestMethod] + [TestCategory("QRRenderer/ArtQRCode")] + public void can_instantate_parameterless() + { + var asciiCode = new ArtQRCode(); + asciiCode.Should().NotBeNull(); + asciiCode.Should().BeOfType(); + } + + [TestMethod] + [TestCategory("QRRenderer/ArtQRCode")] + public void can_render_artqrcode_from_helper() + { + //Create QR code + var bmp = ArtQRCodeHelper.GetQRCode("A", 10, Color.Black, Color.White, Color.Transparent, QRCodeGenerator.ECCLevel.L); + + var result = HelperFunctions.ImageToHash(bmp); + //bmp.SaveAsPng("Helper.png"); + result.Should().Be("1dbda9e61f832a7ebdb5d97f8c6e8fb6"); + + } + } +} +#endif \ No newline at end of file diff --git a/QRCoder.ImageSharpTests/Helpers/HelperFunctions.cs b/QRCoder.ImageSharpTests/Helpers/HelperFunctions.cs new file mode 100644 index 00000000..e46e1ddd --- /dev/null +++ b/QRCoder.ImageSharpTests/Helpers/HelperFunctions.cs @@ -0,0 +1,94 @@ +using System; +using System.Text; +using System.IO; +using System.Security.Cryptography; +#if NET6_0_OR_GREATER +using SixLabors.ImageSharp.Formats.Png; +#endif + +#if !NETCOREAPP1_1 +using System.Drawing; +#endif +#if NETFRAMEWORK || NET5_0_WINDOWS +using SW = System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +#endif + + +namespace QRCoderTests.Helpers +{ + public static class HelperFunctions + { + +#if NETFRAMEWORK || NET5_0_WINDOWS + public static BitmapSource ToBitmapSource(DrawingImage source) + { + DrawingVisual drawingVisual = new DrawingVisual(); + DrawingContext drawingContext = drawingVisual.RenderOpen(); + drawingContext.DrawImage(source, new SW.Rect(new SW.Point(0, 0), new SW.Size(source.Width, source.Height))); + drawingContext.Close(); + + RenderTargetBitmap bmp = new RenderTargetBitmap((int)source.Width, (int)source.Height, 96, 96, PixelFormats.Pbgra32); + bmp.Render(drawingVisual); + return bmp; + } + + public static Bitmap BitmapSourceToBitmap(DrawingImage xamlImg) + { + using (MemoryStream ms = new MemoryStream()) + { + PngBitmapEncoder encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(ToBitmapSource(xamlImg))); + encoder.Save(ms); + + using (Bitmap bmp = new Bitmap(ms)) + { + return new Bitmap(bmp); + } + } + } +#endif + +#if !NETCOREAPP1_1 + public static string GetAssemblyPath() + { + return +#if NET5_0 || NET6_0 + AppDomain.CurrentDomain.BaseDirectory; +#elif NET35 || NET452 + Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().CodeBase).Replace("file:\\", ""); +#else + Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location).Replace("file:\\", ""); +#endif + } +#endif + + +#if NET6_0 + public static string ImageToHash(SixLabors.ImageSharp.Image image) + { + using var ms = new MemoryStream(); + image.Save(ms, new PngEncoder()); + byte[] imgBytes = ms.ToArray(); + return ByteArrayToHash(imgBytes); + } +#endif + + public static string ByteArrayToHash(byte[] data) + { +#if !NETCOREAPP1_1 + var md5 = MD5.Create(); + var hash = md5.ComputeHash(data); +#else + var hash = new SshNet.Security.Cryptography.MD5().ComputeHash(data); +#endif + return BitConverter.ToString(hash).Replace("-", "").ToLower(); + } + + public static string StringToHash(string data) + { + return ByteArrayToHash(Encoding.UTF8.GetBytes(data)); + } + } +} diff --git a/QRCoder.ImageSharpTests/QRCoder.ImageSharpTests.csproj b/QRCoder.ImageSharpTests/QRCoder.ImageSharpTests.csproj new file mode 100644 index 00000000..c44cde09 --- /dev/null +++ b/QRCoder.ImageSharpTests/QRCoder.ImageSharpTests.csproj @@ -0,0 +1,34 @@ + + + + net6.0 + enable + enable + + false + + + + + + + + + + + + + + + + + + + Always + + + Always + + + + diff --git a/QRCoder.ImageSharpTests/Usings.cs b/QRCoder.ImageSharpTests/Usings.cs new file mode 100644 index 00000000..ab67c7ea --- /dev/null +++ b/QRCoder.ImageSharpTests/Usings.cs @@ -0,0 +1 @@ +global using Microsoft.VisualStudio.TestTools.UnitTesting; \ No newline at end of file diff --git a/QRCoder.ImageSharpTests/assets/noun_Scientist_2909361.svg b/QRCoder.ImageSharpTests/assets/noun_Scientist_2909361.svg new file mode 100644 index 00000000..869a0e73 --- /dev/null +++ b/QRCoder.ImageSharpTests/assets/noun_Scientist_2909361.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/QRCoder.ImageSharpTests/assets/noun_software engineer_2909346.png b/QRCoder.ImageSharpTests/assets/noun_software engineer_2909346.png new file mode 100644 index 0000000000000000000000000000000000000000..bc100986ce64bfca32ac724284be9ea5a20a9a8d GIT binary patch literal 3611 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 hAlways + + + $(MSBuildProgramFiles32)\Reference Assemblies\Microsoft\Framework\.NETFramework\v3.5\Profile\Client false From 5a4a0700f9eeeb1caea8efae792efd7f5f4ad7b2 Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 22 Jun 2022 11:47:58 +0200 Subject: [PATCH 2/4] added an image sharp qr code with logo options. --- QRCoder.ImageSharp/ImageSharpQRCode.cs | 212 ++++++++++++++++++ .../ImageSharpQrCodeTests.cs | 35 +++ QRCoder.sln | 10 +- QRCoderTests/SvgQRCodeRendererTests.cs | 2 +- 4 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 QRCoder.ImageSharp/ImageSharpQRCode.cs create mode 100644 QRCoder.ImageSharpTests/ImageSharpQrCodeTests.cs diff --git a/QRCoder.ImageSharp/ImageSharpQRCode.cs b/QRCoder.ImageSharp/ImageSharpQRCode.cs new file mode 100644 index 00000000..a28b5f6c --- /dev/null +++ b/QRCoder.ImageSharp/ImageSharpQRCode.cs @@ -0,0 +1,212 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System; +using System.Collections.Generic; +using System.Text; + +namespace QRCoder.ImageSharp +{ + public enum LogoLocation + { + Center, + BottomRight + } + + public enum LogoBackgroundShape + { + Circle, + Rectangle + } + public class ImageSharpQRCode : AbstractQRCode, IDisposable + { + public ImageSharpQRCode() + { + } + + public ImageSharpQRCode(QRCodeData data) : base(data) + { + } + + /// + /// Renders an art-style QR code with dots as modules. (With default settings: DarkColor=Black, LightColor=White, Background=Transparent, QuietZone=true) + /// + /// Amount of px each dark/light module of the QR code shall take place in the final QR code image + /// QRCode graphic as bitmap + public Image GetGraphic(int pixelsPerModule, Image? logoImage = null, LogoLocation logoLocation = LogoLocation.BottomRight, LogoBackgroundShape logoBackgroundShape = LogoBackgroundShape.Rectangle) + { + return this.GetGraphic(pixelsPerModule, Color.Black, Color.White, Color.Transparent, logoImage, logoLocation, logoBackgroundShape); + } + + /// + /// Renders an art-style QR code with dots as modules and a background image (With default settings: DarkColor=Black, LightColor=White, Background=Transparent, QuietZone=true) + /// + /// A bitmap object that will be used as background picture + /// QRCode graphic as bitmap + public Image GetGraphic(Image? logoImage = null) + { + return this.GetGraphic(10, Color.Black, Color.White, Color.Transparent, logoImage); + } + + /// + /// Renders an art-style QR code with dots as modules and various user settings + /// + /// Amount of px each dark/light module of the QR code shall take place in the final QR code image + /// Color of the dark modules + /// Color of the light modules + /// Color of the background + /// A bitmap object that will be used as background picture + /// Value between 0.0 to 1.0 that defines how big the module dots are. The bigger the value, the less round the dots will be. + /// If true a white border is drawn around the whole QR Code + /// Style of the quiet zones + /// Style of the background image (if set). Fill=spanning complete graphic; DataAreaOnly=Don't paint background into quietzone + /// Optional image that should be used instead of the default finder patterns + /// QRCode graphic as bitmap + public Image GetGraphic(int pixelsPerModule, Color darkColor, Color lightColor, Color backgroundColor, Image? logoImage = null, LogoLocation logoLocation = LogoLocation.BottomRight, LogoBackgroundShape logoBackgroundShape = LogoBackgroundShape.Circle, double pixelSizeFactor = 0.8, + bool drawQuietZones = true) + { + if (pixelSizeFactor > 1) + throw new ArgumentException("The parameter pixelSize must be between 0 and 1. (0-100%)"); + + int pixelSize = (int)Math.Min(pixelsPerModule, Math.Floor(pixelsPerModule / pixelSizeFactor)); + + var numModules = QrCodeData.ModuleMatrix.Count - (drawQuietZones ? 0 : 8); + var offset = (drawQuietZones ? 0 : 4); + var size = numModules * pixelsPerModule; + + var image = new Image(size, size); + + var options = new DrawingOptions + { + GraphicsOptions = new GraphicsOptions + { + Antialias = true, + AntialiasSubpixelDepth = 2 + } + }; + + IBrush lightBrush = Brushes.Solid(lightColor); + IBrush darkBrush = Brushes.Solid(darkColor); + + IBrush backgroundBrush = Brushes.Solid(backgroundColor); + + //background rectangle: + IPath backgroundRectangle = new RectangularPolygon(0, 0, size, size); + + image.Mutate(x => x.Fill(options, brush: backgroundBrush, path: backgroundRectangle)); + + //if (backgroundImage != null) + //{ + // switch (backgroundImageStyle) + // { + // case BackgroundImageStyle.Fill: + // backgroundImage = backgroundImage.Clone(x => x.Resize(size, size)); + // image.Mutate(x => x.DrawImage(backgroundImage, new Point(0, 0), 1)); + // break; + // case BackgroundImageStyle.DataAreaOnly: + // var bgOffset = 4 - offset; + // backgroundImage = backgroundImage.Clone(x => x.Resize(size - (2 * bgOffset * pixelsPerModule), size - (2 * bgOffset * pixelsPerModule))); + // image.Mutate(x => x.DrawImage(backgroundImage, new Point(bgOffset * pixelsPerModule, bgOffset * pixelsPerModule), 1)); + // break; + // } + //} + + for (var x = 0; x < numModules; x += 1) + { + for (var y = 0; y < numModules; y += 1) + { + var rectangleF = new RectangularPolygon(x * pixelsPerModule, y * pixelsPerModule, pixelsPerModule, pixelsPerModule); + + var pixelIsDark = this.QrCodeData.ModuleMatrix[offset + y][offset + x]; + var solidBrush = pixelIsDark ? darkBrush : lightBrush; + //var pixelImage = pixelIsDark ? darkModulePixel : lightModulePixel; + + if (!IsPartOfFinderPattern(x, y, numModules, offset)) + if (drawQuietZones && IsPartOfQuietZone(x, y, numModules)) + image.Mutate(im => im.Fill(options, solidBrush, rectangleF)); + else + image.Mutate(im => im.Fill(options, solidBrush, rectangleF)); + else + image.Mutate(im => im.Fill(options, solidBrush, rectangleF)); + } + } + + if(logoImage != null) + { + var logoSize = (int)(size * 0.15); + var logoOffset = drawQuietZones ? 4 : 0; + var locationPadding = logoLocation switch + { + LogoLocation.Center => new PointF(size / 2, size / 2), + _ => new Point(size - logoSize / 2 - logoOffset * pixelsPerModule, size - logoSize / 2 - logoOffset * pixelsPerModule), + }; + + + var locationLogo = logoLocation switch + { + LogoLocation.Center => new Point(size / 2 - logoSize / 2, size / 2 - logoSize / 2), + _ => new Point(size - logoSize - logoOffset * pixelsPerModule, size - logoSize - logoOffset * pixelsPerModule), + }; + + + IPath backgroundShape = logoBackgroundShape switch + { + LogoBackgroundShape.Circle => new EllipsePolygon(locationPadding, logoSize / 2), + _ => logoLocation == LogoLocation.Center ? new RectangularPolygon(size / 2 - logoSize/2, size /2 - logoSize /2, logoSize, logoSize) : new RectangularPolygon(size - logoSize - logoOffset * pixelsPerModule, size - logoSize - logoOffset * pixelsPerModule, logoSize, logoSize), + }; + + backgroundShape = backgroundShape.Scale(1.03f); + + image.Mutate(x => x.Fill(lightBrush, backgroundShape)); + + logoImage = logoImage.Clone(x => + { + x.Resize(logoSize, logoSize); + + }); + image.Mutate(x => x.DrawImage(logoImage, locationLogo, 1)); + } + + return image; + } + + /// + /// Checks if a given module(-position) is part of the quietzone of a QR code + /// + /// X position + /// Y position + /// Total number of modules per row + /// true, if position is part of quiet zone + private bool IsPartOfQuietZone(int x, int y, int numModules) + { + return + x < 4 || //left + y < 4 || //top + x > numModules - 5 || //right + y > numModules - 5; //bottom + } + + + /// + /// Checks if a given module(-position) is part of one of the three finder patterns of a QR code + /// + /// X position + /// Y position + /// Total number of modules per row + /// Offset in modules (usually depending on drawQuietZones parameter) + /// true, if position is part of any finder pattern + private bool IsPartOfFinderPattern(int x, int y, int numModules, int offset) + { + var cornerSize = 11 - offset; + var outerLimitLow = (numModules - cornerSize - 1); + var outerLimitHigh = outerLimitLow + 8; + var invertedOffset = 4 - offset; + return + (x >= invertedOffset && x < cornerSize && y >= invertedOffset && y < cornerSize) || //Top-left finder pattern + (x > outerLimitLow && x < outerLimitHigh && y >= invertedOffset && y < cornerSize) || //Top-right finder pattern + (x >= invertedOffset && x < cornerSize && y > outerLimitLow && y < outerLimitHigh); //Bottom-left finder pattern + } + } +} diff --git a/QRCoder.ImageSharpTests/ImageSharpQrCodeTests.cs b/QRCoder.ImageSharpTests/ImageSharpQrCodeTests.cs new file mode 100644 index 00000000..2d02ed19 --- /dev/null +++ b/QRCoder.ImageSharpTests/ImageSharpQrCodeTests.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using QRCoder.ImageSharp; +using QRCoderTests.Helpers; +using SixLabors.ImageSharp; + +namespace QRCoder.ImageSharpTests +{ + [TestClass] + public class ImageSharpQrCodeTests + { + [TestMethod] + public void TestRegularQrCode() + { + var gen = new QRCodeGenerator(); + var data = gen.CreateQrCode("This is a quick test! 123#?", QRCodeGenerator.ECCLevel.H); + var qrcode = new ImageSharpQRCode(data); + var image = qrcode.GetGraphic(); + image.SaveAsPng("imageSharpQr.png"); + } + + [TestMethod] + public void TestLogoQrCode() + { + var gen = new QRCodeGenerator(); + var data = gen.CreateQrCode("This is a quick test! 123#?", QRCodeGenerator.ECCLevel.H); + var qrcode = new ImageSharpQRCode(data); + var image = qrcode.GetGraphic(60, Image.Load(HelperFunctions.GetAssemblyPath() + "\\assets\\noun_software engineer_2909346.png"), LogoLocation.Center, LogoBackgroundShape.Circle); + image.SaveAsPng("imageSharpQr_logo.png"); + } + } +} diff --git a/QRCoder.sln b/QRCoder.sln index 43b17b25..41ba7419 100644 --- a/QRCoder.sln +++ b/QRCoder.sln @@ -15,9 +15,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "QRCoderTests", "QRCoderTest EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "QRCoder.Xaml", "QRCoder.Xaml\QRCoder.Xaml.csproj", "{A7A7E073-2504-4BA2-A63B-87AC34174789}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QRCoder.ImageSharp", "QRCoder.ImageSharp\QRCoder.ImageSharp.csproj", "{229BC36C-54D6-43FC-9D82-EE0D6FD9883C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "QRCoder.ImageSharp", "QRCoder.ImageSharp\QRCoder.ImageSharp.csproj", "{229BC36C-54D6-43FC-9D82-EE0D6FD9883C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QRCoder.ImageSharpTests", "QRCoder.ImageSharpTests\QRCoder.ImageSharpTests.csproj", "{F5596C43-DDB9-47BC-BCA0-2308A59F630B}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "QRCoder.ImageSharpTests", "QRCoder.ImageSharpTests\QRCoder.ImageSharpTests.csproj", "{F5596C43-DDB9-47BC-BCA0-2308A59F630B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{3E51E726-E6AB-492B-A37D-DF1CE024D54F}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -159,6 +161,10 @@ Global GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {1B51624B-9915-4ED6-8FC1-1B7C472246E5} = {3E51E726-E6AB-492B-A37D-DF1CE024D54F} + {F5596C43-DDB9-47BC-BCA0-2308A59F630B} = {3E51E726-E6AB-492B-A37D-DF1CE024D54F} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F1845CDF-5EE5-456F-B6C8-717E4E2284F4} EndGlobalSection diff --git a/QRCoderTests/SvgQRCodeRendererTests.cs b/QRCoderTests/SvgQRCodeRendererTests.cs index b0620594..709cd618 100644 --- a/QRCoderTests/SvgQRCodeRendererTests.cs +++ b/QRCoderTests/SvgQRCodeRendererTests.cs @@ -117,7 +117,7 @@ public void can_render_svg_qrcode_with_png_logo() logoObj.GetMediaType().ShouldBe(SvgQRCode.SvgLogo.MediaType.PNG); var svg = new SvgQRCode(data).GetGraphic(10, Color.DarkGray, Color.White, logo: logoObj); - + var result = HelperFunctions.StringToHash(svg); result.ShouldBe("78e02e8ba415f15817d5ed88c4afca31"); } From 344d9bd3a9ac0dfefe80f3c405c325355e54e705 Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 22 Jun 2022 19:57:15 +0200 Subject: [PATCH 3/4] added background color --- QRCoder.ImageSharp/ImageSharpQRCode.cs | 2 +- QRCoder.ImageSharp/QRCoder.ImageSharp.csproj | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/QRCoder.ImageSharp/ImageSharpQRCode.cs b/QRCoder.ImageSharp/ImageSharpQRCode.cs index a28b5f6c..6b05011a 100644 --- a/QRCoder.ImageSharp/ImageSharpQRCode.cs +++ b/QRCoder.ImageSharp/ImageSharpQRCode.cs @@ -159,7 +159,7 @@ public Image GetGraphic(int pixelsPerModule, Color darkColor, Color lightColor, backgroundShape = backgroundShape.Scale(1.03f); - image.Mutate(x => x.Fill(lightBrush, backgroundShape)); + image.Mutate(x => x.Fill(backgroundBrush, backgroundShape)); logoImage = logoImage.Clone(x => { diff --git a/QRCoder.ImageSharp/QRCoder.ImageSharp.csproj b/QRCoder.ImageSharp/QRCoder.ImageSharp.csproj index d71d2701..3f179b4c 100644 --- a/QRCoder.ImageSharp/QRCoder.ImageSharp.csproj +++ b/QRCoder.ImageSharp/QRCoder.ImageSharp.csproj @@ -4,6 +4,7 @@ netstandard2.1;net5.0;net5.0-windows;net6.0;net6.0-windows disable enable + 1.0.1-dev From 521a49f356c07b8a2f7d6c6ae712a84c205dbb48 Mon Sep 17 00:00:00 2001 From: Henry Date: Thu, 23 Jun 2022 15:26:37 +0200 Subject: [PATCH 4/4] added skia sharp support to qr code renderers --- QRCoder.ImageSharp/ImageSharpQRCode.cs | 15 +- .../ImageSharpQrCodeTests.cs | 1 + .../QRCoder.ImageSharpTests.csproj | 1 + .../Extensions/SaveExtensions.cs | 36 +++ QRCoder.SkiaSharp/QRCoder.SkiaSharp.csproj | 18 ++ QRCoder.SkiaSharp/SkiaSharpQRCode.cs | 222 ++++++++++++++++++ .../Helpers/HelperFunctions.cs | 91 +++++++ .../QRCoder.SkiaSharpTests.csproj | 31 +++ QRCoder.SkiaSharpTests/SkiaSharpTests.cs | 43 ++++ QRCoder.SkiaSharpTests/Usings.cs | 1 + .../assets/noun_Scientist_2909361.svg | 1 + .../assets/noun_software engineer_2909346.png | Bin 0 -> 3611 bytes QRCoder.sln | 37 +++ QRCoder/Models/LogoBackgroundShape.cs | 13 + QRCoder/Models/LogoLocation.cs | 13 + 15 files changed, 511 insertions(+), 12 deletions(-) create mode 100644 QRCoder.SkiaSharp/Extensions/SaveExtensions.cs create mode 100644 QRCoder.SkiaSharp/QRCoder.SkiaSharp.csproj create mode 100644 QRCoder.SkiaSharp/SkiaSharpQRCode.cs create mode 100644 QRCoder.SkiaSharpTests/Helpers/HelperFunctions.cs create mode 100644 QRCoder.SkiaSharpTests/QRCoder.SkiaSharpTests.csproj create mode 100644 QRCoder.SkiaSharpTests/SkiaSharpTests.cs create mode 100644 QRCoder.SkiaSharpTests/Usings.cs create mode 100644 QRCoder.SkiaSharpTests/assets/noun_Scientist_2909361.svg create mode 100644 QRCoder.SkiaSharpTests/assets/noun_software engineer_2909346.png create mode 100644 QRCoder/Models/LogoBackgroundShape.cs create mode 100644 QRCoder/Models/LogoLocation.cs diff --git a/QRCoder.ImageSharp/ImageSharpQRCode.cs b/QRCoder.ImageSharp/ImageSharpQRCode.cs index 6b05011a..58e5e2cd 100644 --- a/QRCoder.ImageSharp/ImageSharpQRCode.cs +++ b/QRCoder.ImageSharp/ImageSharpQRCode.cs @@ -1,4 +1,5 @@ -using SixLabors.ImageSharp; +using QRCoder.Models; +using SixLabors.ImageSharp; using SixLabors.ImageSharp.Drawing; using SixLabors.ImageSharp.Drawing.Processing; using SixLabors.ImageSharp.PixelFormats; @@ -9,17 +10,7 @@ namespace QRCoder.ImageSharp { - public enum LogoLocation - { - Center, - BottomRight - } - - public enum LogoBackgroundShape - { - Circle, - Rectangle - } + public class ImageSharpQRCode : AbstractQRCode, IDisposable { public ImageSharpQRCode() diff --git a/QRCoder.ImageSharpTests/ImageSharpQrCodeTests.cs b/QRCoder.ImageSharpTests/ImageSharpQrCodeTests.cs index 2d02ed19..77f112fb 100644 --- a/QRCoder.ImageSharpTests/ImageSharpQrCodeTests.cs +++ b/QRCoder.ImageSharpTests/ImageSharpQrCodeTests.cs @@ -4,6 +4,7 @@ using System.Text; using System.Threading.Tasks; using QRCoder.ImageSharp; +using QRCoder.Models; using QRCoderTests.Helpers; using SixLabors.ImageSharp; diff --git a/QRCoder.ImageSharpTests/QRCoder.ImageSharpTests.csproj b/QRCoder.ImageSharpTests/QRCoder.ImageSharpTests.csproj index c44cde09..54dcc637 100644 --- a/QRCoder.ImageSharpTests/QRCoder.ImageSharpTests.csproj +++ b/QRCoder.ImageSharpTests/QRCoder.ImageSharpTests.csproj @@ -15,6 +15,7 @@ + diff --git a/QRCoder.SkiaSharp/Extensions/SaveExtensions.cs b/QRCoder.SkiaSharp/Extensions/SaveExtensions.cs new file mode 100644 index 00000000..5e1631c0 --- /dev/null +++ b/QRCoder.SkiaSharp/Extensions/SaveExtensions.cs @@ -0,0 +1,36 @@ +using QRCoder.Models; +using SkiaSharp; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace QRCoder.SkiaSharp.Extensions +{ + public static class SaveExtensions + { + /// + /// Saves a png of the qr code + /// + /// + /// + public static void SaveToPng(this SkiaSharpQRCode skiaSharpQRCode, string path, int quality = 80) + { + using var image = skiaSharpQRCode.GetGraphic(); + using var imageData = image.Encode(SKEncodedImageFormat.Png, quality); + using var stream = File.OpenWrite(path); + imageData.SaveTo(stream); + } + + public static void SaveToPng(this SkiaSharpQRCode skiaSharpQRCode, Stream stream, int quality, int pixelsPerModule, SKImage? logoImage = null, LogoLocation logoLocation = LogoLocation.BottomRight, LogoBackgroundShape logoBackgroundShape = LogoBackgroundShape.Circle) => SaveToPng(skiaSharpQRCode, stream, quality, pixelsPerModule, SKColors.Black, SKColors.White, SKColors.White, logoImage, logoLocation, logoBackgroundShape); + + public static void SaveToPng(this SkiaSharpQRCode skiaSharpQRCode, Stream stream, int quality, int pixelsPerModule, SKColor darkColor, SKColor lightColor, SKColor backgroundColor, SKImage? logoImage = null, LogoLocation logoLocation = LogoLocation.BottomRight, LogoBackgroundShape logoBackgroundShape = LogoBackgroundShape.Circle, bool drawQuietZones = true) + { + using var image = skiaSharpQRCode.GetGraphic(pixelsPerModule, darkColor, lightColor, backgroundColor, logoImage, logoLocation, logoBackgroundShape, drawQuietZones: drawQuietZones); + using var imageData = image.Encode(SKEncodedImageFormat.Png, quality); + imageData.SaveTo(stream); + stream.Position = 0; + } + } +} diff --git a/QRCoder.SkiaSharp/QRCoder.SkiaSharp.csproj b/QRCoder.SkiaSharp/QRCoder.SkiaSharp.csproj new file mode 100644 index 00000000..5b45031f --- /dev/null +++ b/QRCoder.SkiaSharp/QRCoder.SkiaSharp.csproj @@ -0,0 +1,18 @@ + + + + net6.0 + enable + enable + 1.0.0-dev1 + + + + + + + + + + + diff --git a/QRCoder.SkiaSharp/SkiaSharpQRCode.cs b/QRCoder.SkiaSharp/SkiaSharpQRCode.cs new file mode 100644 index 00000000..500bfbd5 --- /dev/null +++ b/QRCoder.SkiaSharp/SkiaSharpQRCode.cs @@ -0,0 +1,222 @@ +using QRCoder.Models; +using SkiaSharp; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace QRCoder.SkiaSharp +{ + public class SkiaSharpQRCode : AbstractQRCode, IDisposable + { + public SkiaSharpQRCode() + { + } + + public SkiaSharpQRCode(QRCodeData data) : base(data) + { + } + + /// + /// Renders an art-style QR code with dots as modules. (With default settings: DarkColor=Black, LightColor=White, Background=Transparent, QuietZone=true) + /// + /// Amount of px each dark/light module of the QR code shall take place in the final QR code image + /// QRCode graphic as bitmap + public SKImage GetGraphic(int pixelsPerModule, SKImage? logoImage = null, LogoLocation logoLocation = LogoLocation.BottomRight, LogoBackgroundShape logoBackgroundShape = LogoBackgroundShape.Rectangle) + { + return this.GetGraphic(pixelsPerModule, SKColors.Black, SKColors.White, SKColors.Transparent, logoImage, logoLocation, logoBackgroundShape); + } + + /// + /// Renders an art-style QR code with dots as modules and a background image (With default settings: DarkColor=Black, LightColor=White, Background=Transparent, QuietZone=true) + /// + /// A bitmap object that will be used as background picture + /// QRCode graphic as bitmap + public SKImage GetGraphic(SKImage? logoImage = null) + { + return this.GetGraphic(10, SKColors.Black, SKColors.White, SKColors.Transparent, logoImage); + } + + /// + /// Renders an art-style QR code with dots as modules and various user settings + /// + /// Amount of px each dark/light module of the QR code shall take place in the final QR code image + /// Color of the dark modules + /// Color of the light modules + /// Color of the background + /// A bitmap object that will be used as background picture + /// Value between 0.0 to 1.0 that defines how big the module dots are. The bigger the value, the less round the dots will be. + /// If true a white border is drawn around the whole QR Code + /// Style of the quiet zones + /// Style of the background image (if set). Fill=spanning complete graphic; DataAreaOnly=Don't paint background into quietzone + /// Optional image that should be used instead of the default finder patterns + /// QRCode graphic as bitmap + public SKImage GetGraphic(int pixelsPerModule, SKColor darkColor, SKColor lightColor, SKColor backgroundColor, SKImage? logoImage = null, LogoLocation logoLocation = LogoLocation.BottomRight, LogoBackgroundShape logoBackgroundShape = LogoBackgroundShape.Circle, double pixelSizeFactor = 0.8, + bool drawQuietZones = true) + { + if (pixelSizeFactor > 1) + throw new ArgumentException("The parameter pixelSize must be between 0 and 1. (0-100%)"); + + int pixelSize = (int)Math.Min(pixelsPerModule, Math.Floor(pixelsPerModule / pixelSizeFactor)); + + var numModules = QrCodeData.ModuleMatrix.Count - (drawQuietZones ? 0 : 8); + var offset = (drawQuietZones ? 0 : 4); + var size = numModules * pixelsPerModule; + + var imageInfo = new SKImageInfo(size, size, SKColorType.RgbaF32); + + using var surface = SKSurface.Create(imageInfo); + + var canvas = surface.Canvas; + + var lightBrush = new SKPaint + { + Color = lightColor, + IsAntialias = true, + Style = SKPaintStyle.Fill + }; + + var darkBrush = new SKPaint + { + Color = darkColor, + IsAntialias = true, + Style = SKPaintStyle.Fill + }; + + var backgroundBrush = new SKPaint + { + Color = backgroundColor, + IsAntialias = true, + Style = SKPaintStyle.Fill + }; + + + //background rectangle: + + canvas.DrawRect(0, 0, size, size, backgroundBrush); + + //if (backgroundImage != null) + //{ + // switch (backgroundImageStyle) + // { + // case BackgroundImageStyle.Fill: + // backgroundImage = backgroundImage.Clone(x => x.Resize(size, size)); + // image.Mutate(x => x.DrawImage(backgroundImage, new Point(0, 0), 1)); + // break; + // case BackgroundImageStyle.DataAreaOnly: + // var bgOffset = 4 - offset; + // backgroundImage = backgroundImage.Clone(x => x.Resize(size - (2 * bgOffset * pixelsPerModule), size - (2 * bgOffset * pixelsPerModule))); + // image.Mutate(x => x.DrawImage(backgroundImage, new Point(bgOffset * pixelsPerModule, bgOffset * pixelsPerModule), 1)); + // break; + // } + //} + + for (var x = 0; x < numModules; x += 1) + { + for (var y = 0; y < numModules; y += 1) + { + var rectangleF = SKRect.Create(x * pixelsPerModule, y * pixelsPerModule, pixelsPerModule, pixelsPerModule); //creates a rectangle in positions x * pixelsPerModule, y * pixelsPerModule and with the width, height pixelsPerModule. Do not use the constructor since that uses the 4 point location. + + var pixelIsDark = this.QrCodeData.ModuleMatrix[offset + y][offset + x]; + var solidBrush = pixelIsDark ? darkBrush : lightBrush; + //var pixelImage = pixelIsDark ? darkModulePixel : lightModulePixel; + + if (!IsPartOfFinderPattern(x, y, numModules, offset)) + if (drawQuietZones && IsPartOfQuietZone(x, y, numModules)) + canvas.DrawRect(rectangleF, solidBrush); // .Mutate(im => im.Fill(options, solidBrush, rectangleF)); + else + canvas.DrawRect(rectangleF, solidBrush); // .Mutate(im => im.Fill(options, solidBrush, rectangleF)); + else + canvas.DrawRect(rectangleF, solidBrush); // .Mutate(im => im.Fill(options, solidBrush, rectangleF)); + } + } + + if (logoImage != null) + { + var logoSize = (int)(size * 0.15); + var logoOffset = drawQuietZones ? 4 : 0; + + var locationPadding = logoLocation switch + { + LogoLocation.Center => new SKPoint(size / 2, size / 2), + _ => new SKPoint(size - logoSize / 2 - logoOffset * pixelsPerModule, size - logoSize / 2 - logoOffset * pixelsPerModule), + }; + + var locationLogo = logoLocation switch + { + LogoLocation.Center => new SKPoint(size / 2 - logoSize / 2, size / 2 - logoSize / 2), + _ => new SKPoint(size - logoSize - logoOffset * pixelsPerModule, size - logoSize - logoOffset * pixelsPerModule), + }; + + + + switch (logoBackgroundShape) + { + case LogoBackgroundShape.Circle: + canvas.DrawOval(locationPadding, new SKSize(1.20f*logoSize / 2, 1.20f * logoSize/2), backgroundBrush); + + break; + case LogoBackgroundShape.Rectangle: + var paddingRectangle = logoLocation switch + { + LogoLocation.Center => SKRect.Create(size / 2 - logoSize / 2, size / 2 - logoSize / 2, logoSize, logoSize), + _ => SKRect.Create(size - logoSize - logoOffset * pixelsPerModule, size - logoSize - logoOffset * pixelsPerModule, logoSize, logoSize) + }; + paddingRectangle.Inflate(1.05f, 1.05f); + canvas.DrawRect(paddingRectangle, backgroundBrush); + break; + } + + + var destinationArea = logoLocation switch + { + LogoLocation.Center => SKRect.Create(size / 2 - logoSize / 2, size / 2 - logoSize / 2, logoSize, logoSize), + _ => SKRect.Create(size - logoSize - logoOffset * pixelsPerModule, size - logoSize - logoOffset * pixelsPerModule, logoSize, logoSize) + }; + //var sourceArea = SKRect.Create(logoImage.Info.Width, logoImage.Info.Height); + + canvas.DrawImage(logoImage, destinationArea); + } + + return surface.Snapshot(); + } + + /// + /// Checks if a given module(-position) is part of the quietzone of a QR code + /// + /// X position + /// Y position + /// Total number of modules per row + /// true, if position is part of quiet zone + private bool IsPartOfQuietZone(int x, int y, int numModules) + { + return + x < 4 || //left + y < 4 || //top + x > numModules - 5 || //right + y > numModules - 5; //bottom + } + + + /// + /// Checks if a given module(-position) is part of one of the three finder patterns of a QR code + /// + /// X position + /// Y position + /// Total number of modules per row + /// Offset in modules (usually depending on drawQuietZones parameter) + /// true, if position is part of any finder pattern + private bool IsPartOfFinderPattern(int x, int y, int numModules, int offset) + { + var cornerSize = 11 - offset; + var outerLimitLow = (numModules - cornerSize - 1); + var outerLimitHigh = outerLimitLow + 8; + var invertedOffset = 4 - offset; + return + (x >= invertedOffset && x < cornerSize && y >= invertedOffset && y < cornerSize) || //Top-left finder pattern + (x > outerLimitLow && x < outerLimitHigh && y >= invertedOffset && y < cornerSize) || //Top-right finder pattern + (x >= invertedOffset && x < cornerSize && y > outerLimitLow && y < outerLimitHigh); //Bottom-left finder pattern + } + } +} diff --git a/QRCoder.SkiaSharpTests/Helpers/HelperFunctions.cs b/QRCoder.SkiaSharpTests/Helpers/HelperFunctions.cs new file mode 100644 index 00000000..c0490310 --- /dev/null +++ b/QRCoder.SkiaSharpTests/Helpers/HelperFunctions.cs @@ -0,0 +1,91 @@ +using System; +using System.Text; +using System.IO; +using System.Security.Cryptography; + +#if !NETCOREAPP1_1 +using System.Drawing; +#endif +#if NETFRAMEWORK || NET5_0_WINDOWS +using SW = System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +#endif + + +namespace QRCoderTests.Helpers +{ + public static class HelperFunctions + { + +#if NETFRAMEWORK || NET5_0_WINDOWS + public static BitmapSource ToBitmapSource(DrawingImage source) + { + DrawingVisual drawingVisual = new DrawingVisual(); + DrawingContext drawingContext = drawingVisual.RenderOpen(); + drawingContext.DrawImage(source, new SW.Rect(new SW.Point(0, 0), new SW.Size(source.Width, source.Height))); + drawingContext.Close(); + + RenderTargetBitmap bmp = new RenderTargetBitmap((int)source.Width, (int)source.Height, 96, 96, PixelFormats.Pbgra32); + bmp.Render(drawingVisual); + return bmp; + } + + public static Bitmap BitmapSourceToBitmap(DrawingImage xamlImg) + { + using (MemoryStream ms = new MemoryStream()) + { + PngBitmapEncoder encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(ToBitmapSource(xamlImg))); + encoder.Save(ms); + + using (Bitmap bmp = new Bitmap(ms)) + { + return new Bitmap(bmp); + } + } + } +#endif + +#if !NETCOREAPP1_1 + public static string GetAssemblyPath() + { + return +#if NET5_0 || NET6_0 + AppDomain.CurrentDomain.BaseDirectory; +#elif NET35 || NET452 + Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().CodeBase).Replace("file:\\", ""); +#else + Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location).Replace("file:\\", ""); +#endif + } +#endif + + +//#if NET6_0 +// public static string ImageToHash(SixLabors.ImageSharp.Image image) +// { +// using var ms = new MemoryStream(); +// image.Save(ms, new PngEncoder()); +// byte[] imgBytes = ms.ToArray(); +// return ByteArrayToHash(imgBytes); +// } +//#endif + + public static string ByteArrayToHash(byte[] data) + { +#if !NETCOREAPP1_1 + var md5 = MD5.Create(); + var hash = md5.ComputeHash(data); +#else + var hash = new SshNet.Security.Cryptography.MD5().ComputeHash(data); +#endif + return BitConverter.ToString(hash).Replace("-", "").ToLower(); + } + + public static string StringToHash(string data) + { + return ByteArrayToHash(Encoding.UTF8.GetBytes(data)); + } + } +} diff --git a/QRCoder.SkiaSharpTests/QRCoder.SkiaSharpTests.csproj b/QRCoder.SkiaSharpTests/QRCoder.SkiaSharpTests.csproj new file mode 100644 index 00000000..96d4b27f --- /dev/null +++ b/QRCoder.SkiaSharpTests/QRCoder.SkiaSharpTests.csproj @@ -0,0 +1,31 @@ + + + + net6.0 + enable + enable + + false + + + + + + + + + + + + + + + + Always + + + Always + + + + diff --git a/QRCoder.SkiaSharpTests/SkiaSharpTests.cs b/QRCoder.SkiaSharpTests/SkiaSharpTests.cs new file mode 100644 index 00000000..22a6c7af --- /dev/null +++ b/QRCoder.SkiaSharpTests/SkiaSharpTests.cs @@ -0,0 +1,43 @@ +using QRCoder.SkiaSharp; +using QRCoderTests.Helpers; +using SkiaSharp; + +namespace QRCoder.SkiaSharpTests +{ + [TestClass] + public class SkiaSharpTests + { + [TestMethod] + public void TestStandardQrCodeGeneration() + { + var gen = new QRCodeGenerator(); + var data = gen.CreateQrCode("This is a quick test! 123#?", QRCodeGenerator.ECCLevel.H); + var qrcode = new SkiaSharpQRCode(data); + using var image = qrcode.GetGraphic(); + using var imageData = image.Encode(SKEncodedImageFormat.Png, 80); + using var stream = File.OpenWrite("qrcode_skia.png"); + + imageData.SaveTo(stream); + + } + + [TestMethod] + public void TestLogoQrCodeGeneration() + { + var gen = new QRCodeGenerator(); + var data = gen.CreateQrCode("This is a quick test! 123#?", QRCodeGenerator.ECCLevel.H); + var qrcode = new SkiaSharpQRCode(data); + + var logoImagePath = HelperFunctions.GetAssemblyPath() + "\\assets\\noun_software engineer_2909346.png"; + + using var logoStream = File.OpenRead(logoImagePath); + using var logoImage = SKImage.FromEncodedData(logoStream); + + using var image = qrcode.GetGraphic(60, SKColors.Black,SKColors.White, SKColors.Red, logoImage, Models.LogoLocation.Center, Models.LogoBackgroundShape.Circle); + using var imageData = image.Encode(SKEncodedImageFormat.Png, 80); + using var stream = File.OpenWrite("qrcode_skia_logo.png"); + + imageData.SaveTo(stream); + } + } +} \ No newline at end of file diff --git a/QRCoder.SkiaSharpTests/Usings.cs b/QRCoder.SkiaSharpTests/Usings.cs new file mode 100644 index 00000000..ab67c7ea --- /dev/null +++ b/QRCoder.SkiaSharpTests/Usings.cs @@ -0,0 +1 @@ +global using Microsoft.VisualStudio.TestTools.UnitTesting; \ No newline at end of file diff --git a/QRCoder.SkiaSharpTests/assets/noun_Scientist_2909361.svg b/QRCoder.SkiaSharpTests/assets/noun_Scientist_2909361.svg new file mode 100644 index 00000000..869a0e73 --- /dev/null +++ b/QRCoder.SkiaSharpTests/assets/noun_Scientist_2909361.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/QRCoder.SkiaSharpTests/assets/noun_software engineer_2909346.png b/QRCoder.SkiaSharpTests/assets/noun_software engineer_2909346.png new file mode 100644 index 0000000000000000000000000000000000000000..bc100986ce64bfca32ac724284be9ea5a20a9a8d GIT binary patch literal 3611 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 h