[FileExplorerAddOns]Improves handling of Gcode Thumbnails (#27947)

* Improves handling of Gcode Thumbnails

* Remove Peek support

* Moves GcodeHelper to PreviewHandlerCommon

* Reverts minor change

* Skip unknown data on GcodeHelper.GetBestThumbnail

* Replaces QOI.Core with QoiImage

* Fixes spellchecker

* Reverts changes to NOTICE.md

* Minor QoiImage improvements

* Use custom QoiPixel struct

* Add MIT notice for the QOI reference code

* Fix spellcheck for the MIT notice

* Update NOTICE.md

tweaked notice a bit
This commit is contained in:
Pedro Lamas
2023-09-19 15:33:55 +01:00
committed by GitHub
parent cc454701b8
commit 4ba1d83cf5
22 changed files with 103394 additions and 55303 deletions

View File

@@ -2,14 +2,6 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices.ComTypes;
using System.Text;
using System.Windows.Forms;
using Common;
using Common.Utilities;
using Microsoft.PowerToys.PreviewHandler.Gcode.Telemetry.Events;
@@ -67,7 +59,9 @@ namespace Microsoft.PowerToys.PreviewHandler.Gcode
using (var reader = new StreamReader(fs))
{
thumbnail = GetThumbnail(reader);
var gcodeThumbnail = GcodeHelper.GetBestThumbnail(reader);
thumbnail = gcodeThumbnail?.GetBitmap();
}
_infoBarAdded = false;
@@ -92,66 +86,6 @@ namespace Microsoft.PowerToys.PreviewHandler.Gcode
}
}
/// <summary>
/// Reads the G-code content searching for thumbnails and returns the largest.
/// </summary>
/// <param name="reader">The TextReader instance for the G-code content.</param>
/// <returns>A thumbnail extracted from the G-code content.</returns>
public static Bitmap GetThumbnail(TextReader reader)
{
if (reader == null)
{
return null;
}
Bitmap thumbnail = null;
var bitmapBase64 = GetBase64Thumbnails(reader)
.OrderByDescending(x => x.Length)
.FirstOrDefault();
if (!string.IsNullOrEmpty(bitmapBase64))
{
var bitmapBytes = Convert.FromBase64String(bitmapBase64);
thumbnail = new Bitmap(new MemoryStream(bitmapBytes));
}
return thumbnail;
}
/// <summary>
/// Gets all thumbnails in base64 format found on the G-code data.
/// </summary>
/// <param name="reader">The TextReader instance for the G-code content.</param>
/// <returns>An enumeration of thumbnails in base64 format found on the G-code.</returns>
private static IEnumerable<string> GetBase64Thumbnails(TextReader reader)
{
string line;
StringBuilder capturedText = null;
while ((line = reader.ReadLine()) != null)
{
if (line.StartsWith("; thumbnail begin", StringComparison.InvariantCulture))
{
capturedText = new StringBuilder();
}
else if (line == "; thumbnail end")
{
if (capturedText != null)
{
yield return capturedText.ToString();
capturedText = null;
}
}
else if (capturedText != null)
{
capturedText.Append(line[2..]);
}
}
}
/// <summary>
/// Occurs when RichtextBox is resized.
/// </summary>

View File

@@ -2,7 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Drawing.Drawing2D;
using System.Text;
using Common.Utilities;
namespace Microsoft.PowerToys.ThumbnailHandler.Gcode
{
@@ -45,64 +45,23 @@ namespace Microsoft.PowerToys.ThumbnailHandler.Gcode
return null;
}
Bitmap thumbnail = null;
var gcodeThumbnail = GcodeHelper.GetBestThumbnail(reader);
var bitmapBase64 = GetBase64Thumbnails(reader)
.OrderByDescending(x => x.Length)
.FirstOrDefault();
var thumbnail = gcodeThumbnail?.GetBitmap();
if (!string.IsNullOrEmpty(bitmapBase64))
if (thumbnail != null && thumbnail.Width != cx && thumbnail.Height != cx)
{
var bitmapBytes = Convert.FromBase64String(bitmapBase64);
thumbnail = new Bitmap(new MemoryStream(bitmapBytes));
if (thumbnail.Width != cx && thumbnail.Height != cx)
{
// We are not the appropriate size for caller. Resize now while
// respecting the aspect ratio.
float scale = Math.Min((float)cx / thumbnail.Width, (float)cx / thumbnail.Height);
int scaleWidth = (int)(thumbnail.Width * scale);
int scaleHeight = (int)(thumbnail.Height * scale);
thumbnail = ResizeImage(thumbnail, scaleWidth, scaleHeight);
}
// We are not the appropriate size for caller. Resize now while
// respecting the aspect ratio.
float scale = Math.Min((float)cx / thumbnail.Width, (float)cx / thumbnail.Height);
int scaleWidth = (int)(thumbnail.Width * scale);
int scaleHeight = (int)(thumbnail.Height * scale);
thumbnail = ResizeImage(thumbnail, scaleWidth, scaleHeight);
}
return thumbnail;
}
/// <summary>
/// Gets all thumbnails in base64 format found on the G-code data.
/// </summary>
/// <param name="reader">The TextReader instance for the G-code content.</param>
/// <returns>An enumeration of thumbnails in base64 format found on the G-code.</returns>
private static IEnumerable<string> GetBase64Thumbnails(TextReader reader)
{
string line;
StringBuilder capturedText = null;
while ((line = reader.ReadLine()) != null)
{
if (line.StartsWith("; thumbnail begin", StringComparison.InvariantCulture))
{
capturedText = new StringBuilder();
}
else if (line == "; thumbnail end")
{
if (capturedText != null)
{
yield return capturedText.ToString();
capturedText = null;
}
}
else if (capturedText != null)
{
capturedText.Append(line[2..]);
}
}
}
/// <summary>
/// Resize the image with high quality to the specified width and height.
/// </summary>

View File

@@ -19,14 +19,17 @@ namespace GcodePreviewHandlerUnitTests
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2201:Do not raise reserved exception types", Justification = "new Exception() is fine in test projects.")]
public class GcodePreviewHandlerTest
{
[TestMethod]
public void GcodePreviewHandlerControlAddsControlsToFormWhenDoPreviewIsCalled()
[DataTestMethod]
[DataRow("HelperFiles/sample.gcode")]
[DataRow("HelperFiles/sample_JPG.gcode")]
[DataRow("HelperFiles/sample_QOI.gcode")]
public void GcodePreviewHandlerControlAddsControlsToFormWhenDoPreviewIsCalled(string filePath)
{
// Arrange
using (var gcodePreviewHandlerControl = new GcodePreviewHandlerControl())
{
// Act
var file = File.ReadAllBytes("HelperFiles/sample.gcode");
var file = File.ReadAllBytes(filePath);
gcodePreviewHandlerControl.DoPreview<IStream>(GetMockStream(file));

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,8 @@
<ItemGroup>
<None Remove="HelperFiles\sample.gcode" />
<None Remove="HelperFiles\sample_JPG.gcode" />
<None Remove="HelperFiles\sample_QOI.gcode" />
</ItemGroup>
<ItemGroup>
@@ -41,6 +43,12 @@
<Content Include="HelperFiles\sample.gcode">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="HelperFiles\sample_JPG.gcode">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="HelperFiles\sample_QOI.gcode">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Compile Include="..\STATestClassAttribute.cs" Link="STATestClassAttribute.cs" />
<Compile Include="..\STATestMethodAttribute.cs" Link="STATestMethodAttribute.cs" />
</ItemGroup>

View File

@@ -2,28 +2,24 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Drawing;
using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using Common.ComInterlop;
using Microsoft.PowerToys.STATestExtension;
using Microsoft.PowerToys.ThumbnailHandler.Gcode;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace GcodeThumbnailProviderUnitTests
{
[STATestClass]
public class GcodeThumbnailProviderTests
{
[TestMethod]
public void GetThumbnailValidStreamGcode()
[DataTestMethod]
[DataRow("HelperFiles/sample.gcode")]
[DataRow("HelperFiles/sample_JPG.gcode")]
[DataRow("HelperFiles/sample_QOI.gcode")]
public void GetThumbnailValidStreamGcode(string filePath)
{
// Act
var filePath = "HelperFiles/sample.gcode";
GcodeThumbnailProvider provider = new GcodeThumbnailProvider(filePath);
Bitmap bitmap = provider.GetThumbnail(256);

View File

@@ -23,6 +23,8 @@
<ItemGroup>
<None Remove="HelperFiles\sample.gcode" />
<None Remove="HelperFiles\sample_JPG.gcode" />
<None Remove="HelperFiles\sample_QOI.gcode" />
</ItemGroup>
<ItemGroup>
@@ -44,5 +46,11 @@
<Content Include="HelperFiles\sample.gcode">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="HelperFiles\sample_JPG.gcode">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="HelperFiles\sample_QOI.gcode">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<UseWindowsForms>true</UseWindowsForms>
<AssemblyTitle>PowerToys.PreviewHandlerCommon</AssemblyTitle>
@@ -9,6 +9,8 @@
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<PropertyGroup>

View File

@@ -0,0 +1,81 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace Common.Utilities
{
/// <summary>
/// Gcode file helper class.
/// </summary>
public static class GcodeHelper
{
/// <summary>
/// Gets any thumbnails found in a gcode file.
/// </summary>
/// <param name="reader">The <see cref="TextReader"/> instance to the gcode file.</param>
/// <returns>The thumbnails found in a gcode file.</returns>
public static IEnumerable<GcodeThumbnail> GetThumbnails(TextReader reader)
{
string? line;
var format = GcodeThumbnailFormat.Unknown;
StringBuilder? capturedText = null;
while ((line = reader.ReadLine()) != null)
{
if (line.StartsWith("; thumbnail", StringComparison.InvariantCulture))
{
var parts = line[11..].Split(" ");
switch (parts[1])
{
case "begin":
format = parts[0].ToUpperInvariant() switch
{
"" => GcodeThumbnailFormat.PNG,
"_JPG" => GcodeThumbnailFormat.JPG,
"_QOI" => GcodeThumbnailFormat.QOI,
_ => GcodeThumbnailFormat.Unknown,
};
capturedText = new StringBuilder();
break;
case "end":
if (capturedText != null)
{
yield return new GcodeThumbnail(format, capturedText.ToString());
capturedText = null;
}
break;
}
}
else
{
capturedText?.Append(line[2..]);
}
}
}
/// <summary>
/// Gets the best thumbnail available in a gcode file.
/// </summary>
/// <param name="reader">The <see cref="TextReader"/> instance to the gcode file.</param>
/// <returns>The best thumbnail available in the gcode file.</returns>
public static GcodeThumbnail? GetBestThumbnail(TextReader reader)
{
return GetThumbnails(reader)
.Where(x => x.Format != GcodeThumbnailFormat.Unknown)
.OrderByDescending(x => (int)x.Format)
.ThenByDescending(x => x.Data.Length)
.FirstOrDefault();
}
}
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Drawing;
using System.IO;
using PreviewHandlerCommon.Utilities;
namespace Common.Utilities
{
/// <summary>
/// Represents a gcode thumbnail.
/// </summary>
public class GcodeThumbnail
{
/// <summary>
/// Gets the gcode thumbnail image format.
/// </summary>
public GcodeThumbnailFormat Format { get; }
/// <summary>
/// Gets the gcode thumbnail image data in base64.
/// </summary>
public string Data { get; }
/// <summary>
/// Initializes a new instance of the <see cref="GcodeThumbnail"/> class.
/// </summary>
/// <param name="format">The gcode thumbnail image format.</param>
/// <param name="data">The gcode thumbnail image data in base64.</param>
public GcodeThumbnail(GcodeThumbnailFormat format, string data)
{
Format = format;
Data = data;
}
/// <summary>
/// Gets a <see cref="Bitmap"/> representing this thumbnail.
/// </summary>
/// <returns>A <see cref="Bitmap"/> representing this thumbnail.</returns>
public Bitmap? GetBitmap()
{
switch (Format)
{
case GcodeThumbnailFormat.JPG:
case GcodeThumbnailFormat.PNG:
return BitmapFromBase64String();
case GcodeThumbnailFormat.QOI:
return BitmapFromQoiBase64String();
default:
return null;
}
}
private Bitmap BitmapFromBase64String()
{
var bitmapBytes = Convert.FromBase64String(Data);
return new Bitmap(new MemoryStream(bitmapBytes));
}
private Bitmap BitmapFromQoiBase64String()
{
var bitmapBytes = Convert.FromBase64String(Data);
return QoiImage.FromStream(new MemoryStream(bitmapBytes));
}
}
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Common.Utilities
{
/// <summary>
/// The gcode thumbnail image format.
/// </summary>
public enum GcodeThumbnailFormat
{
/// <summary>
/// Unknown image format.
/// </summary>
Unknown,
/// <summary>
/// JPG image format.
/// </summary>
JPG,
/// <summary>
/// QOI image format.
/// </summary>
QOI,
/// <summary>
/// PNG image format.
/// </summary>
PNG,
}
}

View File

@@ -0,0 +1,177 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Buffers.Binary;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
//// Based on https://github.com/phoboslab/qoi/blob/master/qoi.h
namespace PreviewHandlerCommon.Utilities
{
/// <summary>
/// QOI Image helper.
/// </summary>
public static class QoiImage
{
#pragma warning disable SA1310 // Field names should not contain underscore
private const byte QOI_OP_INDEX = 0x00; // 00xxxxxx
private const byte QOI_OP_DIFF = 0x40; // 01xxxxxx
private const byte QOI_OP_LUMA = 0x80; // 10xxxxxx
private const byte QOI_OP_RUN = 0xc0; // 11xxxxxx
private const byte QOI_OP_RGB = 0xfe; // 11111110
private const byte QOI_OP_RGBA = 0xff; // 11111111
private const byte QOI_MASK_2 = 0xc0; // 11000000
private const int QOI_MAGIC = 'q' << 24 | 'o' << 16 | 'i' << 8 | 'f';
private const int QOI_HEADER_SIZE = 14;
private const uint QOI_PIXELS_MAX = 400000000;
private const byte QOI_PADDING_LENGTH = 8;
#pragma warning restore SA1310 // Field names should not contain underscore
private record struct QoiPixel(byte R, byte G, byte B, byte A)
{
public readonly int GetColorHash() => (R * 3) + (G * 5) + (B * 7) + (A * 11);
}
/// <summary>
/// Creates a <see cref="Bitmap"/> from the specified QOI data stream.
/// </summary>
/// <param name="stream">A <see cref="Stream"/> that contains the QOI data.</param>
/// <returns>The <see cref="Bitmap"/> this method creates.</returns>
/// <exception cref="ArgumentException">The stream does not have a valid QOI image format.</exception>
public static Bitmap FromStream(Stream stream)
{
var fileSize = stream.Length;
if (fileSize < QOI_HEADER_SIZE + QOI_PADDING_LENGTH)
{
throw new ArgumentException("Not enough data for a QOI file");
}
using var reader = new BinaryReader(stream);
var headerMagic = ReadUInt32BigEndian(reader);
if (headerMagic != QOI_MAGIC)
{
throw new ArgumentException("Invalid QOI file header");
}
var width = ReadUInt32BigEndian(reader);
var height = ReadUInt32BigEndian(reader);
var channels = reader.ReadByte();
var colorSpace = reader.ReadByte();
if (width == 0 || height == 0 || channels < 3 || channels > 4 || colorSpace > 1 || height >= QOI_PIXELS_MAX / width)
{
throw new ArgumentException("Invalid QOI file data");
}
var pixelsCount = width * height;
var pixels = new QoiPixel[pixelsCount];
var index = new QoiPixel[64];
var pixel = new QoiPixel(0, 0, 0, 255);
var run = 0;
var chunksLen = fileSize - QOI_PADDING_LENGTH;
for (var pixelIndex = 0; pixelIndex < pixelsCount; pixelIndex++)
{
if (run > 0)
{
run--;
}
else if (stream.Position < chunksLen)
{
var b1 = reader.ReadByte();
if (b1 == QOI_OP_RGB)
{
pixel.R = reader.ReadByte();
pixel.G = reader.ReadByte();
pixel.B = reader.ReadByte();
}
else if (b1 == QOI_OP_RGBA)
{
pixel.R = reader.ReadByte();
pixel.G = reader.ReadByte();
pixel.B = reader.ReadByte();
pixel.A = reader.ReadByte();
}
else if ((b1 & QOI_MASK_2) == QOI_OP_INDEX)
{
pixel = index[b1];
}
else if ((b1 & QOI_MASK_2) == QOI_OP_DIFF)
{
pixel.R += (byte)(((b1 >> 4) & 0x03) - 2);
pixel.G += (byte)(((b1 >> 2) & 0x03) - 2);
pixel.B += (byte)((b1 & 0x03) - 2);
}
else if ((b1 & QOI_MASK_2) == QOI_OP_LUMA)
{
var b2 = reader.ReadByte();
var vg = (b1 & 0x3f) - 32;
pixel.R += (byte)(vg - 8 + ((b2 >> 4) & 0x0f));
pixel.G += (byte)vg;
pixel.B += (byte)(vg - 8 + (b2 & 0x0f));
}
else if ((b1 & QOI_MASK_2) == QOI_OP_RUN)
{
run = b1 & 0x3f;
}
index[pixel.GetColorHash() % 64] = pixel;
}
pixels[pixelIndex] = pixel;
}
return ConvertToBitmap(width, height, channels, pixels);
}
private static Bitmap ConvertToBitmap(uint width, uint height, byte channels, QoiPixel[] pixels)
{
var pixelFormat = channels == 4 ? PixelFormat.Format32bppArgb : PixelFormat.Format24bppRgb;
var bitmap = new Bitmap((int)width, (int)height, pixelFormat);
var bitmapData = bitmap.LockBits(new Rectangle(0, 0, bitmap.Width, bitmap.Height), ImageLockMode.WriteOnly, pixelFormat);
unsafe
{
for (var pixelIndex = 0; pixelIndex < pixels.Length; pixelIndex++)
{
var pixel = pixels[pixelIndex];
var bitmapPixel = (byte*)bitmapData.Scan0 + (pixelIndex * channels);
bitmapPixel[0] = pixel.B;
bitmapPixel[1] = pixel.G;
bitmapPixel[2] = pixel.R;
if (channels == 4)
{
bitmapPixel[3] = pixel.A;
}
}
}
bitmap.UnlockBits(bitmapData);
return bitmap;
}
private static uint ReadUInt32BigEndian(BinaryReader reader)
{
var buffer = reader.ReadBytes(4);
return BinaryPrimitives.ReadUInt32BigEndian(buffer);
}
}
}

View File

@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
@@ -17,7 +18,7 @@ namespace Common.Utilities
/// </remarks>
public class ReadonlyStream : Stream
{
private IStream _stream;
private IStream? _stream;
/// <summary>
/// Initializes a new instance of the <see cref="ReadonlyStream"/> class.
@@ -238,6 +239,7 @@ namespace Common.Utilities
}
}
[MemberNotNull(nameof(_stream))]
private void CheckDisposed()
{
if (_stream == null)

View File

@@ -20,12 +20,12 @@ namespace Common
/// <summary>
/// WebView2 Control to display Svg.
/// </summary>
private WebView2 _browser;
private WebView2? _browser;
/// <summary>
/// WebView2 Environment
/// </summary>
private CoreWebView2Environment _webView2Environment;
private CoreWebView2Environment? _webView2Environment;
/// <summary>
/// Name of the virtual host
@@ -38,7 +38,7 @@ namespace Common
/// <remarks>
/// Source: https://stackoverflow.com/a/283917/14774889
/// </remarks>
public static string AssemblyDirectory
public static string? AssemblyDirectory
{
get
{
@@ -60,7 +60,7 @@ namespace Common
_browser = new WebView2();
_browser.Dock = DockStyle.Fill;
_browser.Visible = true;
_browser.NavigationCompleted += (object sender, CoreWebView2NavigationCompletedEventArgs args) =>
_browser.NavigationCompleted += (object? sender, CoreWebView2NavigationCompletedEventArgs args) =>
{
// Put here logic needed after WebView2 control is done navigating to url/page
};
@@ -83,7 +83,7 @@ namespace Common
_browser.NavigateToString("Test");
// Or navigate to Uri
_browser.Source = new Uri(filePath);
_browser.Source = new Uri(filePath!);
}
catch (NullReferenceException)
{