From 4c1d1ff60bb9cfaeedf5e58620b19c7f46f01342 Mon Sep 17 00:00:00 2001 From: "Joel Sallow (/u/ta11ow)" <32407840+vexx32@users.noreply.github.com> Date: Sun, 13 Sep 2020 21:49:20 -0400 Subject: [PATCH] Major Rework - Placement, sizing, cleanliness, and speed. (#79) * :construction: Rework placement, sizing, & cleanup - Reworked placement algorithms and padding calculations. - Reworked size estimation algorithms to be much more accurate, which makes scaling estimates much more appropriate for the image size and dimensions. - Constrain maximum word widths based on a minimum word length so small words don't scale out of control and require several re-calcs to make things fit. :recycle: Split several things out into separate classes: - Constants.cs - hold constants used for calculations. - Extensions.cs - holds extension methods instead of WCUtils. - LockingRandom.cs - class-based implementation of the threadsafe Random logic that was already being used. - Word.cs - created a new type to simplify handling of words and sizes. * :recycle: bump lang version & is not null * :fire: Remove `-Words` parameter alias Too similar to `-WordSizes` which was a separate parameter. --- .vscode/settings.json | 9 + Module/PSWordCloudCmdlet.csproj | 2 + Module/src/PSWordCloud/Attributes.cs | 164 +- Module/src/PSWordCloud/Constants.cs | 14 + Module/src/PSWordCloud/Extensions.cs | 313 ++++ Module/src/PSWordCloud/LockingRandom.cs | 82 + Module/src/PSWordCloud/NewWordCloudCommand.cs | 1341 +++++++-------- Module/src/PSWordCloud/WCUtils.cs | 391 ++--- Module/src/PSWordCloud/Word.cs | 35 + docs/New-WordCloud.md | 1457 ++++++++--------- 10 files changed, 2046 insertions(+), 1762 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 Module/src/PSWordCloud/Constants.cs create mode 100644 Module/src/PSWordCloud/Extensions.cs create mode 100644 Module/src/PSWordCloud/LockingRandom.cs create mode 100644 Module/src/PSWordCloud/Word.cs diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0dd3fc5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "omnisharp.enableRoslynAnalyzers": true, + "cSpell.words": [ + "AARRGGBB", + "ARGB", + "RRGGBB", + "wcloud" + ] +} \ No newline at end of file diff --git a/Module/PSWordCloudCmdlet.csproj b/Module/PSWordCloudCmdlet.csproj index 0ba1772..6f02c60 100644 --- a/Module/PSWordCloudCmdlet.csproj +++ b/Module/PSWordCloudCmdlet.csproj @@ -2,6 +2,8 @@ netcoreapp3.1 + preview + enable diff --git a/Module/src/PSWordCloud/Attributes.cs b/Module/src/PSWordCloud/Attributes.cs index 2474766..46c64bd 100644 --- a/Module/src/PSWordCloud/Attributes.cs +++ b/Module/src/PSWordCloud/Attributes.cs @@ -1,7 +1,6 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Linq; using System.Management.Automation; using System.Management.Automation.Language; @@ -40,6 +39,8 @@ public class TransformToSKSizeIAttribute : ArgumentTransformationAttribute public override object Transform(EngineIntrinsics engineIntrinsics, object inputData) { int sideLength = 0; + SKSizeI? result; + switch (inputData) { case SKSize sk: @@ -72,75 +73,42 @@ public override object Transform(EngineIntrinsics engineIntrinsics, object input { sideLength = (int)ul; } + break; case decimal d: if (d <= int.MaxValue) { sideLength = (int)Math.Round(d); } + break; case float f: if (f <= int.MaxValue) { sideLength = (int)Math.Round(f); } + break; case double d: if (d <= int.MaxValue) { sideLength = (int)Math.Round(d); } + break; case string s: - if (WCUtils.StandardImageSizes.ContainsKey(s)) - { - return WCUtils.StandardImageSizes[s].Size; - } - else + result = GetSizeFromString(s); + if (result is not null) { - var matchWH = Regex.Match(s, @"^(?[\d\.,]+)x(?[\d\.,]+)(px)?$"); - if (matchWH.Success) - { - try - { - var width = int.Parse(matchWH.Groups["Width"].Value); - var height = int.Parse(matchWH.Groups["Height"].Value); - - return new SKSizeI(width, height); - } - catch (Exception e) - { - throw new ArgumentTransformationMetadataException( - "Could not parse input string as a float value", e); - } - } - - var matchSide = Regex.Match(s, @"^(?[\d\.,]+)(px)?$"); - if (matchSide.Success) - { - sideLength = int.Parse(matchSide.Groups["SideLength"].Value); - } + return result; } break; case object o: - IEnumerable properties; - if (o is Hashtable ht) - { - properties = ht; - } - else + result = GetSizeFromProperties(o); + if (result is not null) { - properties = PSObject.AsPSObject(o).Properties; - } - - if (properties.GetValue("Width") != null && properties.GetValue("Height") != null) - { - // If these conversions fail, the exception will cause the transform to fail. - var width = properties.GetValue("Width").ConvertTo(); - var height = properties.GetValue("Height").ConvertTo(); - - return new SKSizeI(width, height); + return result; } break; @@ -154,6 +122,71 @@ public override object Transform(EngineIntrinsics engineIntrinsics, object input var errorMessage = $"Unrecognisable input '{inputData}' for SKSize parameter. See the help documentation for the parameter for allowed values."; throw new ArgumentTransformationMetadataException(errorMessage); } + + private SKSizeI? GetSizeFromString(string str) + { + if (WCUtils.StandardImageSizes.ContainsKey(str)) + { + return WCUtils.StandardImageSizes[str].Size; + } + else + { + var matchWH = Regex.Match(str, @"^(?[\d\.,]+)x(?[\d\.,]+)(px)?$"); + if (matchWH.Success) + { + try + { + var width = int.Parse(matchWH.Groups["Width"].Value); + var height = int.Parse(matchWH.Groups["Height"].Value); + + return new SKSizeI(width, height); + } + catch (Exception e) + { + throw new ArgumentTransformationMetadataException( + "Could not parse input string as an integer value", e); + } + } + + var matchSide = Regex.Match(str, @"^(?[\d\.,]+)(px)?$"); + if (matchSide.Success) + { + var sideLength = int.Parse(matchSide.Groups["SideLength"].Value); + return new SKSizeI(sideLength, sideLength); + } + } + + return null; + } + + private SKSizeI? GetSizeFromProperties(object obj) + { + IEnumerable properties; + if (obj is Hashtable ht) + { + properties = ht; + } + else + { + properties = PSObject.AsPSObject(obj).Properties; + } + + if (properties.GetValue("Width") is not null && properties.GetValue("Height") is not null) + { + // If these conversions fail, the exception will cause the transform to fail. + object? width = properties.GetValue("Width"); + object? height = properties.GetValue("Height"); + + if (width is null || height is null) + { + return null; + } + + return new SKSizeI(width.ConvertTo(), height.ConvertTo()); + } + + return null; + } } public class FontFamilyCompleter : IArgumentCompleter @@ -210,21 +243,24 @@ private static SKTypeface CreateTypefaceFromObject(object input) } SKFontStyle style; - if (properties.GetValue("FontWeight") != null - || properties.GetValue("FontSlant") != null - || properties.GetValue("FontWidth") != null) + if (properties.GetValue("FontWeight") is not null + || properties.GetValue("FontSlant") is not null + || properties.GetValue("FontWidth") is not null) { - SKFontStyleWeight weight = properties.GetValue("FontWeight") == null + object? weightValue = properties.GetValue("FontWeight"); + SKFontStyleWeight weight = weightValue is null ? SKFontStyleWeight.Normal - : properties.GetValue("FontWeight").ConvertTo(); + : weightValue.ConvertTo(); - SKFontStyleSlant slant = properties.GetValue("FontSlant") == null + object? slantValue = properties.GetValue("FontSlant"); + SKFontStyleSlant slant = slantValue is null ? SKFontStyleSlant.Upright - : properties.GetValue("FontSlant").ConvertTo(); + : slantValue.ConvertTo(); - SKFontStyleWidth width = properties.GetValue("FontWidth") == null + object? widthValue = properties.GetValue("FontWidth"); + SKFontStyleWidth width = widthValue is null ? SKFontStyleWidth.Normal - : properties.GetValue("FontWidth").ConvertTo(); + : widthValue.ConvertTo(); style = new SKFontStyle(weight, width, slant); } @@ -235,7 +271,7 @@ private static SKTypeface CreateTypefaceFromObject(object input) : SKFontStyle.Normal; } - string familyName = properties.GetValue("FamilyName").ConvertTo(); + string familyName = properties.GetValue("FamilyName")?.ConvertTo() ?? string.Empty; return WCUtils.FontManager.MatchFamily(familyName, style); } @@ -301,21 +337,25 @@ private SKColor[] TransformObject(object input) properties = PSObject.AsPSObject(item).Properties; } - byte red = properties.GetValue("red") == null + object? redValue = properties.GetValue("red"); + byte red = redValue is null ? (byte)0 - : properties.GetValue("red").ConvertTo(); + : redValue.ConvertTo(); - byte green = properties.GetValue("green") == null + object? greenValue = properties.GetValue("green"); + byte green = greenValue is null ? (byte)0 - : properties.GetValue("green").ConvertTo(); + : greenValue.ConvertTo(); - byte blue = properties.GetValue("blue") == null + object? blueValue = properties.GetValue("blue"); + byte blue = blueValue is null ? (byte)0 - : properties.GetValue("blue").ConvertTo(); + : blueValue.ConvertTo(); - byte alpha = properties.GetValue("alpha") == null + object? alphaValue = properties.GetValue("alpha"); + byte alpha = alphaValue is null ? (byte)255 - : properties.GetValue("alpha").ConvertTo(); + : alphaValue.ConvertTo(); colorList.Add(new SKColor(red, green, blue, alpha)); } diff --git a/Module/src/PSWordCloud/Constants.cs b/Module/src/PSWordCloud/Constants.cs new file mode 100644 index 0000000..bdd2f59 --- /dev/null +++ b/Module/src/PSWordCloud/Constants.cs @@ -0,0 +1,14 @@ +namespace PSWordCloud +{ + internal static class Constants + { + internal const float BaseAngularIncrement = 360 / 7; + internal const float BleedAreaScale = 1.5f; + internal const float FocusWordScale = 1.15f; + internal const float MaxWordAreaPercent = 0.9f; + internal const float MaxWordWidthPercent = 0.95f; + internal const int MinEffectiveWordWidth = 15; + internal const float PaddingBaseScale = 1.5f; + internal const float StrokeBaseScale = 0.01f; + } +} diff --git a/Module/src/PSWordCloud/Extensions.cs b/Module/src/PSWordCloud/Extensions.cs new file mode 100644 index 0000000..731297e --- /dev/null +++ b/Module/src/PSWordCloud/Extensions.cs @@ -0,0 +1,313 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Management.Automation; +using System.Text; +using System.Xml; +using SkiaSharp; + +namespace PSWordCloud +{ + internal static class Extensions + { + internal static SKPoint Multiply(this SKPoint point, float factor) + => new SKPoint(point.X * factor, point.Y * factor); + + internal static float ToRadians(this float degrees) + => (float)(degrees * Math.PI / 180); + + /// + /// Utility method which is just a convenient shortcut to . + /// + /// The object to convert. + /// The original object type. + /// The resulting destination type. + /// The converted value. + public static TResult ConvertTo(this object item) + => LanguagePrimitives.ConvertTo(item); + + /// + /// Perform an in-place-modification operation on every element in the array. + /// + /// The array to operate on. + /// The transformed array. + public static T[] TransformElements(this T[] items, Func operation) + { + for (var index = 0; index < items.Length; index++) + { + items[index] = operation.Invoke(items[index]); + } + + return items; + } + + public static SKColor AsMonochrome(this SKColor color) + { + color.ToHsv(out _, out _, out float brightness); + byte level = (byte)Math.Floor(255 * brightness / 100f); + + return new SKColor(level, level, level); + } + + /// + /// Determines whether a given color is considered sufficiently visually distinct from a backdrop color. + /// + /// The target color. + /// A reference color to compare against. + internal static bool IsDistinctFrom(this SKColor target, SKColor backdrop) + { + if (target.IsTransparent()) + { + return false; + } + + backdrop.ToHsv(out float refHue, out float refSaturation, out float refBrightness); + target.ToHsv(out float hue, out float saturation, out float brightness); + + float brightnessDistance = Math.Abs(refBrightness - brightness); + if (brightnessDistance > 30) + { + return true; + } + + float hueDistance = Math.Abs(refHue - hue); + if (hueDistance > 24 && brightnessDistance > 20) + { + return true; + } + + float saturationDistance = Math.Abs(refSaturation - saturation); + return saturationDistance > 24 && brightnessDistance > 18; + } + + private static bool IsTransparent(this SKColor color) => color.Alpha == 0; + + /// + /// Gets the smallest square that would completely contain the given , with the rectangle positioned + /// at its centre. + /// + /// The rectangle to find the containing square for. + internal static SKRect GetEnclosingSquare(this SKRect rectangle) + => rectangle switch + { + SKRect wide when wide.Width > wide.Height => SKRect.Inflate(wide, x: 0, y: (wide.Width - wide.Height) / 2), + SKRect tall when tall.Height > tall.Width => SKRect.Inflate(tall, x: (tall.Height - tall.Width) / 2, y: 0), + _ => SKRect.Inflate(rectangle, x: 0, y: 0) + }; + + /// + /// Returns a random value between the specified minimum and maximum. + /// + /// The instance to use for the generation. + /// The minimum float value. + /// The maximum float value. + internal static float NextFloat(this Random random, float min, float max) + { + if (min > max) + { + return max; + } + + var range = max - min; + return (float)random.NextDouble() * range + min; + } + + /// + /// Performs an in-place random shuffle on an array by swapping elements. + /// This algorithm is pretty commonly used, but effective and fast enough for our purposes. + /// + /// Random number generator. + /// The array to shuffle. + /// The element type of the array. + internal static IList Shuffle(this Random rng, IList array) + { + int n = array.Count; + while (n > 1) + { + int k = rng.Next(n--); + T temp = array[n]; + array[n] = array[k]; + array[k] = temp; + } + + return array; + } + + /// + /// Checks if any part of the rectangle lies outside the region's bounds. + /// + /// The rectangle to test position against the edges of the region. + /// The region to test against. + /// Returns false if the rectangle is entirely within the region, and false otherwise. + internal static bool FallsOutside(this SKRect rectangle, SKRegion region) + { + var bounds = region.Bounds; + return rectangle.Top < bounds.Top + || rectangle.Bottom > bounds.Bottom + || rectangle.Left < bounds.Left + || rectangle.Right > bounds.Right; + } + + /// + /// Checks if the given point is outside the given . + /// + /// The point in question. + /// The region to check against. + /// Returns true if the point is within the given region. + internal static bool IsOutside(this SKPoint point, SKRegion region) => !region.Contains(point); + + /// + /// Translates the so that its midpoint is the given position. + /// + /// The path to translate. + /// The new point to centre the path on. + internal static void CentreOnPoint(this SKPath path, SKPoint point) + { + var pathMidpoint = new SKPoint(path.TightBounds.MidX, path.TightBounds.MidY); + path.Offset(point - pathMidpoint); + } + + /// + /// Checks if the given lies somewhere inside the . + /// + /// The region that defines the bounds. + /// The point to check. + /// + internal static bool Contains(this SKRegion region, SKPoint point) + { + SKRectI bounds = region.Bounds; + return bounds.Left < point.X && point.X < bounds.Right + && bounds.Top < point.Y && point.Y < bounds.Bottom; + } + + internal static void SetFill(this SKPaint brush, SKColor fill) + { + brush.IsStroke = false; + brush.Style = SKPaintStyle.Fill; + brush.Color = fill; + } + + internal static void SetStroke(this SKPaint brush, SKColor stroke, float width) + { + brush.IsStroke = true; + brush.StrokeWidth = width; + brush.Style = SKPaintStyle.Stroke; + brush.Color = stroke; + } + + /// + /// Sets the contents of the region to the specified path. + /// + /// The region to set the path into. + /// The path object. + /// Whether to set the region's new bounds to the bounds of the path itself. + internal static bool SetPath(this SKRegion region, SKPath path, bool usePathBounds) + { + if (usePathBounds && path.GetBounds(out SKRect bounds)) + { + using SKRegion clip = new SKRegion(); + + clip.SetRect(SKRectI.Ceiling(bounds)); + return region.SetPath(path, clip); + } + else + { + return region.SetPath(path); + } + } + + /// + /// Combines the region with a given path, specifying the operation used to combine. + /// + /// The region to perform the operation on. + /// The path to perform the operation with. + /// The type of operation to perform. + /// + internal static bool CombineWithPath(this SKRegion region, SKPath path, SKRegionOperation operation) + { + using SKRegion pathRegion = new SKRegion(); + + pathRegion.SetPath(path, usePathBounds: true); + return region.Op(pathRegion, operation); + } + + /// + /// Rotates the given path by the declared angle. + /// + /// The path to rotate. + /// The angle in degrees to rotate the path. + internal static void Rotate(this SKPath path, float degrees) + { + SKRect pathBounds = path.TightBounds; + SKMatrix rotation = SKMatrix.CreateRotationDegrees(degrees, pathBounds.MidX, pathBounds.MidY); + path.Transform(rotation); + } + + /// + /// Checks whether the region intersects the given rectangle. + /// + /// The region to check collision with. + /// The rectangle to check for intersection. + internal static bool IntersectsRect(this SKRegion region, SKRect rect) + { + if (region.Bounds.IsEmpty) + { + return false; + } + + using SKRegion rectRegion = new SKRegion(); + + rectRegion.SetRect(SKRectI.Round(rect)); + return region.Intersects(rectRegion); + } + + /// + /// Checks whether the region intersects the given path. + /// + /// The region to check collision with. + /// The rectangle to check for intersection. + internal static bool IntersectsPath(this SKRegion region, SKPath path) + { + if (region.Bounds.IsEmpty) + { + return false; + } + + using SKRegion pathRegion = new SKRegion(); + + pathRegion.SetPath(path, region); + return region.Intersects(pathRegion); + } + + internal static string GetPrettyString(this XmlDocument document) + { + var stringBuilder = new StringBuilder(); + + var settings = new XmlWriterSettings + { + Indent = true + }; + + using (var xmlWriter = XmlWriter.Create(stringBuilder, settings)) + { + document.Save(xmlWriter); + } + + return stringBuilder.ToString(); + } + + internal static object? GetValue(this IEnumerable collection, string key) + => collection switch + { + PSMemberInfoCollection properties => properties[key].Value, + IDictionary dictionary => dictionary[key], + IDictionary dictT => dictT[key], + _ => throw new ArgumentException( + string.Format( + "GetValue method only accepts {0} or {1}", + typeof(PSMemberInfoCollection).ToString(), + typeof(IDictionary).ToString())), + }; + + } +} diff --git a/Module/src/PSWordCloud/LockingRandom.cs b/Module/src/PSWordCloud/LockingRandom.cs new file mode 100644 index 0000000..e1e4ae8 --- /dev/null +++ b/Module/src/PSWordCloud/LockingRandom.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; + +namespace PSWordCloud +{ + class LockingRandom + { + private const int MinimumAngleCount = 4; + private const int MaximumAngleCount = 14; + + private readonly Random _random; + private readonly object _lock = new object(); + + internal LockingRandom() + { + _random = new Random(); + } + + internal LockingRandom(int seed) + { + _random = new Random(seed); + } + + internal float PickRandomQuadrant() + => GetRandomInt(0, 4) * 90; + + internal float RandomFloat() + { + lock (_lock) + { + return (float)_random.NextDouble(); + } + } + + internal float RandomFloat(float min, float max) + { + lock (_lock) + { + return _random.NextFloat(min, max); + } + } + + internal int GetRandomInt() + { + lock (_lock) + { + return _random.Next(); + } + } + + internal int GetRandomInt(int min, int max) + { + lock (_lock) + { + return _random.Next(min, max); + } + } + + internal IList Shuffle(IList items) + { + lock (_lock) + { + return _random.Shuffle(items); + } + } + + internal IList GetRandomFloats( + int min, + int max, + int minCount = MinimumAngleCount, + int maxCount = MaximumAngleCount) + { + var angles = new float[GetRandomInt(minCount, maxCount)]; + for (var index = 0; index < angles.Length; index++) + { + angles[index] = RandomFloat(min, max); + } + + return angles; + } + } +} diff --git a/Module/src/PSWordCloud/NewWordCloudCommand.cs b/Module/src/PSWordCloud/NewWordCloudCommand.cs index 1ede5fe..3e86965 100644 --- a/Module/src/PSWordCloud/NewWordCloudCommand.cs +++ b/Module/src/PSWordCloud/NewWordCloudCommand.cs @@ -1,9 +1,11 @@ using System; using System.Collections; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Management.Automation; +using System.Management.Automation.Provider; using System.Numerics; using System.Runtime.CompilerServices; using System.Text.RegularExpressions; @@ -17,27 +19,16 @@ namespace PSWordCloud { /// /// Defines the New-WordCloud cmdlet. - /// - /// This command can be used to input large amounts of text, and will generate a word cloud based on - /// the relative frequencies of the words in the input text. /// [Cmdlet(VerbsCommon.New, "WordCloud", DefaultParameterSetName = COLOR_BG_SET, HelpUri = "https://github.com/vexx32/PSWordCloud/blob/main/docs/New-WordCloud.md")] [Alias("wordcloud", "nwc", "wcloud")] - [OutputType(typeof(System.IO.FileInfo))] + [OutputType(typeof(FileInfo))] public class NewWordCloudCommand : PSCmdlet { #region Constants - private const float FocusWordScale = 1.3f; - private const float BleedAreaScale = 1.5f; - private const float MaxWordWidthPercent = 1.0f; - private const float PaddingBaseScale = 0.06f; - private const float MaxWordAreaPercent = 0.0575f; - - private const char Ellipsis = '…'; - internal const string COLOR_BG_SET = "ColorBackground"; internal const string COLOR_BG_FOCUS_SET = "ColorBackground-FocusWord"; internal const string COLOR_BG_FOCUS_TABLE_SET = "ColorBackground-FocusWord-WordTable"; @@ -47,35 +38,12 @@ public class NewWordCloudCommand : PSCmdlet internal const string FILE_FOCUS_TABLE_SET = "FileBackground-FocusWord-WordTable"; internal const string FILE_TABLE_SET = "FileBackground-WordTable"; - internal const float STROKE_BASE_SCALE = 0.01f; - #endregion Constants #region StaticMembers - private static readonly string[] _stopWords = new[] { - "a","about","above","after","again","against","all","am","an","and","any","are","aren't","as","at","be", - "because","been","before","being","below","between","both","but","by","can't","cannot","could","couldn't", - "did","didn't","do","does","doesn't","doing","don't","down","during","each","few","for","from","further", - "had","hadn't","has","hasn't","have","haven't","having","he","he'd","he'll","he's","her","here","here's", - "hers","herself","him","himself","his","how","how's","i","i'd","i'll","i'm","i've","if","in","into","is", - "isn't","it","it's","its","itself","let's","me","more","most","mustn't","my","myself","no","nor","not","of", - "off","on","once","only","or","other","ought","our","ours","ourselves","out","over","own","same","shan't", - "she","she'd","she'll","she's","should","shouldn't","so","some","such","than","that","that's","the","their", - "theirs","them","themselves","then","there","there's","these","they","they'd","they'll","they're","they've", - "this","those","through","to","too","under","until","up","very","was","wasn't","we","we'd","we'll","we're", - "we've","were","weren't","what","what's","when","when's","where","where's","which","while","who","who's", - "whom","why","why's","with","won't","would","wouldn't","you","you'd","you'll","you're","you've","your", - "yours","yourself","yourselves" }; - - private static readonly char[] _splitChars = new[] { - ' ','\n','\t','\r','.',',',';','\\','/','|', - ':','"','?','!','{','}','[',']',':','(',')', - '<','>','“','”','*','#','%','^','&','+','=' }; - - private static readonly object _randomLock = new object(); - private static Random _random; - private static Random Random => _random ??= new Random(); + private static LockingRandom? _lockingRandom; + internal static LockingRandom SafeRandom => _lockingRandom ??= new LockingRandom(); private readonly CancellationTokenSource _cancel = new CancellationTokenSource(); @@ -92,9 +60,9 @@ public class NewWordCloudCommand : PSCmdlet [Parameter(Mandatory = true, ValueFromPipeline = true, ParameterSetName = COLOR_BG_FOCUS_SET)] [Parameter(Mandatory = true, ValueFromPipeline = true, ParameterSetName = FILE_SET)] [Parameter(Mandatory = true, ValueFromPipeline = true, ParameterSetName = FILE_FOCUS_SET)] - [Alias("InputString", "Text", "String", "Words", "Document", "Page")] + [Alias("InputString", "Text", "String", "Document", "Page")] [AllowEmptyString()] - public PSObject InputObject { get; set; } + public PSObject? InputObject { get; set; } /// /// Gets or sets the input word dictionary. @@ -112,7 +80,7 @@ public class NewWordCloudCommand : PSCmdlet [Parameter(Mandatory = true, ParameterSetName = FILE_TABLE_SET)] [Parameter(Mandatory = true, ParameterSetName = FILE_FOCUS_TABLE_SET)] [Alias("WordSizeTable", "CustomWordSizes")] - public IDictionary WordSizes { get; set; } + public IDictionary? WordSizes { get; set; } /// /// Gets or sets the output path to save the final SVG vector file to. @@ -126,9 +94,9 @@ public class NewWordCloudCommand : PSCmdlet [Parameter(Mandatory = true, Position = 0, ParameterSetName = FILE_FOCUS_TABLE_SET)] [Parameter(Mandatory = true, Position = 0, ParameterSetName = FILE_TABLE_SET)] [Alias("OutFile", "ExportPath", "ImagePath")] - public string Path { get; set; } + public string Path { get; set; } = string.Empty; - private string _backgroundFullPath; + private string? _backgroundFullPath; /// /// Gets or sets the path to the background image to be used as a base for the final word cloud image. /// @@ -138,7 +106,7 @@ public class NewWordCloudCommand : PSCmdlet [Parameter(Mandatory = true, ParameterSetName = FILE_TABLE_SET)] public string BackgroundImage { - get => _backgroundFullPath; + get => _backgroundFullPath!; set { var resolvedPaths = SessionState.Path.GetResolvedPSPathFromPSPath(value); @@ -279,7 +247,7 @@ public string BackgroundImage [Parameter(Mandatory = true, ParameterSetName = FILE_FOCUS_SET)] [Parameter(Mandatory = true, ParameterSetName = FILE_FOCUS_TABLE_SET)] [Alias("Title")] - public string FocusWord { get; set; } + public string? FocusWord { get; set; } [Parameter(ParameterSetName = COLOR_BG_FOCUS_SET)] [Parameter(ParameterSetName = COLOR_BG_FOCUS_TABLE_SET)] @@ -296,7 +264,7 @@ public string BackgroundImage /// [Parameter()] [Alias("ForbidWord", "IgnoreWord")] - public string[] ExcludeWord { get; set; } + public string[]? ExcludeWord { get; set; } /// /// Gets or sets the words to be explicitly included in rendering of the cloud. @@ -305,7 +273,7 @@ public string BackgroundImage /// [Parameter()] [Alias()] - public string[] IncludeWord { get; set; } + public string[]? IncludeWord { get; set; } /// /// Gets or sets the float value to scale the base word size by. By default, the word cloud is scaled to fill @@ -364,7 +332,10 @@ public string BackgroundImage /// Gets or sets the maximum number of words to render as part of the cloud. /// /// The default value is 100. - [Parameter()] + [Parameter(ParameterSetName = COLOR_BG_SET)] + [Parameter(ParameterSetName = COLOR_BG_FOCUS_SET)] + [Parameter(ParameterSetName = FILE_SET)] + [Parameter(ParameterSetName = FILE_FOCUS_SET)] [Alias("MaxWords")] [ValidateRange(0, int.MaxValue)] public int MaxRenderedWords { get; set; } = 100; @@ -420,9 +391,10 @@ public string BackgroundImage #region privateVariables - private float PaddingMultiplier { get => Padding * PaddingBaseScale; } + private float PaddingMultiplier { get => Padding * Constants.PaddingBaseScale; } - private List>> _wordProcessingTasks; + private readonly ConcurrentBag _processedWords = new ConcurrentBag(); + private readonly List _waitHandles = new List(); private int _colorSetIndex = 0; @@ -431,29 +403,41 @@ public string BackgroundImage #endregion privateVariables /// - /// Implements the method for . + /// Implements the method for . /// Instantiates the random number generator, and organises the base color set for the cloud. /// protected override void BeginProcessing() { - lock (_randomLock) - { - _random = MyInvocation.BoundParameters.ContainsKey(nameof(RandomSeed)) - ? new Random(RandomSeed) - : new Random(); - } + InitializeSafeRandom(); + SetProgressId(); + PrepareColorSet(); + } - _progressId = RandomInt(); + private void InitializeSafeRandom() + { + _lockingRandom = MyInvocation.BoundParameters.ContainsKey(nameof(RandomSeed)) + ? new LockingRandom(RandomSeed) + : new LockingRandom(); + } + private void SetProgressId() + { + _progressId = SafeRandom.GetRandomInt(); + } + + private void PrepareColorSet() + { if (Monochrome.IsPresent) { - ColorSet.TransformElements(c => c.AsMonochrome()); + ConvertColorSetToMonochrome(); } - Shuffle(ColorSet); + SafeRandom.Shuffle(ColorSet); ColorSet = ColorSet.Take(MaxColors).ToArray(); } + private void ConvertColorSetToMonochrome() => ColorSet.TransformElements(c => c.AsMonochrome()); + /// /// Implements the method for . /// Spins up a for each input text string to split them all @@ -467,23 +451,16 @@ protected override void ProcessRecord() case FILE_FOCUS_SET: case COLOR_BG_SET: case COLOR_BG_FOCUS_SET: - List text = NormalizeInput(InputObject); - _wordProcessingTasks ??= new List>>(GetEstimatedCapacity(InputObject)); - - foreach (var line in text) + if (InputObject is null) { - var shortLine = Regex.Split(line, @"\r?\n")[0]; - shortLine = shortLine.Length <= 32 ? shortLine : shortLine.Substring(0, 31); - if (shortLine.Length < line.Length) - { - shortLine = $"{shortLine}{Ellipsis}"; - } - - WriteDebug($"Processing input text: {shortLine}"); - _wordProcessingTasks.Add(ProcessInputAsync(line, IncludeWord, ExcludeWord)); + return; } + var state = new EventWaitHandle(initialState: false, EventResetMode.ManualReset); + ThreadPool.QueueUserWorkItem(StartProcessingInput, (InputObject, state), preferLocal: false); + _waitHandles.Add(state); break; + default: return; } @@ -495,192 +472,295 @@ protected override void ProcessRecord() /// protected override void EndProcessing() { - if ((WordSizes == null || WordSizes.Count == 0) - && (_wordProcessingTasks == null || _wordProcessingTasks.Count == 0)) + bool wordSizesWereSpecified = WordSizes?.Count > 0; + bool hasTextInput = _waitHandles.Count > 0; + if (!(wordSizesWereSpecified || hasTextInput)) { - // No input was supplied; exit stage left. + WriteDebug("No input was received. Ending processing."); return; } - int currentWordNumber = 0; - - SKPath bubblePath = null; - SKPath wordPath = null; - SKRect viewbox = SKRect.Empty; - SKBitmap backgroundImage = null; - List sortedWordList; + DrawWordCloud(); + } + private IReadOnlyList GetRelativeWordSizes(string parameterSet) + { Dictionary wordScaleDictionary; - switch (ParameterSetName) + int maxWords = 0; + switch (parameterSet) { case FILE_SET: case FILE_FOCUS_SET: case COLOR_BG_SET: case COLOR_BG_FOCUS_SET: - wordScaleDictionary = CancellableCollateWords(); + wordScaleDictionary = GetWordFrequencyDictionary(); + maxWords = MaxRenderedWords; break; case FILE_TABLE_SET: case FILE_FOCUS_TABLE_SET: case COLOR_BG_TABLE_SET: case COLOR_BG_FOCUS_TABLE_SET: - wordScaleDictionary = NormalizeWordScaleDictionary(WordSizes); + wordScaleDictionary = GetProcessedWordScaleDictionary(WordSizes!); break; default: throw new NotImplementedException("This parameter set has not defined an input handling method."); } - // All words counted and in the dictionary. - float highestWordFreq = wordScaleDictionary.Values.Max(); - if (MyInvocation.BoundParameters.ContainsKey(nameof(FocusWord))) { WriteDebug($"Adding focus word '{FocusWord}' to the dictionary."); - wordScaleDictionary[FocusWord] = highestWordFreq *= FocusWordScale; + + float highestWordFrequency = wordScaleDictionary.Values.Max(); + wordScaleDictionary[FocusWord!] = highestWordFrequency *= Constants.FocusWordScale; } - // Get a sorted list of words by their sizes - sortedWordList = new List(SortWordList(wordScaleDictionary, MaxRenderedWords)); + return GetWordListFromDictionary(wordScaleDictionary, maxWords); + } - try + IReadOnlyList GetWordListFromDictionary(IReadOnlyDictionary dictionary, int maxWords) + { + IEnumerable sortedWords = dictionary + .OrderByDescending(x => x.Value) + .Select(x => new Word(x.Key, x.Value)); + + if (maxWords > 0) { - if (MyInvocation.BoundParameters.ContainsKey(nameof(BackgroundImage))) - { - // Set image size from the background size - WriteDebug($"Importing background image from '{_backgroundFullPath}'."); - backgroundImage = SKBitmap.Decode(_backgroundFullPath); - viewbox = new SKRectI(0, 0, backgroundImage.Width, backgroundImage.Height); - } - else - { - // Set image size from default or specified size - viewbox = new SKRectI(0, 0, ImageSize.Width, ImageSize.Height); - } + sortedWords = sortedWords.Take(maxWords); + } - using var clipRegion = GetClipRegion(viewbox, AllowOverflow.IsPresent); - wordPath = new SKPath(); + return sortedWords.ToList(); + } - float baseFontScale = GetBaseFontScale( - clipRegion.Bounds, - WordScale, - wordScaleDictionary.Values.Average(), - sortedWordList.Count, - Typeface); + private SKRect GetViewBoxRect(string backgroundImagePath, out SKBitmap? backgroundBitmap) + { + backgroundBitmap = null; + if (backgroundImagePath is null) + { + return new SKRectI(left: 0, top: 0, ImageSize.Width, ImageSize.Height); + } + else + { + WriteDebug($"Importing background image from '{_backgroundFullPath}'."); + backgroundBitmap = SKBitmap.Decode(_backgroundFullPath); + return new SKRectI(left: 0, top: 0, backgroundBitmap.Width, backgroundBitmap.Height); + } - float maxWordWidth = AllowRotation == WordOrientations.None - ? viewbox.Width * MaxWordWidthPercent - : Math.Max(viewbox.Width, viewbox.Height) * MaxWordWidthPercent; + } - using SKPaint brush = new SKPaint - { - Typeface = Typeface - }; + private float GetMaxWordWidth(SKRect viewbox, WordOrientations permittedOrientations, bool hasFocusWord) + { + if (hasFocusWord) + { + if (WCUtils.AngleIsMostlyVertical(FocusWordAngle)) { + return viewbox.Height * Constants.MaxWordWidthPercent; + } - SKRect rect = SKRect.Empty; + return viewbox.Width * Constants.MaxWordWidthPercent; + } - // Pre-test and adjust global scale based on the largest word. - baseFontScale = GetAdjustedFontScale( - wordScaleDictionary, - sortedWordList[0], - maxWordWidth, - viewbox.Width * viewbox.Height, - baseFontScale); + if (permittedOrientations == WordOrientations.None) + { + return viewbox.Width * Constants.MaxWordWidthPercent; + } - // Apply manual scaling from the user - baseFontScale *= WordScale; - WriteDebug($"Global font scale: {baseFontScale}"); + return Math.Max(viewbox.Width, viewbox.Height) * Constants.MaxWordWidthPercent; + } - Dictionary scaledWordSizes = GetScaledWordSizes( - wordScaleDictionary, - maxWordWidth, - viewbox.Width * viewbox.Height, - ref baseFontScale); + private void DrawImageBackground( + SKCanvas canvas, + SKColor backgroundColor = default, + SKBitmap? backgroundImage = null) + { + if (ParameterSetName.StartsWith(FILE_SET)) + { + canvas.DrawBitmap(backgroundImage, 0, 0); + } + else if (backgroundColor != SKColor.Empty) + { + canvas.Clear(backgroundColor); + } + } - // Remove all words that were cut from the final rendering list - sortedWordList.RemoveAll(x => !scaledWordSizes.ContainsKey(x)); + private void DrawAllWordsOnCanvas( + SKCanvas canvas, + SKRect viewbox, + SKRegion clipRegion, + SKRegion occupiedSpace, + IReadOnlyList wordList) + { + WriteVerbose("Drawing words on canvas."); - using SKDynamicMemoryWStream outputStream = new SKDynamicMemoryWStream(); - using SKXmlStreamWriter xmlWriter = new SKXmlStreamWriter(outputStream); - using SKCanvas canvas = SKSvgCanvas.Create(viewbox, xmlWriter); - using SKRegion occupiedSpace = new SKRegion(); + int currentWordNumber = 0; + int totalWords = wordList.Count; + float strokeWidth = MyInvocation.BoundParameters.ContainsKey(nameof(StrokeWidth)) + ? StrokeWidth + : -1; - if (ParameterSetName.StartsWith(FILE_SET)) - { - canvas.DrawBitmap(backgroundImage, 0, 0); - } - else if (BackgroundColor != SKColor.Empty) - { - canvas.Clear(BackgroundColor); - } + foreach (Word word in wordList) + { + WriteDebug($"Scanning for draw location for '{word}'."); + currentWordNumber++; - foreach (string word in sortedWordList.OrderByDescending(x => scaledWordSizes[x])) - { - WriteDebug($"Scanning for draw location for '{word}'."); + bool isFocusWord = currentWordNumber == 1 + && MyInvocation.BoundParameters.ContainsKey(nameof(FocusWord)); - currentWordNumber++; + WriteDrawingProgressMessage(currentWordNumber, totalWords, word.Text, word.ScaledSize); - bool isFocusWord = currentWordNumber == 1 - && MyInvocation.BoundParameters.ContainsKey(nameof(FocusWordAngle)); - var wordSize = scaledWordSizes[word]; - var totalWords = sortedWordList.Count; - var percentComplete = 100f * currentWordNumber / sortedWordList.Count; + SKPath? wordPath = null, bubblePath = null; + try + { + SKPoint targetPoint = FindDrawLocation( + word, + Typeface, + isFocusWord, + viewbox, + clipRegion, + occupiedSpace, + out wordPath, + out bubblePath); - var wordProgress = new ProgressRecord( - _progressId, - "Drawing word cloud...", - $"Finding space for word: '{word}'...") + if (targetPoint != SKPoint.Empty) { - StatusDescription = string.Format( - "Current Word: \"{0}\" [Size: {1:0}] ({2} of {3})", - word, - wordSize, - currentWordNumber, - totalWords), - PercentComplete = (int)Math.Round(percentComplete) - }; - WriteProgress(wordProgress); - - try - { - SKPoint targetPoint = FindDrawLocation( - word, - scaledWordSizes[word], - Typeface, - isFocusWord, - viewbox, - clipRegion, - occupiedSpace, - out wordPath, - out bubblePath); - - if (targetPoint != SKPoint.Empty) - { - var strokeWidth = MyInvocation.BoundParameters.ContainsKey(nameof(StrokeWidth)) - ? StrokeWidth - : -1; - - WriteDebug($"Drawing '{word}' at [{targetPoint.X}, {targetPoint.Y}]."); - - DrawWord(wordPath, strokeWidth, bubblePath, canvas, occupiedSpace); - } - else - { - WriteWarning($"Unable to find a place to draw '{word}'; skipping to next word."); - } + WriteDebug($"Drawing '{word}' at [{targetPoint.X}, {targetPoint.Y}]."); + DrawWord(wordPath, strokeWidth, bubblePath, canvas, occupiedSpace); } - catch (OperationCanceledException) + else { - // If we receive OperationCancelledException, StopProcessing() has been called, - // so the pipeline is terminating. - throw new PipelineStoppedException(); + WriteWarning($"Unable to find a place to draw '{word}'; skipping to next word."); } } + catch (OperationCanceledException) + { + // If we receive OperationCancelledException, StopProcessing() has been called, + // so the pipeline is terminating. + throw new PipelineStoppedException(); + } + finally { + wordPath?.Dispose(); + bubblePath?.Dispose(); + } + } + } + + private void WriteDrawingProgressMessage(int currentWord, int totalWords, string word, float wordSize) + { + var percentComplete = 100f * currentWord / totalWords; + + var wordProgress = new ProgressRecord( + _progressId, + "Drawing word cloud...", + $"Finding space for word: '{word}'...") + { + StatusDescription = string.Format( + "Current Word: \"{0}\" [Size: {1:0}] ({2} of {3})", + word, + wordSize, + currentWord, + totalWords), + PercentComplete = (int)Math.Round(percentComplete) + }; + + WriteProgress(wordProgress); + } + + private (float averageFrequency, float averageLength) GetWordListStatistics( + IReadOnlyList wordList) + { - WriteDebug("Saving canvas data."); + float totalFrequency = 0, totalLength = 0; + for (int i = 0; i < wordList.Count; i++) + { + totalFrequency += wordList[i].RelativeSize; + totalLength += wordList[i].Text.Length; + } + + return (totalFrequency / wordList.Count, totalLength / wordList.Count); + } + + private float GetPaddingValue(Word word, SKPath wordPath) + { + float padding = wordPath.TightBounds.Height * PaddingMultiplier / word.ScaledSize; + return padding; + } + + private float ConstrainLargestWordWidth( + float maxWordWidth, + Word largestWord, + float fontScale) + { + float largestWordSize = largestWord.Scale(fontScale); + + using SKPaint brush = WCUtils.GetBrush(largestWordSize, StrokeWidth * Constants.StrokeBaseScale, Typeface); + using SKPath wordPath = brush.GetTextPath(largestWord.Text, x: 0, y: 0); + + float padding = GetPaddingValue(largestWord, wordPath); + float effectiveWidth = Math.Max(Constants.MinEffectiveWordWidth, largestWord.Text.Length); + float adjustedWidth = wordPath.Bounds.Width * (effectiveWidth / largestWord.Text.Length) + padding; + if (adjustedWidth > maxWordWidth) + { + return ConstrainLargestWordWidth( + maxWordWidth, + largestWord, + fontScale * Constants.MaxWordWidthPercent * (maxWordWidth / adjustedWidth)); + } + + return fontScale; + } + + private IReadOnlyList GetFinalWordList(SKRectI drawableBounds, SKRect viewbox) + { + IReadOnlyList wordList = GetRelativeWordSizes(ParameterSetName); + + (float averageWordFrequency, float averageWordLength) = GetWordListStatistics(wordList); + + float baseFontScale = GetBaseFontScale( + drawableBounds, + averageWordFrequency, + averageWordLength, + wordList.Count, + Typeface); + + float maxWordWidth = GetMaxWordWidth( + viewbox, + AllowRotation, + MyInvocation.BoundParameters.ContainsKey(nameof(FocusWord))); + + baseFontScale = ConstrainLargestWordWidth(maxWordWidth, wordList[0], baseFontScale); + + WriteDebug($"Global font scale: {baseFontScale}"); + + ScaleWordSizes( + wordList, + maxWordWidth, + baseFontScale, + WordScale); + + return wordList; + } + + private void DrawWordCloud() + { + SKRect viewbox = GetViewBoxRect(BackgroundImage, out SKBitmap? backgroundBitmap); + + using SKDynamicMemoryWStream memoryStream = new SKDynamicMemoryWStream(); + using SKCanvas canvas = SKSvgCanvas.Create(viewbox, memoryStream); + using SKRegion occupiedSpace = new SKRegion(); + using SKRegion clipRegion = GetClipRegion(viewbox, AllowOverflow.IsPresent); + + IReadOnlyList finalWordTable = GetFinalWordList(clipRegion.Bounds, viewbox); + + try + { + DrawImageBackground(canvas, BackgroundColor, backgroundBitmap); + DrawAllWordsOnCanvas(canvas, viewbox, clipRegion, occupiedSpace, finalWordTable); + + // Canvas must be completely flushed AND disposed before it will write the final closing XML tags. canvas.Flush(); canvas.Dispose(); - outputStream.Flush(); - SaveSvgData(outputStream, viewbox); + WriteDebug($"Saving canvas data to {string.Join(',', Path)}."); + memoryStream.Flush(); + SaveSvgData(memoryStream, viewbox, Path); if (PassThru.IsPresent) { @@ -694,10 +774,9 @@ protected override void EndProcessing() finally { WriteDebug("Disposing SkiaSharp objects."); - - wordPath?.Dispose(); - bubblePath?.Dispose(); - backgroundImage?.Dispose(); + backgroundBitmap?.Dispose(); + canvas?.Dispose(); + memoryStream?.Dispose(); // Write 'Completed' progress record WriteProgress(new ProgressRecord(_progressId, "Completed", "Completed") @@ -737,7 +816,7 @@ protected override void StopProcessing() private void DrawWord( SKPath wordPath, float strokeWidth, - SKPath bubblePath, + SKPath? bubblePath, SKCanvas canvas, SKRegion occupiedSpace) { @@ -745,40 +824,29 @@ private void DrawWord( SKColor wordColor; SKColor bubbleColor; - wordPath.FillType = SKPathFillType.EvenOdd; + wordPath.FillType = SKPathFillType.Winding; if (bubblePath == null) { - wordColor = GetContrastingColor(BackgroundColor); - - // Since we're not using bubbles, record the exact space the word occupies. occupiedSpace.CombineWithPath(wordPath, SKRegionOperation.Union); + wordColor = GetContrastingColor(BackgroundColor); } else { + occupiedSpace.CombineWithPath(bubblePath, SKRegionOperation.Union); + bubbleColor = GetContrastingColor(BackgroundColor); wordColor = GetContrastingColor(bubbleColor); - // If we're using word bubbles, the bubbles should more or less enclose the words. - occupiedSpace.CombineWithPath(bubblePath, SKRegionOperation.Union); - - brush.IsStroke = false; - brush.Style = SKPaintStyle.Fill; - brush.Color = bubbleColor; + brush.SetFill(bubbleColor); canvas.DrawPath(bubblePath, brush); } - brush.IsStroke = false; - brush.Style = SKPaintStyle.Fill; - brush.Color = wordColor; + brush.SetFill(wordColor); canvas.DrawPath(wordPath, brush); if (strokeWidth > -1) { - brush.IsStroke = true; - brush.StrokeWidth = strokeWidth; - brush.Style = SKPaintStyle.Stroke; - brush.Color = StrokeColor; - + brush.SetStroke(StrokeColor, strokeWidth); canvas.DrawPath(wordPath, brush); } } @@ -789,62 +857,34 @@ private void DrawWord( /// /// The input dictionary to normalize. /// The normalized . - private Dictionary NormalizeWordScaleDictionary(IDictionary dictionary) + private Dictionary GetProcessedWordScaleDictionary(IDictionary dictionary) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); WriteDebug("Processing -WordSizes input."); foreach (var word in dictionary.Keys) { + if (word is null || WordSizes?[word] is null) + { + continue; + } + try { result.Add( word.ConvertTo(), - WordSizes[word].ConvertTo()); + WordSizes[word]!.ConvertTo()); } catch (Exception e) { WriteWarning($"Skipping entry '{word}' due to error converting key or value: {e.Message}."); - WriteDebug($"Entry type: key - {word.GetType().FullName} ; value - {WordSizes[word].GetType().FullName}"); + WriteDebug($"Entry type: key - {word.GetType().FullName} ; value - {WordSizes[word]!.GetType().FullName}"); } } return result; } - /// - /// Waits for all the word processing tasks to complete and then counts everything before building the word - /// frequency dictionary. - /// If StopProcessing() is called during the tasks' operation, a will be - /// thrown. - /// - /// A containing the processed words and their counts. - private Dictionary CancellableCollateWords() - { - var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); - WriteDebug("Waiting for word processing tasks to finish."); - var processingTasks = Task.WhenAll(_wordProcessingTasks); - - var waitHandles = new[] { ((IAsyncResult)processingTasks).AsyncWaitHandle, _cancel.Token.WaitHandle }; - if (WaitHandle.WaitAny(waitHandles) == 1) - { - // If we receive a signal from the cancellation token, throw PipelineStoppedException() to - // terminate the pipeline, as StopProcessing() has been called. - throw new PipelineStoppedException(); - } - - WriteDebug("Word processing tasks complete."); - var result = processingTasks.GetAwaiter().GetResult(); - - WriteDebug("Counting words and populating scaling dictionary."); - foreach (var lineWords in result) - { - CountWords(lineWords, dictionary); - } - - return dictionary; - } - /// /// Gets an representing the clipping boundaries of the word cloud. /// @@ -859,8 +899,8 @@ private static SKRegion GetClipRegion(SKRect viewbox, bool allowOverflow) clipRegion.SetRect( SKRectI.Round(SKRect.Inflate( viewbox, - viewbox.Width * (BleedAreaScale - 1), - viewbox.Height * (BleedAreaScale - 1)))); + viewbox.Width * (Constants.BleedAreaScale - 1), + viewbox.Height * (Constants.BleedAreaScale - 1)))); } else { @@ -871,97 +911,42 @@ private static SKRegion GetClipRegion(SKRect viewbox, bool allowOverflow) } /// - /// Scales all word size values in the to the + /// Scales all word size values in the to the /// values. /// - /// The input dictionary of relative word sizes to be scaled. + /// The input dictionary of relative word sizes to be scaled. /// The maximum limit on width for a given word. /// /// /// - /// If any of the words in the exceed the + /// If any of the words in the exceed the /// when scaled, the font scale for the whole set will be reduced so that all words remain within the limit. /// /// A containing the scaled words and their sizes. - private Dictionary GetScaledWordSizes( - IDictionary wordScaleDictionary, + private void ScaleWordSizes( + IReadOnlyList wordList, float maxWordWidth, - float imageArea, - ref float baseFontScale) + float baseFontScale, + float userFontScale) { - var scaledWordSizes = new Dictionary( - wordScaleDictionary.Count, - StringComparer.OrdinalIgnoreCase); - using var brush = new SKPaint(); - - foreach (string word in wordScaleDictionary.Keys) + using SKPaint brush = WCUtils.GetBrush(wordSize: 0, StrokeWidth * Constants.StrokeBaseScale, Typeface); + for (int index = 0; index < wordList.Count; index++) { - float adjustedWordScale = ScaleWordSize( - wordScaleDictionary[word], - baseFontScale, - wordScaleDictionary); - - brush.Prepare(adjustedWordScale, StrokeWidth); - - var textRect = brush.GetTextPath(word, 0, 0).ComputeTightBounds(); - var adjustedTextWidth = textRect.Width * (1 + PaddingMultiplier) + StrokeWidth * 2 * STROKE_BASE_SCALE; + brush.TextSize = wordList[index].Scale(baseFontScale * userFontScale); + using SKPath textPath = brush.GetTextPath(wordList[index].Text, x: 0, y: 0); + SKRect textRect = textPath.Bounds; - if (!AllowOverflow.IsPresent - && (adjustedTextWidth > maxWordWidth - || textRect.Width * textRect.Height > imageArea * MaxWordAreaPercent)) + float paddedWidth = textRect.Width + GetPaddingValue(wordList[index], textPath); + if (!AllowOverflow.IsPresent && paddedWidth > maxWordWidth) { - baseFontScale *= 0.95f; - return GetScaledWordSizes(wordScaleDictionary, maxWordWidth, imageArea, ref baseFontScale); + ScaleWordSizes( + wordList, + maxWordWidth, + baseFontScale * Constants.MaxWordWidthPercent * (maxWordWidth / paddedWidth), + userFontScale); + return; } - - scaledWordSizes[word] = adjustedWordScale; - } - - return scaledWordSizes; - } - - /// - /// Adjusts the base font scale with respect to the width and area of the largest word, attempting to ensure - /// that the largest reasonable size is used. - /// - /// A dictionary containing unique words and their frequency counts. - /// The most frequent word in the dictionary. - /// The maximum allowable word width. - /// The total image area that will be visible. - /// - /// - /// The scale value here represents a best guess as to the appropriate scaling value needed to approximately - /// fill the entire image. This value will be multiplied by the user-provided value - /// after this, so the actual scaling value may differ from this result. - /// - /// The calculated scaling factor. - private float GetAdjustedFontScale( - IDictionary wordFrequencies, - string largestWord, - float maxWordWidth, - float imageArea, - float baseFontScale) - { - using var brush = new SKPaint(); - - float adjustedWordSize = ScaleWordSize( - wordFrequencies[largestWord], - baseFontScale, - wordFrequencies); - - brush.Prepare(adjustedWordSize, StrokeWidth); - - var textRect = brush.GetTextPath(largestWord, 0, 0).ComputeTightBounds(); - var adjustedTextWidth = textRect.Width * (1 + PaddingMultiplier) + StrokeWidth * 2 * STROKE_BASE_SCALE; - - if (adjustedTextWidth > maxWordWidth - || textRect.Width * textRect.Height < imageArea * MaxWordAreaPercent) - { - baseFontScale *= 1.05f; - return GetAdjustedFontScale(wordFrequencies, largestWord, maxWordWidth, imageArea, baseFontScale); } - - return baseFontScale; } /// @@ -979,180 +964,143 @@ private float GetAdjustedFontScale( /// The resulting bubble path, or null if none can be found /// The point at which a word can be drawn, or null if no appropriate point is found. private SKPoint FindDrawLocation( - string word, - float wordSize, + Word word, SKTypeface typeface, bool isFocusWord, SKRect viewbox, SKRegion clipRegion, SKRegion occupiedSpace, out SKPath wordPath, - out SKPath bubblePath) + out SKPath? bubblePath) { - var pointProgress = new ProgressRecord( - _progressId + 1, - "Scanning available space...", - "Scanning radial points...") - { - ParentActivityId = _progressId - }; - - var availableAngles = isFocusWord ? new[] { FocusWordAngle } : GetDrawAngles(AllowRotation); + IReadOnlyList availableAngles = isFocusWord + ? new[] { FocusWordAngle } + : WCUtils.GetDrawAngles(AllowRotation, SafeRandom); var centrePoint = new SKPoint(viewbox.MidX, viewbox.MidY); - float aspectRatio = viewbox.Width / viewbox.Height; - float inflationValue = 2 * wordSize * (PaddingMultiplier + StrokeWidth * STROKE_BASE_SCALE); - wordPath = null; bubblePath = null; - SKRect wordBounds; - using var brush = new SKPaint - { - IsAutohinted = true, - IsAntialias = true, - Typeface = typeface - }; + using SKPaint brush = WCUtils.GetBrush(word.ScaledSize, StrokeWidth * Constants.StrokeBaseScale, typeface); + wordPath = brush.GetTextPath(word.Text, 0, 0); - brush.Prepare(wordSize, StrokeWidth); + float wordPadding = GetPaddingValue(word, wordPath); + float maxRadius = GetMaxRadius(viewbox.Location, centrePoint); - // Max radius should reach to the corner of the image; location is top-left of the box - float maxRadius = SKPoint.Distance(viewbox.Location, centrePoint); - - if (AllowOverflow) - { - maxRadius *= BleedAreaScale; - } - - foreach (var drawAngle in availableAngles) + foreach (float drawAngle in availableAngles) { - wordPath = brush.GetTextPath(word, 0, 0); - wordBounds = wordPath.TightBounds; - - SKMatrix rotation = SKMatrix.MakeRotationDegrees(drawAngle, wordBounds.MidX, wordBounds.MidY); - wordPath.Transform(rotation); + wordPath.Rotate(drawAngle); for ( - float radius = 0; + float radius = (SafeRandom.RandomFloat() / 25) * wordPath.TightBounds.Height; radius <= maxRadius; radius += GetRadiusIncrement( - wordSize, + word.ScaledSize, DistanceStep, maxRadius, - inflationValue)) + wordPadding)) { - var radialPoints = GetOvalPoints(centrePoint, radius, RadialStep, aspectRatio); - var totalPoints = radialPoints.Count; - var pointsChecked = 0; + SKPoint result = FindDrawPointAtRadius( + radius, + drawAngle, + wordPadding, + wordPath, + viewbox, + clipRegion, + occupiedSpace, + out bubblePath); - foreach (var point in radialPoints) + if (result != SKPoint.Empty) { - pointsChecked++; - if (!clipRegion.Contains(point) && point != centrePoint) - { - continue; - } - - _cancel.Token.ThrowIfCancellationRequested(); - - pointProgress.Activity = string.Format( - "Finding available space to draw at angle: {0}", - drawAngle); - pointProgress.StatusDescription = string.Format( - "Checking [Point:{0,8:N2}, {1,8:N2}] ({2,4} / {3,4}) at [Radius: {4,8:N2}]", - point.X, - point.Y, - pointsChecked, - totalPoints, - radius); - - WriteProgress(pointProgress); - - var pathMidpoint = new SKPoint(wordBounds.MidX, wordBounds.MidY); - - wordPath.Offset(point - pathMidpoint); - wordBounds = wordPath.TightBounds; - - wordBounds.Inflate(inflationValue, inflationValue); - - if (WordBubble == WordBubbleShape.None) - { - if (isFocusWord) - { - // First word always gets drawn, and will be in the centre. - return wordBounds.Location; - } - - if (wordBounds.FallsOutside(clipRegion)) - { - continue; - } - - if (!occupiedSpace.IntersectsRect(wordBounds)) - { - return wordBounds.Location; - } - } - else - { - bubblePath = new SKPath(); - - SKRoundRect wordBubble; - float bubbleRadius; - - switch (WordBubble) - { - case WordBubbleShape.Rectangle: - bubbleRadius = wordBounds.Height / 16; - wordBubble = new SKRoundRect(wordBounds, bubbleRadius, bubbleRadius); - bubblePath.AddRoundRect(wordBubble); - break; - - case WordBubbleShape.Square: - bubbleRadius = Math.Max(wordBounds.Width, wordBounds.Height) / 16; - wordBubble = new SKRoundRect(wordBounds.GetEnclosingSquare(), bubbleRadius, bubbleRadius); - bubblePath.AddRoundRect(wordBubble); - break; - - case WordBubbleShape.Circle: - bubbleRadius = Math.Max(wordBounds.Width, wordBounds.Height) / 2; - bubblePath.AddCircle(wordBounds.MidX, wordBounds.MidY, bubbleRadius); - break; - - case WordBubbleShape.Oval: - bubblePath.AddOval(wordBounds); - break; - } - - if (isFocusWord) - { - // First word always gets drawn, and will be in the centre. - return wordBounds.Location; - } - - if (wordBounds.FallsOutside(clipRegion)) - { - continue; - } - - if (!occupiedSpace.IntersectsPath(bubblePath)) - { - return wordBounds.Location; - } - } - - if (point == centrePoint) - { - // No point checking more than a single point at the origin - break; - } + return result; } } + + wordPath.Rotate(-drawAngle); + } + + return SKPoint.Empty; + } + + private void ThrowIfPipelineStopping() => _cancel.Token.ThrowIfCancellationRequested(); + + private SKPoint FindDrawPointAtRadius( + float radius, + float drawAngle, + float inflationValue, + SKPath wordPath, + SKRect viewbox, + SKRegion clipRegion, + SKRegion occupiedSpace, + out SKPath? bubblePath) + { + SKPoint centrePoint = new SKPoint(viewbox.MidX, viewbox.MidY); + float aspectRatio = viewbox.Width / viewbox.Height; + IReadOnlyList radialPoints = GetOvalPoints(centrePoint, radius, RadialStep, aspectRatio); + int totalPoints = radialPoints.Count; + int pointsChecked = 0; + bubblePath = null; + + foreach (SKPoint point in radialPoints) + { + ThrowIfPipelineStopping(); + + pointsChecked++; + if (point.IsOutside(clipRegion)) + { + WriteDebug($"Skipping point {point} because it's outside the clipping region."); + continue; + } + + WritePointProgress(point, drawAngle, radius, pointsChecked, totalPoints); + + wordPath.CentreOnPoint(point); + SKRect wordBounds = SKRect.Inflate(wordPath.TightBounds, inflationValue, inflationValue); + + if (WCUtils.WordWillFit(wordBounds, WordBubble, clipRegion, occupiedSpace, out bubblePath)) + { + return wordBounds.Location; + } } return SKPoint.Empty; } + private void WritePointProgress(SKPoint point, float drawAngle, float radius, int pointsChecked, int totalPoints) + { + var pointProgress = new ProgressRecord( + _progressId + 1, + "Scanning available space...", + "Scanning radial points...") + { + ParentActivityId = _progressId, + Activity = string.Format( + "Finding available space to draw at angle: {0}", + drawAngle), + StatusDescription = string.Format( + "Checking [Point:{0,8:N2}, {1,8:N2}] ({2,4} / {3,4}) at [Radius: {4,8:N2}]", + point.X, + point.Y, + pointsChecked, + totalPoints, + radius) + }; + + WriteProgress(pointProgress); + } + + private float GetMaxRadius(SKPoint viewboxOrigin, SKPoint centrePoint) + { + float radius = SKPoint.Distance(viewboxOrigin, centrePoint); + + if (AllowOverflow) + { + radius *= Constants.BleedAreaScale; + } + + return radius; + } + /// /// Gets the next available color from the current set. /// If the set's end is reached, it will loop back to the beginning of the set again. @@ -1175,176 +1123,131 @@ private SKColor GetNextColor() private SKColor GetContrastingColor(SKColor reference) { SKColor result; + uint attempts = 0; do { result = GetNextColor(); + attempts++; } - while (!result.IsDistinctFrom(reference)); + while (!result.IsDistinctFrom(reference) && attempts < ColorSet.Length); return result; } - /// - /// Returns a shuffled set of possible angles determined by the . - /// - private static IList GetDrawAngles(WordOrientations permittedRotations) - { - return permittedRotations switch - { - WordOrientations.Vertical => Shuffle(new float[] { 0, 90 }), - WordOrientations.FlippedVertical => Shuffle(new float[] { 0, -90 }), - WordOrientations.EitherVertical => Shuffle(new float[] { 0, 90, -90 }), - WordOrientations.UprightDiagonals => Shuffle(new float[] { 0, -90, -45, 45, 90 }), - WordOrientations.InvertedDiagonals => Shuffle(new float[] { 90, 135, -135, -90, 180 }), - WordOrientations.AllDiagonals => Shuffle(new float[] { 45, 90, 135, 180, -135, -90, -45, 0 }), - WordOrientations.AllUpright => RandomAngles(-90, 91), - WordOrientations.AllInverted => RandomAngles(90, 271), - WordOrientations.All => RandomAngles(0, 361), - _ => new float[] { 0 }, - }; - } - - /// - /// Returns a set of random angles between and . - /// - private static float[] RandomAngles(int minAngle, int maxAngle) - { - var angles = new float[RandomInt(4, 12)]; - for (var index = 0; index < angles.Length; index++) - { - angles[index] = RandomFloat(minAngle, maxAngle); - } - - return angles; - } - /// /// Save the written SVG data to the provided PSProvider path. /// Since SkiaSharp does not write a viewbox attribute into the SVG, this method handles that as well. /// /// The memory stream containing the SVG data. /// The visible area of the image. - private void SaveSvgData(SKDynamicMemoryWStream outputStream, SKRect viewbox) + private void SaveSvgData(SKDynamicMemoryWStream outputStream, SKRect viewbox, string savePath) { - string[] path = new[] { Path }; + string[] path = new[] { savePath }; - if (InvokeProvider.Item.Exists(Path, force: true, literalPath: true)) - { - WriteDebug($"Clearing existing content from '{Path}'."); - try - { - InvokeProvider.Content.Clear(path, force: false, literalPath: true); - } - catch (Exception e) - { - // Unconditionally suppress errors from the Content.Clear() operation. Errors here may indicate that - // a provider is being written to that does not support the Content.Clear() interface, or that there - // is no existing item to clear. - // In either case, an error here does not necessarily mean we cannot write the data, so we can - // ignore this error. If there is an access denied error, it will be more clear to the user if we - // surface that from the Content.Write() interface in any case. - WriteDebug($"Error encountered while clearing content for item '{path}'. {e.Message}"); - } - } + ClearFileContent(path); - // We're using the PSProvider interface to allow users to target any PSProvider path. - using var writer = InvokeProvider.Content.GetWriter(path, force: false, literalPath: true).First(); using SKData data = outputStream.DetachAsData(); - using var reader = new StreamReader(data.AsStream()); + using var reader = new StreamReader(data.AsStream(), leaveOpen: false); + + XmlDocument imageXml = GetXmlDocumentWithViewbox(reader, viewbox); + WriteSvgDataToPSPath(path, imageXml); + } + + private void ClearFileContent(string[] paths) + { + try + { + WriteDebug($"Clearing existing content from '{string.Join(", ", paths)}'."); + InvokeProvider.Content.Clear(paths, force: false, literalPath: true); + } + catch (Exception e) + { + // Unconditionally suppress errors from the Content.Clear() operation. Errors here may indicate that + // a provider is being written to that does not support the Content.Clear() interface, so ignore errors + // at this point. + WriteDebug($"Error encountered while clearing content for item '{string.Join(", ", paths)}'. {e.Message}"); + } + } + private static XmlDocument GetXmlDocumentWithViewbox(StreamReader reader, SKRect viewbox) + { var imageXml = new XmlDocument(); imageXml.LoadXml(reader.ReadToEnd()); // Check if the SVG already has a viewbox attribute on the root SVG element. // If not, we need to add that in before writing the data to the target location. var svgElement = imageXml.GetElementsByTagName("svg")[0] as XmlElement; - if (svgElement.GetAttribute("viewbox") == string.Empty) + if (svgElement?.GetAttribute("viewbox") == string.Empty) { svgElement.SetAttribute( "viewbox", $"{viewbox.Location.X} {viewbox.Location.Y} {viewbox.Width} {viewbox.Height}"); } - WriteDebug($"Saving data to '{Path}'."); - writer.Write(new[] { imageXml.GetPrettyString() }); - writer.Close(); + return imageXml; } - /// - /// Check the type of the and return a probable count so we can reasonably - /// estimate necessary capacity for processing. - /// - /// The input object received from the pipeline. - private int GetEstimatedCapacity(PSObject inputObject) => inputObject.BaseObject switch + private void WriteSvgDataToPSPath(string[] paths, XmlDocument svgData) { - string _ => 1, - IList list => list.Count, - _ => 8 - }; + WriteDebug($"Saving data to '{Path}'."); + using IContentWriter writer = InvokeProvider.Content.GetWriter(paths, force: false, literalPath: true).First(); + writer.Write(new[] { svgData.GetPrettyString() }); + writer.Close(); + } /// /// Process a given and convert it to a string (or multiple strings, if there are more /// than one). /// /// One or more input objects. - private List NormalizeInput(PSObject input) - { - var list = new List(); - string value; - switch (input.BaseObject) + private IReadOnlyList CreateStringList(PSObject input) + => input.BaseObject switch { - case string s2: - list.Add(s2); - break; + string s => new[] { s }, + string[] sa => sa, + _ => GetStrings(input.BaseObject) + }; - case string[] sa: - list.AddRange(sa); - break; + private IReadOnlyList GetStrings(object baseObject) + { + IEnumerable enumerable = LanguagePrimitives.GetEnumerable(baseObject); - default: - IEnumerable enumerable; - try - { - enumerable = LanguagePrimitives.GetEnumerable(input.BaseObject); - } - catch - { - break; - } + if (enumerable != null) + { + return GetStringsFromEnumerable(enumerable); + } - if (enumerable != null) - { - foreach (var item in enumerable) - { - try - { - value = item.ConvertTo(); - } - catch - { - break; - } - - list.Add(value); - } - - break; - } + try + { + return new[] { baseObject.ConvertTo() }; + } + catch + { + return Array.Empty(); + } + } - try - { - value = input.ConvertTo(); - } - catch - { - break; - } + private IReadOnlyList GetStringsFromEnumerable(IEnumerable enumerable) + { + var strings = new List(); + foreach (var item in enumerable) + { + if (item is null) + { + continue; + } - list.Add(value); - break; + try + { + strings.Add(item.ConvertTo()); + } + catch + { + continue; + } } - return list; + return strings; } /// @@ -1377,47 +1280,24 @@ private static void CountWords(IEnumerable wordList, IDictionary /// Determines the base font scale for the word cloud. /// - /// The total available drawing space. + /// The total available drawing space. /// The base scale value. /// The average frequency of words. /// The total number of words to account for. /// A value representing a conservative scaling value to apply to each word. private static float GetBaseFontScale( - SKRect space, - float baseScale, + SKRect canvasRect, float averageWordFrequency, + float averageWordLength, int wordCount, SKTypeface typeface) { - var FontScale = WCUtils.GetFontScale(typeface); - return baseScale * FontScale * Math.Max(space.Height, space.Width) / (averageWordFrequency * wordCount); - } - - /// - /// Scale each word by the base word scale value and determine its final font size. - /// - /// The base size for the font. - /// The global scaling factor. - /// The dictionary of word scales containing their base sizes. - /// The scaled word size. - private static float ScaleWordSize( - float baseSize, - float globalScale, - IDictionary scaleDictionary) - { - return baseSize / scaleDictionary.Values.Max() * globalScale * (1 + RandomFloat() / 5); - } + float fontCharArea = WCUtils.GetAverageCharArea(typeface); + float estimatedPadding = (float)Math.Sqrt(fontCharArea) * Constants.PaddingBaseScale / averageWordFrequency; + float estimatedWordArea = fontCharArea * averageWordLength * averageWordFrequency * wordCount + estimatedPadding; + float canvasArea = canvasRect.Height * canvasRect.Width; - /// - /// Sorts the word list by the frequency of words, in descending order. - /// - /// The dictionary containing words and their relative frequencies. - /// The total number of words to consider. - /// An enumerable string list of words in order from most used to least. - private static IEnumerable SortWordList(IDictionary dictionary, int maxWords) - { - return dictionary.Keys.OrderByDescending(word => dictionary[word]) - .Take(maxWords == 0 ? int.MaxValue : maxWords); + return canvasArea * Constants.MaxWordAreaPercent / estimatedWordArea; } /// @@ -1445,14 +1325,15 @@ private static float GetRadiusIncrement( } /// - /// Scans in an ovoid pattern at a given radius to get a set of points to check for sufficient drawing space. + /// Gets a set of points around the perimeter of an oval defined by a given and + /// . /// /// The centre point of the image. /// The current radius we're scanning at. /// The current radial stepping value. - /// The aspect ratio of the canvas. + /// The aspect ratio of the canvas, used to stretch the circular scan into n ovoid. /// A containing possible draw locations. - private static List GetOvalPoints( + private static IReadOnlyList GetOvalPoints( SKPoint centre, float radius, float radialStep, @@ -1465,21 +1346,10 @@ private static List GetOvalPoints( return result; } - Complex point; + float angleIncrement = (radialStep * Constants.BaseAngularIncrement) / (15 * (float)Math.Sqrt(radius)); - var baseRadialPoints = 7; - var baseAngleIncrement = 360 / baseRadialPoints; - float angleIncrement = baseAngleIncrement / (float)Math.Sqrt(radius); - - bool clockwise = RandomFloat() > 0.5; - - float angle = RandomInt(0, 4) switch - { - 1 => 90, - 2 => 180, - 3 => 270, - _ => 0 - }; + float angle = SafeRandom.PickRandomQuadrant(); + bool clockwise = SafeRandom.RandomFloat() > 0.5; float maxAngle; if (clockwise) @@ -1492,80 +1362,83 @@ private static List GetOvalPoints( angleIncrement *= -1; } + return GenerateOvalPoints( + radius, + angle, + angleIncrement, + maxAngle, + aspectRatio, + centre); + } + + private static IReadOnlyList GenerateOvalPoints( + float radius, + float startingAngle, + float angleIncrement, + float maxAngle, + float aspectRatio, + SKPoint centre) + { + List points = new List(); + float angle = startingAngle; + bool clockwise = angleIncrement > 0; + do { - point = Complex.FromPolarCoordinates(radius, angle.ToRadians()); - result.Add(new SKPoint(centre.X + (float)point.Real * aspectRatio, centre.Y + (float)point.Imaginary)); - - angle += angleIncrement * (radialStep / 15); + points.Add(GetPointOnOval(radius, angle, aspectRatio, centre)); + angle += angleIncrement; } while (clockwise ? angle <= maxAngle : angle >= maxAngle); - return result; + return points; } - /// - /// Retrieves a random floating-point number between 0 and 1. - /// - private static float RandomFloat() + private static SKPoint GetPointOnOval(float semiMinorAxis, float degrees, float axisRatio, SKPoint centre) { - lock (_randomLock) - { - return (float)Random.NextDouble(); - } + Complex point = Complex.FromPolarCoordinates(semiMinorAxis, degrees.ToRadians()); + float xPosition = centre.X + (float)point.Real * axisRatio; + float yPosition = centre.Y + (float)point.Imaginary; + + return new SKPoint(xPosition, yPosition); } - /// - /// Retrieves a random floating-point number between and . - /// - private static float RandomFloat(float min, float max) + private Dictionary GetWordFrequencyDictionary() { - if (min > max) - { - return max; - } + var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); - lock (_randomLock) - { - var range = max - min; - return (float)Random.NextDouble() * range + min; - } - } + WriteDebug("Waiting for any remaining queued word processing work items to finish."); - /// - /// Retrieves a random int value. - /// - private static int RandomInt() - { - lock (_randomLock) + var waitHandles = new WaitHandle[] { default!, _cancel.Token.WaitHandle }; + foreach (EventWaitHandle handle in _waitHandles) { - return Random.Next(); - } - } + waitHandles[0] = handle; + int waitHandleIndex = WaitHandle.WaitAny(waitHandles); + if (waitHandleIndex == 1) + { + // If we receive a signal from the cancellation token, throw PipelineStoppedException() to + // terminate the pipeline, as StopProcessing() has been called. + throw new PipelineStoppedException(); + } - /// - /// Retrieves a random int value within the specified bounds. - /// - /// The minimum bound. - /// The maximum bound. - private static int RandomInt(int min, int max) - { - lock (_randomLock) - { - return Random.Next(min, max); + handle.Dispose(); } + + WriteDebug("Word processing tasks complete."); + + WriteDebug("Counting words and populating scaling dictionary."); + CountWords(_processedWords, dictionary); + + return dictionary; } - /// - /// Performs an in-place shuffle of the input array, randomly shuffling its contents. - /// - /// The array of items to be shuffled. - /// The type of the array. - private static IList Shuffle(IList items) + private void StartProcessingInput((PSObject, EventWaitHandle) state) { - lock (_randomLock) - { - return Random.Shuffle(items); - } + (PSObject? inputObject, EventWaitHandle waitHandle) = state; + ProcessInput( + CreateStringList(PSObject.AsPSObject(inputObject)), + IncludeWord, + ExcludeWord); + + waitHandle.Set(); } /// @@ -1573,26 +1446,36 @@ private static IList Shuffle(IList items) /// /// The text to split and process. /// An enumerable string collection of all words in the input, with stopwords stripped out. - private async Task> ProcessInputAsync( - string line, - string[] includeWords = null, - string[] excludeWords = null) + private void ProcessInput( + IReadOnlyList lines, + IReadOnlyList? includeWords = null, + IReadOnlyList? excludeWords = null) { - return await Task.Run(() => TrimAndSplitWords(line) - .Where(x => SelectWord(x, includeWords, excludeWords, AllowStopWords.IsPresent)) - .ToList()); + var words = TrimAndSplitWords(lines); + for (int index = 0; index < words.Count; index++) + { + if (KeepWord(words[index], includeWords, excludeWords, AllowStopWords.IsPresent)) + { + _processedWords.Add(words[index]); + } + } } - /// - /// Enumerates and returns each word from the input text. - /// - /// A string of text to extract words from. - private IEnumerable TrimAndSplitWords(string text) + private IReadOnlyList TrimAndSplitWords(IReadOnlyList text) { - foreach (var word in text.Split(_splitChars, StringSplitOptions.RemoveEmptyEntries)) + var wordList = new List(text.Count); + for (int i = 0; i < text.Count; i++) { - yield return Regex.Replace(word, @"^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$", string.Empty); + string[] initialWords = text[i].Split(WCUtils.SplitChars, StringSplitOptions.RemoveEmptyEntries); + + for (int index = 0; index < initialWords.Length; index++) + { + var word = Regex.Replace(initialWords[index], @"^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$", string.Empty); + wordList.Add(word); + } } + + return wordList; } /// @@ -1602,7 +1485,11 @@ private IEnumerable TrimAndSplitWords(string text) /// The word in question. /// A reference list of desired words, overridingthe stopwords or exclude list. /// A reference list of undesired words, effectively impromptu stopwords. - private static bool SelectWord(string word, string[] includeWords, string[] excludeWords, bool allowStopWords) + private static bool KeepWord( + string word, + IReadOnlyList? includeWords, + IReadOnlyList? excludeWords, + bool allowStopWords) { if (includeWords?.Contains(word, StringComparer.OrdinalIgnoreCase) == true) { @@ -1614,7 +1501,7 @@ private static bool SelectWord(string word, string[] includeWords, string[] excl return false; } - if (!allowStopWords && _stopWords.Contains(word, StringComparer.OrdinalIgnoreCase)) + if (!allowStopWords && WCUtils.IsStopWord(word)) { return false; } diff --git a/Module/src/PSWordCloud/WCUtils.cs b/Module/src/PSWordCloud/WCUtils.cs index a189bc2..07b819b 100644 --- a/Module/src/PSWordCloud/WCUtils.cs +++ b/Module/src/PSWordCloud/WCUtils.cs @@ -7,6 +7,7 @@ using System.Reflection; using System.Runtime.CompilerServices; using System.Text; +using System.Threading.Tasks; using System.Xml; using SkiaSharp; @@ -38,155 +39,49 @@ public enum WordBubbleShape : sbyte internal static class WCUtils { - internal static SKPoint Multiply(this SKPoint point, float factor) - => new SKPoint(point.X * factor, point.Y * factor); - - internal static float ToRadians(this float degrees) - => (float)(degrees * Math.PI / 180); - /// - /// Returns a font scale value based on the size of the letter X in a given typeface. + /// Returns a font scale value based on the size of the letter X in a given typeface when the text size is 1 unit. /// /// The typeface to measure the scale from. - /// A float value typically between 0 and 1. Many common typefaces have values around 0.5. - internal static float GetFontScale(SKTypeface typeface) - { - var text = "X"; - using var paint = new SKPaint - { - Typeface = typeface, - TextSize = 1 - }; - var rect = paint.GetTextPath(text, 0, 0).ComputeTightBounds(); - - return (rect.Width + rect.Height) / 2; - } - - /// - /// Utility method which is just a convenient shortcut to . - /// - /// The object to convert. - /// The original object type. - /// The resulting destination type. - /// The converted value. - public static TResult ConvertTo(this object item) - => LanguagePrimitives.ConvertTo(item); - - /// - /// Perform an in-place-modification operation on every element in the array. - /// - /// The array to operate on. - /// The transformed array. - public static T[] TransformElements(this T[] items, Func operation) - { - for (var index = 0; index < items.Length; index++) - { - items[index] = operation.Invoke(items[index]); - } - - return items; - } - - public static SKColor AsMonochrome(this SKColor color) - { - color.ToHsv(out _, out _, out float brightness); - byte level = (byte)Math.Floor(255 * brightness / 100f); - - return new SKColor(level, level, level); - } - - /// - /// Determines whether a given color is considered sufficiently visually distinct from a backdrop color. - /// - /// The target color. - /// A reference color to compare against. - internal static bool IsDistinctFrom(this SKColor target, SKColor backdrop) + /// A float value representing the area occupied by the letter X in a typeface. + internal static float GetAverageCharArea(SKTypeface typeface) { - backdrop.ToHsv(out float refHue, out float refSaturation, out float refBrightness); - target.ToHsv(out float hue, out float saturation, out float brightness); + const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + using SKPaint brush = GetBrush(wordSize: 1, strokeWidth: 0, typeface: typeface); - float brightnessDistance = Math.Abs(refBrightness - brightness); - if (brightnessDistance > 30) - { - return true; - } + using SKPath textPath = brush.GetTextPath(alphabet, x: 0, y: 0); - if (Math.Abs(refHue - hue) > 24 && brightnessDistance > 20) - { - return true; - } - - if (Math.Abs(refSaturation - saturation) > 24 && brightnessDistance > 18) - { - return true; - } - - if (target.Alpha == 0) - { - return false; - } - - return false; + SKRect rect = textPath.TightBounds; + return rect.Width * rect.Height / alphabet.Length; } /// - /// Performs an in-place random shuffle on an array by swapping elements. - /// This algorithm is pretty commonly used, but effective and fast enough for our purposes. + /// Returns a shuffled set of possible angles determined by the . /// - /// Random number generator. - /// The array to shuffle. - /// The element type of the array. - internal static IList Shuffle(this Random rng, IList array) - { - int n = array.Count; - while (n > 1) + internal static IReadOnlyList GetDrawAngles(WordOrientations permittedRotations, LockingRandom random) + => (IReadOnlyList)(permittedRotations switch { - int k = rng.Next(n--); - T temp = array[n]; - array[n] = array[k]; - array[k] = temp; - } - - return array; - } - - /// - /// Checks if any part of the rectangle lies outside the region's bounds. - /// - /// The region to test for edge intersection. - /// The rectangle to test position against the edges of the region. - /// Returns false if the rectangle is entirely within the region, and false otherwise. - internal static bool FallsOutside(this SKRect other, SKRegion region) - { - var bounds = region.Bounds; - return other.Top < bounds.Top - || other.Bottom > bounds.Bottom - || other.Left < bounds.Left - || other.Right > bounds.Right; - } - - /// - /// Checks if the given lies somewhere inside the . - /// - /// The region that defines the bounds. - /// The point to check. - /// - internal static bool Contains(this SKRegion region, SKPoint point) - { - SKRectI bounds = region.Bounds; - return bounds.Left < point.X && point.X < bounds.Right - && bounds.Top < point.Y && point.Y < bounds.Bottom; - } + WordOrientations.Vertical => random.Shuffle(new float[] { 0, 90 }), + WordOrientations.FlippedVertical => random.Shuffle(new float[] { 0, -90 }), + WordOrientations.EitherVertical => random.Shuffle(new float[] { 0, 90, -90 }), + WordOrientations.UprightDiagonals => random.Shuffle(new float[] { 0, -90, -45, 45, 90 }), + WordOrientations.InvertedDiagonals => random.Shuffle(new float[] { 90, 135, -135, -90, 180 }), + WordOrientations.AllDiagonals => random.Shuffle(new float[] { 45, 90, 135, 180, -135, -90, -45, 0 }), + WordOrientations.AllUpright => random.GetRandomFloats(-90, 91), + WordOrientations.AllInverted => random.GetRandomFloats(90, 271), + WordOrientations.All => random.GetRandomFloats(0, 361), + _ => new float[] { 0 }, + }); /// /// Prepares the brush to draw the next word. /// This overload assumes the text to be drawn will be black. /// - /// /// /// - internal static void Prepare(this SKPaint brush, float wordSize, float strokeWidth) - => brush.Prepare(wordSize, strokeWidth, SKColors.Black); + /// + internal static SKPaint GetBrush(float wordSize, float strokeWidth, SKTypeface typeface) + => GetBrush(wordSize, strokeWidth, SKColors.Black, typeface); /// /// Prepares the brush to draw the next word. @@ -195,108 +90,123 @@ internal static void Prepare(this SKPaint brush, float wordSize, float strokeWid /// The size of the word we'll be drawing. /// Width of the stroke we'll be drawing. /// Color of the word we'll be drawing. - internal static void Prepare(this SKPaint brush, float wordSize, float strokeWidth, SKColor color) - { - brush.TextSize = wordSize; - brush.IsStroke = false; - brush.Style = SKPaintStyle.StrokeAndFill; - brush.StrokeWidth = wordSize * strokeWidth * NewWordCloudCommand.STROKE_BASE_SCALE; - brush.IsVerticalText = false; - brush.Color = color; - } + /// The typeface to draw words with. + internal static SKPaint GetBrush( + float wordSize, + float strokeWidth, + SKColor color, + SKTypeface typeface) + => new SKPaint + { + Typeface = typeface, + TextSize = wordSize, + Style = SKPaintStyle.StrokeAndFill, + Color = color, + StrokeWidth = wordSize * strokeWidth * Constants.StrokeBaseScale, + IsStroke = false, + IsAutohinted = true, + IsAntialias = true + }; /// - /// Sets the contents of the region to the specified path. + /// Gets the appropriate word bubble path for the requested shape, sized to fit the word bounds. /// - /// The region to set the path into. - /// The path object. - /// Whether to set the region's new bounds to the bounds of the path itself. - internal static bool SetPath(this SKRegion region, SKPath path, bool usePathBounds) - { - if (usePathBounds && path.GetBounds(out SKRect bounds)) + /// The shape of the bubble. + /// The bounds of the word to surround. + /// The representing the word bubble. + internal static SKPath GetWordBubblePath(WordBubbleShape shape, SKRect wordBounds) + => shape switch { - using SKRegion clip = new SKRegion(); + WordBubbleShape.Rectangle => GetRectanglePath(wordBounds), + WordBubbleShape.Square => GetSquarePath(wordBounds), + WordBubbleShape.Circle => GetCirclePath(wordBounds), + WordBubbleShape.Oval => GetOvalPath(wordBounds), + _ => throw new ArgumentOutOfRangeException(nameof(shape)) + }; - clip.SetRect(SKRectI.Ceiling(bounds)); - return region.SetPath(path, clip); - } - else - { - return region.SetPath(path); - } + private static SKPath GetRectanglePath(SKRect rectangle) + { + var path = new SKPath(); + float cornerRadius = rectangle.Height / 16; + path.AddRoundRect(new SKRoundRect(rectangle, cornerRadius, cornerRadius)); + + return path; } - /// - /// Combines the region with a given path, specifying the operation used to combine. - /// - /// The region to perform the operation on. - /// The path to perform the operation with. - /// The type of operation to perform. - /// - internal static bool CombineWithPath(this SKRegion region, SKPath path, SKRegionOperation operation) + private static SKPath GetSquarePath(SKRect rectangle) { - using SKRegion pathRegion = new SKRegion(); + var path = new SKPath(); + float cornerRadius = Math.Max(rectangle.Width, rectangle.Height) / 16; + path.AddRoundRect(new SKRoundRect(rectangle.GetEnclosingSquare(), cornerRadius, cornerRadius)); - pathRegion.SetPath(path, usePathBounds: true); - return region.Op(pathRegion, operation); + return path; } - /// - /// Checks whether the region intersects the given rectangle. - /// - /// The region to check collision with. - /// The rectangle to check for intersection. - internal static bool IntersectsRect(this SKRegion region, SKRect rect) + private static SKPath GetCirclePath(SKRect rectangle) { - if (region.Bounds.IsEmpty) - { - return false; - } + var path = new SKPath(); + float bubbleRadius = Math.Max(rectangle.Width, rectangle.Height) / 2; + path.AddCircle(rectangle.MidX, rectangle.MidY, bubbleRadius); + + return path; + } - using SKRegion rectRegion = new SKRegion(); + private static SKPath GetOvalPath(SKRect rectangle) + { + var path = new SKPath(); + path.AddOval(rectangle); - rectRegion.SetRect(SKRectI.Round(rect)); - return region.Intersects(rectRegion); + return path; } - /// - /// Checks whether the region intersects the given path. - /// - /// The region to check collision with. - /// The rectangle to check for intersection. - internal static bool IntersectsPath(this SKRegion region, SKPath path) + internal static bool AngleIsMostlyVertical(float degrees) { - if (region.Bounds.IsEmpty) - { - return false; - } + float remainder = Math.Abs(degrees % 180); + return 135 > remainder && remainder > 45; + } - using SKRegion pathRegion = new SKRegion(); + private static bool WordWillFit(SKRect wordBounds, SKRegion occupiedSpace) + => !occupiedSpace.IntersectsRect(wordBounds); - pathRegion.SetPath(path, region); - return region.Intersects(pathRegion); + private static bool WordBubbleWillFit( + WordBubbleShape shape, + SKRect wordBounds, + SKRegion occupiedSpace, + out SKPath bubblePath) + { + bubblePath = GetWordBubblePath(shape, wordBounds); + return !occupiedSpace.IntersectsPath(bubblePath); } /// - /// Gets the smallest square that would completely contain the given rectangle, with the rectangle positioned - /// at its centre. + /// Checks whether the given word bounds rectangle and the bubble surrounding it will fit in the desired + /// location without bleeding over the or intersecting already-drawn words + /// or their bubbles (which are recorded in the region). /// - /// The rectangle to find the containing square for. - internal static SKRect GetEnclosingSquare(this SKRect rect) + /// The rectangular bounds of the word to attempt to fit. + /// The shape of the word bubble we'll need to draw. + /// The region that defines the allowable draw area. + /// The region that defines the space in the image that's already occupied. + /// Returns true if the word and its surrounding bubble have sufficient space to be drawn. + internal static bool WordWillFit( + SKRect wordBounds, + WordBubbleShape bubbleShape, + SKRegion clipRegion, + SKRegion occupiedSpace, + out SKPath? bubblePath) { - // Inflate the smaller dimension - if (rect.Width > rect.Height) + bubblePath = null; + if (wordBounds.FallsOutside(clipRegion)) { - return SKRect.Inflate(rect, x: 0, y: (rect.Width - rect.Height) / 2); + return false; } - if (rect.Height > rect.Width) + if (bubbleShape == WordBubbleShape.None) { - return SKRect.Inflate(rect, x: (rect.Height - rect.Width) / 2, y: 0); + return WordWillFit(wordBounds, occupiedSpace); } - // It was already a square, but we need to return a copy - return SKRect.Create(rect.Location, rect.Size); + return WordBubbleWillFit(bubbleShape, wordBounds, occupiedSpace, out bubblePath); } /// @@ -315,41 +225,7 @@ internal static SKRect GetEnclosingSquare(this SKRect rect) /// /// internal static SKColor GetColorByName(string colorName) - { - return ColorLibrary[colorName]; - } - - internal static string GetPrettyString(this XmlDocument document) - { - var stringBuilder = new StringBuilder(); - - var settings = new XmlWriterSettings - { - Indent = true - }; - - using (var xmlWriter = XmlWriter.Create(stringBuilder, settings)) - { - document.Save(xmlWriter); - } - - return stringBuilder.ToString(); - } - - internal static object GetValue(this IEnumerable collection, string key) - { - return collection switch - { - PSMemberInfoCollection properties => properties[key].Value, - IDictionary dictionary => dictionary[key], - IDictionary dictT => dictT[key], - _ => throw new ArgumentException( - string.Format( - "GetValue method only accepts {0} or {1}", - typeof(PSMemberInfoCollection).ToString(), - typeof(IDictionary).ToString())), - }; - } + => ColorLibrary[colorName]; internal static SKFontManager FontManager = SKFontManager.Default; @@ -358,17 +234,38 @@ internal static object GetValue(this IEnumerable collection, string key) internal static ReadOnlyDictionary StandardImageSizes = new ReadOnlyDictionary(new Dictionary() { - {"480x800", ("Mobile Screen Size (small)", new SKSizeI(480, 800) )}, - {"640x1146", ("Mobile Screen Size (medium)", new SKSizeI(640, 1146) )}, - {"720p", ("Standard HD 1280x720", new SKSizeI(1280, 720) )}, + {"480x800", ("Mobile Screen Size (small)", new SKSizeI( 480, 800))}, + {"640x1146", ("Mobile Screen Size (medium)", new SKSizeI( 640, 1146))}, + {"720p", ("Standard HD 1280x720", new SKSizeI(1280, 720))}, {"1080p", ("Full HD 1920x1080", new SKSizeI(1920, 1080))}, {"4K", ("Ultra HD 3840x2160", new SKSizeI(3840, 2160))}, - {"A4", ("816x1056", new SKSizeI(816, 1056) )}, + {"A4", ("816x1056", new SKSizeI( 816, 1056))}, {"Poster11x17", ("1056x1632", new SKSizeI(1056, 1632))}, {"Poster18x24", ("1728x2304", new SKSizeI(1728, 2304))}, {"Poster24x36", ("2304x3456", new SKSizeI(2304, 3456))}, }); + internal static readonly char[] SplitChars= new[] { + ' ','\n','\t','\r','.',',',';','\\','/','|', + ':','"','?','!','{','}','[',']',':','(',')', + '<','>','“','”','*','#','%','^','&','+','=' }; + + internal static string[] StopWords= new[] { + "a","about","above","after","again","against","all","am","an","and","any","are","aren't","as","at","be", + "because","been","before","being","below","between","both","but","by","can't","cannot","could","couldn't", + "did","didn't","do","does","doesn't","doing","don't","down","during","each","few","for","from","further", + "had","hadn't","has","hasn't","have","haven't","having","he","he'd","he'll","he's","her","here","here's", + "hers","herself","him","himself","his","how","how's","i","i'd","i'll","i'm","i've","if","in","into","is", + "isn't","it","it's","its","itself","let's","me","more","most","mustn't","my","myself","no","nor","not","of", + "off","on","once","only","or","other","ought","our","ours","ourselves","out","over","own","same","shan't", + "she","she'd","she'll","she's","should","shouldn't","so","some","such","than","that","that's","the","their", + "theirs","them","themselves","then","there","there's","these","they","they'd","they'll","they're","they've", + "this","those","through","to","too","under","until","up","very","was","wasn't","we","we'd","we'll","we're", + "we've","were","weren't","what","what's","when","when's","where","where's","which","while","who","who's", + "whom","why","why's","with","won't","would","wouldn't","you","you'd","you'll","you're","you've","your", + "yours","yourself","yourselves" }; + + internal static bool IsStopWord(string word) => StopWords.Contains(word, StringComparer.OrdinalIgnoreCase); private static readonly Dictionary _library; /// @@ -887,11 +784,17 @@ static WCUtils() { "yellowgreen", SKColor.Parse("9acd32") } }; - foreach (var field in typeof(SKColors).GetFields(BindingFlags.Static | BindingFlags.Public)) + foreach (FieldInfo field in typeof(SKColors).GetFields(BindingFlags.Static | BindingFlags.Public)) { if (!_library.ContainsKey(field.Name)) { - _library[field.Name] = (SKColor)field.GetValue(null); + object? value = field.GetValue(null); + if (value is null) + { + continue; + } + + _library[field.Name] = (SKColor)value; } } } diff --git a/Module/src/PSWordCloud/Word.cs b/Module/src/PSWordCloud/Word.cs new file mode 100644 index 0000000..1ca164f --- /dev/null +++ b/Module/src/PSWordCloud/Word.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Globalization; + +namespace PSWordCloud +{ + internal class Word + { + internal Word(string text, float relativeSize) + { + Text = text; + RelativeSize = relativeSize; + } + + internal string Text { get; set; } + + internal float RelativeSize { get; set; } + + internal float ScaledSize { get; private set; } + + /// + /// Scale the by the global scale value and determine its final size. + /// + /// The base size for the font. + /// The global scaling factor. + /// The dictionary of word scales containing their base sizes. + /// The scaled word size. + internal float Scale(float globalScale) + { + ScaledSize = RelativeSize * globalScale; + return ScaledSize; + } + + public override string ToString() => Text; + } +} diff --git a/docs/New-WordCloud.md b/docs/New-WordCloud.md index 03be8ce..38f8943 100644 --- a/docs/New-WordCloud.md +++ b/docs/New-WordCloud.md @@ -1,729 +1,728 @@ ---- -external help file: PSWordCloudCmdlet.dll-Help.xml -Module Name: PSWordCloud -online version: 2.0 -schema: 2.0.0 ---- - -# New-WordCloud - -## SYNOPSIS - -Describes the syntax and behaviour of the New-WordCloud cmdlet. - -## SYNTAX - -### ColorBackground (Default) -``` -New-WordCloud -InputObject [-Path] [-ImageSize ] [-Typeface ] - [-BackgroundColor ] [-ColorSet ] [-StrokeWidth ] [-StrokeColor ] - [-ExcludeWord ] [-IncludeWord ] [-WordScale ] [-AllowRotation ] - [-Padding ] [-WordBubble ] [-DistanceStep ] [-RadialStep ] - [-MaxRenderedWords ] [-MaxColors ] [-RandomSeed ] [-Monochrome] [-AllowStopWords] - [-AllowOverflow] [-PassThru] [] -``` - -### ColorBackground-FocusWord -``` -New-WordCloud -InputObject [-Path] [-ImageSize ] [-Typeface ] - [-BackgroundColor ] [-ColorSet ] [-StrokeWidth ] [-StrokeColor ] - -FocusWord [-FocusWordAngle ] [-ExcludeWord ] [-IncludeWord ] - [-WordScale ] [-AllowRotation ] [-Padding ] [-WordBubble ] - [-DistanceStep ] [-RadialStep ] [-MaxRenderedWords ] [-MaxColors ] - [-RandomSeed ] [-Monochrome] [-AllowStopWords] [-AllowOverflow] [-PassThru] [] -``` - -### FileBackground -``` -New-WordCloud -InputObject [-Path] -BackgroundImage [-Typeface ] - [-ColorSet ] [-StrokeWidth ] [-StrokeColor ] [-ExcludeWord ] - [-IncludeWord ] [-WordScale ] [-AllowRotation ] [-Padding ] - [-WordBubble ] [-DistanceStep ] [-RadialStep ] [-MaxRenderedWords ] - [-MaxColors ] [-RandomSeed ] [-Monochrome] [-AllowStopWords] [-AllowOverflow] [-PassThru] - [] -``` - -### FileBackground-FocusWord -``` -New-WordCloud -InputObject [-Path] -BackgroundImage [-Typeface ] - [-ColorSet ] [-StrokeWidth ] [-StrokeColor ] -FocusWord - [-FocusWordAngle ] [-ExcludeWord ] [-IncludeWord ] [-WordScale ] - [-AllowRotation ] [-Padding ] [-WordBubble ] - [-DistanceStep ] [-RadialStep ] [-MaxRenderedWords ] [-MaxColors ] - [-RandomSeed ] [-Monochrome] [-AllowStopWords] [-AllowOverflow] [-PassThru] [] -``` - -### ColorBackground-WordTable -``` -New-WordCloud -WordSizes [-Path] [-ImageSize ] [-Typeface ] - [-BackgroundColor ] [-ColorSet ] [-StrokeWidth ] [-StrokeColor ] - [-ExcludeWord ] [-IncludeWord ] [-WordScale ] [-AllowRotation ] - [-Padding ] [-WordBubble ] [-DistanceStep ] [-RadialStep ] - [-MaxRenderedWords ] [-MaxColors ] [-RandomSeed ] [-Monochrome] [-AllowStopWords] - [-AllowOverflow] [-PassThru] [] -``` - -### ColorBackground-FocusWord-WordTable -``` -New-WordCloud -WordSizes [-Path] [-ImageSize ] [-Typeface ] - [-BackgroundColor ] [-ColorSet ] [-StrokeWidth ] [-StrokeColor ] - -FocusWord [-FocusWordAngle ] [-ExcludeWord ] [-IncludeWord ] - [-WordScale ] [-AllowRotation ] [-Padding ] [-WordBubble ] - [-DistanceStep ] [-RadialStep ] [-MaxRenderedWords ] [-MaxColors ] - [-RandomSeed ] [-Monochrome] [-AllowStopWords] [-AllowOverflow] [-PassThru] [] -``` - -### FileBackground-WordTable -``` -New-WordCloud -WordSizes [-Path] -BackgroundImage [-Typeface ] - [-ColorSet ] [-StrokeWidth ] [-StrokeColor ] [-ExcludeWord ] - [-IncludeWord ] [-WordScale ] [-AllowRotation ] [-Padding ] - [-WordBubble ] [-DistanceStep ] [-RadialStep ] [-MaxRenderedWords ] - [-MaxColors ] [-RandomSeed ] [-Monochrome] [-AllowStopWords] [-AllowOverflow] [-PassThru] - [] -``` - -### FileBackground-FocusWord-WordTable -``` -New-WordCloud -WordSizes [-Path] -BackgroundImage [-Typeface ] - [-ColorSet ] [-StrokeWidth ] [-StrokeColor ] -FocusWord - [-FocusWordAngle ] [-ExcludeWord ] [-IncludeWord ] [-WordScale ] - [-AllowRotation ] [-Padding ] [-WordBubble ] - [-DistanceStep ] [-RadialStep ] [-MaxRenderedWords ] [-MaxColors ] - [-RandomSeed ] [-Monochrome] [-AllowStopWords] [-AllowOverflow] [-PassThru] [] -``` - -## DESCRIPTION - -New-WordCloud takes input text either over the pipeline or directly to its -InputObject parameter, -and uses the word frequency distribution to create a word cloud image. More frequently-used words -are rendered larger in the final image. - -## EXAMPLES - -### Example 1 - -```powershell -PS> Get-Content .\MyEntireBook.txt | New-WordCloud -Path .\BookCloud.svg -``` - -Creates a word cloud and saves it to a file called BookCloud.svg in the current folder. - -The default font, color, and layout settings will be used. - -### Example 2 - -```powershell -PS> Get-ChildItem .\scripts -Recurse -Filter "*.ps1" | ->> Get-Content | ->> New-WordCloud -Path .\BookCloud.svg -FocusWord Scripts -ColorSet *blue*, *white*, *tan*, *yellow*, *gold* -Typeface Scriptina -StrokeWidth 2 -StrokeColor Brown -MaxRenderedWords 250 -``` - -Creates a word cloud from all PS1 files in the scripts directory, with the word "Scripts" emblazoned -in the centre. - -Word colors will be chosen from the specified set, including all named SKColors that match the -specified patterns. - -The font Scriptina will be used, and all words will have a brown stroke (outline). - -Up to 250 words will be used in the final image. - -### Example 3 - -```powershell -PS> $Params = @{ - Path = '.\BookCloud.svg' - FocusWord = 'News' - ColorSet = @( - @{Red = 200; Green = 180; Blue = 54} - "1899FF" - "*white*" - "*gold*" - ) - StrokeWidth = 0 - StrokeColor = @{Red = 10; Green = 5; Blue = 45; Alpha = 128} -} -PS> Get-Content .\Newspaper.md | New-WordCloud @Params -``` - -Creates a word cloud from the Newspaper.md file in the current directory, with the word "News" -emblazoned in the centre. - -Word colors will be chosen from the specified set, including all named SKColors that match the -specified patterns and those specified by use of the hashtable and hex-string values. - -The default font will be used, and all words will have a hairline-width semi-transparent dark blue -stroke (outline). - -## PARAMETERS - -### -AllowOverflow - -This option permits the word cloud to overflow the bounds of the canvas. - -Use this in conjunction with the -WordScale parameter to create partially-clipped word clouds. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: AllowBleed - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -AllowRotation - -Specify -AllowRotation None to prevent word rotation entirely, or use one of the options to permit specific rotation modes. - -All modes permit the "upright" standard orientation, as well as their specified additions. - -```yaml -Type: WordOrientations -Parameter Sets: (All) -Aliases: -Accepted values: None, Vertical, FlippedVertical, EitherVertical, UprightDiagonals, InvertedDiagonals, AllDiagonals, AllUpright, AllInverted, All - -Required: False -Position: Named -Default value: EitherVertical -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -AllowStopWords - -This option disables the use of the standard Stop Words list. By default, the following words are -ignored during text processing as they otherwise typically are extremely common and would dominate -the word cloud: - -a, about, above, after, again, against, all, am, an, and, any, are, aren't, as, at, be, because, -been, before, being, below, between, both, but, by, can't, cannot, could, couldn't, did, didn't, -do, does, doesn't, doing, don't, down, during, each, few, for, from, further, had, hadn't, has, -hasn't, have, haven't, having, he, he'd, he'll, he's, her, here, here's, hers, herself, him, -himself, his, how, how's, I, I'd, I'll, I'm, I've, if, in, into, is, isn't, it, it's, its, -itself, let's, me, more, most, mustn't, my, myself, no, nor, not, of, off, on, once, only, or, -other, ought, our, ours, ourselves, out, over, own, same, shan't, she, she'd, she'll, she's, should, -shouldn't, so, some, such, than, that, that's, the, their, theirs, them, themselves, then, there, -there's, these, they, they'd, they'll, they're, they've, this, those, through, to, too, under, -until, up, very, was, wasn't, we, we'd, we'll, we're, we've, were, weren't, what, what's, when, -when's, where, where's, which, while, who, who's, whom, why, why's, with, won't, would, wouldn't, -you, you'd, you'll, you're, you've, your, yours, yourself, yourselves - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: IgnoreStopWords - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -BackgroundColor - -Specifies the SKColor values used for the background of the canvas. Multiple values are accepted, -and each new word pulls the next color from the set. If the end of the set is reached, the next word -will reset the index to the start and retrieve the first color again. - -Accepts input as a complete SKColor object, or one of the following formats: - -1. One or more strings matching one of the named color fields in [SkiaSharp.SKColors]. These values - will be pulled for tab-completion automatically. Names containing wildcards may be used, and all - matching colors will be included in the set. The value "Transparent" is also accepted here. -2. A hexadecimal number string with or without the preceding #, in the form: AARRGGBB, RRGGBB, ARGB, - or RGB. -3. A hashtable or custom object with keys or properties named: "Red, Green, Blue", and/or "Alpha", - with values may range from 0-255. Omitted color values are assumed to be 0, but omitting alpha - defaults it to 255 (fully opaque). - -```yaml -Type: SKColor -Parameter Sets: ColorBackground, ColorBackground-FocusWord, ColorBackground-WordTable, ColorBackground-FocusWord-WordTable -Aliases: Backdrop, CanvasColor - -Required: False -Position: Named -Default value: Black -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -BackgroundImage - -Specifies the path to the background image to be used as a base for the final word cloud image. - -```yaml -Type: String -Parameter Sets: FileBackground, FileBackground-FocusWord, FileBackground-WordTable, FileBackground-FocusWord-WordTable -Aliases: - -Required: True -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -ColorSet - -Specifies the SKColor values used for the words in the cloud. Multiple values are accepted, and each new word pulls the next color from the set. If the end of the set is reached, the next word will reset the index to the start and retrieve the first color again. - -Accepts input as a complete SKColor object, or one of the following formats: - -1. One or more strings matching one of the named color fields in [SkiaSharp.SKColors]. These values will be pulled for tab-completion automatically. Names containing wildcards may be used, and all matching colors will be included in the set. -2. A hexadecimal number string with or without the preceding #, in the form: AARRGGBB, RRGGBB, ARGB, or RGB. -3. A hashtable or custom object with keys or properties named: "Red, Green, Blue", and/or "Alpha", with values may range from 0-255. Omitted color values are assumed to be 0, but omitting alpha defaults it to 255 (fully opaque). - -```yaml -Type: SKColor[] -Parameter Sets: (All) -Aliases: - -Required: False -Position: Named -Default value: * -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -DistanceStep - -Determines the value to scale the distance step by. -Larger numbers will result in more radially spread-out clouds. - -```yaml -Type: Single -Parameter Sets: (All) -Aliases: - -Required: False -Position: Named -Default value: 5.0 -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -ExcludeWord - -Determines the words to be explicitly ignored when rendering the word cloud. -This is usually used to exclude irrelevant words, unwanted URL segments, etc. - -Values from -IncludeWord take precedence over those from this parameter. - -```yaml -Type: String[] -Parameter Sets: (All) -Aliases: ForbidWord, IgnoreWord - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -FocusWord - -Determines the focus word string to be used in the word cloud. This string will typically appear in -the centre of the cloud, larger than all the other words. - -```yaml -Type: String -Parameter Sets: ColorBackground-FocusWord, FileBackground-FocusWord, ColorBackground-FocusWord-WordTable, FileBackground-FocusWord-WordTable -Aliases: Title - -Required: True -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -FocusWordAngle - -Specify an angle in degrees to rotate the focus word by, overriding the default random rotations for the focus word only. - -Values from -360 to 360, including sub-degree increments, are permitted. - -```yaml -Type: Single -Parameter Sets: ColorBackground-FocusWord, FileBackground-FocusWord, ColorBackground-FocusWord-WordTable, FileBackground-FocusWord-WordTable -Aliases: RotateTitle, RotateFocusWord - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -ImageSize - -Determines the canvas size for the word cloud image. - -Input can be passed directly as a [SkiaSharp.SKSizeI] object, or in one of the following formats: - -1. A predefined size string. One of: - - 720p (canvas size: 1280x720) - - 1080p (canvas size: 1920x1080) - - 4K (canvas size: 3840x2160) - - A4 (canvas size: 816x1056) - - Poster11x17 (canvas size: 1056x1632) - - Poster18x24 (canvas size: 1728x2304) - - Poster24x36 (canvas size: 2304x3456) - -2. Single integer (e.g., -ImageSize 1024). This will be used as both the width and height of the image, creating a square canvas. -3. Any image size string (e.g., 1024x768). The first number will be used as the width, and the second number used as the height of the canvas. -4. A hashtable or custom object with keys or properties named "Width" and "Height" that contain integer values - -```yaml -Type: SKSizeI -Parameter Sets: ColorBackground, ColorBackground-FocusWord, ColorBackground-WordTable, ColorBackground-FocusWord-WordTable -Aliases: - -Required: False -Position: Named -Default value: 3840x2160 -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -IncludeWord - -Specifies normally-excluded words to include in the word cloud. - -This parameter takes precedence over the -ExcludeWord parameter. - -```yaml -Type: String[] -Parameter Sets: (All) -Aliases: - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -InputObject - -Provides the input text to supply to the word cloud. -All input is accepted, but will be treated as string data regardless of the input type. -If you are entering complex object input, ensure the objects have a meaningful ToString() method override defined. - -```yaml -Type: PSObject -Parameter Sets: ColorBackground, ColorBackground-FocusWord, FileBackground, FileBackground-FocusWord -Aliases: InputString, Text, String, Words, Document, Page - -Required: True -Position: Named -Default value: None -Accept pipeline input: True (ByValue) -Accept wildcard characters: False -``` - -### -MaxColors - -Determines the maximum number of colors to use from the values contained in the -ColorSet parameter. - -The values from the -ColorSet parameter are shuffled before being trimmed down here, so that you are given a variety of color selections even under default conditions. - -```yaml -Type: Int32 -Parameter Sets: (All) -Aliases: MaxColours - -Required: False -Position: Named -Default value: [int]::MaxValue -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -MaxRenderedWords - -Specifies the maximum number of words to draw in the rendered cloud. -More words take longer to render, and after a few hundred words the visible sizes become unreadable in all but the largest images. - -However, an appropriate vector graphics viewer or editor is still capable of zooming in far enough to see them. - -```yaml -Type: Int32 -Parameter Sets: (All) -Aliases: MaxWords - -Required: False -Position: Named -Default value: 100 -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -Monochrome - -If this option is specified, New-WordCloud draws the word cloud in monochrome (greyscale). -Only the Brightness values from the SKColors in the color set provided will be used. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: BlackAndWhite, Greyscale - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -Padding - -Determines the float value to scale the padding space around the words by. - -```yaml -Type: Single -Parameter Sets: (All) -Aliases: Spacing - -Required: False -Position: Named -Default value: 5.0 -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -PassThru - -Specifying this option causes New-WordCloud to emit a FileInfo object representing the finished SVG file when it is completed. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -Path - -The output file path to save the final SVG vector image to. Output is written as a stream, while the image is being generated. Terminating the command early may result in a usable but partially-formed image, or an invalid SVG file with missing tags. - -```yaml -Type: String -Parameter Sets: (All) -Aliases: OutFile, ExportPath, ImagePath - -Required: True -Position: 0 -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -RadialStep - -Determines the distance around each radial arc that the scanning algorithm takes for each circular sweep. -Larger values correspond to fewer points checked on each radial sweep. -This value is scaled according to distance from the center, so there will be more steps on a larger radius. - -```yaml -Type: Single -Parameter Sets: (All) -Aliases: - -Required: False -Position: Named -Default value: 15.0 -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -RandomSeed - -Determines the seed value for the random numbers used to vary the position and placement patterns. - -```yaml -Type: Int32 -Parameter Sets: (All) -Aliases: SeedValue - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -StrokeColor - -Determines the SKColor value used as the stroke color for the words in the image. -Accepts input as a complete SKColor object, or one of the following formats: - -1. A string color name matching one of the fields in SkiaSharp.SKColors. -These values will be pulled for tab-completion automatically. -Wildcards may be used only if the pattern matches exactly one color name. -2. A hexadecimal number string with or without the preceding #, in the form: AARRGGBB, RRGGBB, ARGB, or RGB. -3. A hashtable or custom object with keys or properties named: "Red, Green, Blue", and/or "Alpha", with values from 0-255. -Omitted color values are assumed to be 0, but omitting alpha defaults it to 255 (fully opaque) - -```yaml -Type: SKColor -Parameter Sets: (All) -Aliases: OutlineColor - -Required: False -Position: Named -Default value: Black -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -StrokeWidth - -Determines the width of the word outline. -Values from 0-10 are permitted. -A zero value indicates the special "Hairline" width, where the width of the stroke depends on the SVG viewing scale. - -```yaml -Type: Single -Parameter Sets: (All) -Aliases: OutlineWidth - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -Typeface - -Gets or sets the typeface to be used in the word cloud. -Input can be processed as a SkiaSharp.SKTypeface object, or one of the following formats: - -1. String value matching a valid font name. These can be autocompleted by pressing [Tab]. -An invalid value will cause the system default to be used. -2. A custom object or hashtable object containing the following keys or properties: - - FamilyName: string value. If no font by this name is available, the system default will be used. - - FontWeight: "Invisible", "ExtraLight", Light", "Thin", "Normal", "Medium", "SemiBold", "Bold", - "ExtraBold", "Black", "ExtraBlack" (Default: "Normal") - - FontSlant: "Upright", "Italic", "Oblique" (Default: "Upright") - - FontWidth: "UltraCondensed", "ExtraCondensed", "Condensed", "SemiCondensed", "Normal", "SemiExpanded", - "Expanded", "ExtraExpanded", "UltraExpanded" (Default: "Normal") - -```yaml -Type: SKTypeface -Parameter Sets: (All) -Aliases: FontFamily, FontFace - -Required: False -Position: Named -Default value: Consolas -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -WordBubble - -Gets or sets the shape of backdrop to place behind each word. -The default is no bubble. -Be aware that circle or square bubbles will take up a lot more space than most words typically do; you may need to reduce the `-WordSize` parameter accordingly if you start getting warnings about words being skipped due to insufficient space. - -```yaml -Type: WordBubbleShape -Parameter Sets: (All) -Aliases: -Accepted values: None, Rectangle, Square, Circle, Oval - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -WordScale - -Applies a scaling value to the words in the cloud. -Use this parameter to shrink or expand your total word cloud area with respect to the size of the image. -The default of 1.0 is approximately equivalent to the total image size. -Scale as appropriate according to how much of the total canvas you would like the cloud to cover. - -The cloud size is restricted to the canvas size by default, so values above 1.0 will typically not have an impact without also supplying the -AllowOverflow option. - -```yaml -Type: Single -Parameter Sets: (All) -Aliases: ScaleFactor - -Required: False -Position: Named -Default value: 1.0 -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -WordSizes - -Instead of supplying a chunk of text as the input, this parameter allows you to define your own relative word sizes. -Supply a dictionary or hashtable object where the keys are the words you want to draw in the cloud, and the values are their relative sizes. -Words will be scaled as a percentage of the largest sized word in the table. -In other words, if you have @{ text = 10; image = 100 }, then "text" will appear 10 times smaller than "image". - -```yaml -Type: IDictionary -Parameter Sets: ColorBackground-WordTable, ColorBackground-FocusWord-WordTable, FileBackground-WordTable, FileBackground-FocusWord-WordTable -Aliases: WordSizeTable, CustomWordSizes - -Required: True -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -### System.Management.Automation.PSObject - -New-WordCloud accepts pipeline input of any type to its -InputObject parameter. -Due to the nature of the command, all inputs will be transformed to string before they are used in the final word cloud. -Complex objects may be reduced to their type names only, if they do not have a predefined conversion path to a string representation. - -## OUTPUTS - -### System.IO.FileInfo - -If the -PassThru switch is used, New-WordCloud will output the FileInfo object representing the completed image file. -Otherwise, there is no output to the console. - -## NOTES - -Due to its dependence on the SkiaSharp library, loading the New-WordCloud module will also expose the SkiaSharp library types for you to use. -This is both by necessity and for configurability. -SkiaSharp types are accessible in the SkiaSharp namespace, for example [SkiaSharp.SKTypeface]. -It is also possible to surface the type names with a using namespace declaration. - -While a lot of work has gone into the parameter transforms to ensure you can customise the final look of the word cloud as much as possible, New-WordCloud seamlessly accepts direct input of the SkiaSharp objects it utilises, so that you can obtain and use your own SkiaSharp library objects for maximum configurability. - -## RELATED LINKS - -[Online Version](https://github.com/vexx32/PSWordCloud/blob/main/docs/New-WordCloud.md) - -[SkiaSharp API Reference](https://docs.microsoft.com/en-us/dotnet/api/skiasharp) +--- +external help file: PSWordCloudCmdlet.dll-Help.xml +Module Name: PSWordCloud +online version: 2.0 +schema: 2.0.0 +--- + +# New-WordCloud + +## SYNOPSIS + +Describes the syntax and behaviour of the New-WordCloud cmdlet. + +## SYNTAX + +### ColorBackground (Default) +``` +New-WordCloud -InputObject [-Path] [-ImageSize ] [-Typeface ] + [-BackgroundColor ] [-ColorSet ] [-StrokeWidth ] [-StrokeColor ] + [-ExcludeWord ] [-IncludeWord ] [-WordScale ] [-AllowRotation ] + [-Padding ] [-WordBubble ] [-DistanceStep ] [-RadialStep ] + [-MaxRenderedWords ] [-MaxColors ] [-RandomSeed ] [-Monochrome] [-AllowStopWords] + [-AllowOverflow] [-PassThru] [] +``` + +### ColorBackground-FocusWord +``` +New-WordCloud -InputObject [-Path] [-ImageSize ] [-Typeface ] + [-BackgroundColor ] [-ColorSet ] [-StrokeWidth ] [-StrokeColor ] + -FocusWord [-FocusWordAngle ] [-ExcludeWord ] [-IncludeWord ] + [-WordScale ] [-AllowRotation ] [-Padding ] [-WordBubble ] + [-DistanceStep ] [-RadialStep ] [-MaxRenderedWords ] [-MaxColors ] + [-RandomSeed ] [-Monochrome] [-AllowStopWords] [-AllowOverflow] [-PassThru] [] +``` + +### FileBackground +``` +New-WordCloud -InputObject [-Path] -BackgroundImage [-Typeface ] + [-ColorSet ] [-StrokeWidth ] [-StrokeColor ] [-ExcludeWord ] + [-IncludeWord ] [-WordScale ] [-AllowRotation ] [-Padding ] + [-WordBubble ] [-DistanceStep ] [-RadialStep ] [-MaxRenderedWords ] + [-MaxColors ] [-RandomSeed ] [-Monochrome] [-AllowStopWords] [-AllowOverflow] [-PassThru] + [] +``` + +### FileBackground-FocusWord +``` +New-WordCloud -InputObject [-Path] -BackgroundImage [-Typeface ] + [-ColorSet ] [-StrokeWidth ] [-StrokeColor ] -FocusWord + [-FocusWordAngle ] [-ExcludeWord ] [-IncludeWord ] [-WordScale ] + [-AllowRotation ] [-Padding ] [-WordBubble ] + [-DistanceStep ] [-RadialStep ] [-MaxRenderedWords ] [-MaxColors ] + [-RandomSeed ] [-Monochrome] [-AllowStopWords] [-AllowOverflow] [-PassThru] [] +``` + +### ColorBackground-WordTable +``` +New-WordCloud -WordSizes [-Path] [-ImageSize ] [-Typeface ] + [-BackgroundColor ] [-ColorSet ] [-StrokeWidth ] [-StrokeColor ] + [-ExcludeWord ] [-IncludeWord ] [-WordScale ] [-AllowRotation ] + [-Padding ] [-WordBubble ] [-DistanceStep ] [-RadialStep ] + [-MaxColors ] [-RandomSeed ] [-Monochrome] [-AllowStopWords] [-AllowOverflow] [-PassThru] + [] +``` + +### ColorBackground-FocusWord-WordTable +``` +New-WordCloud -WordSizes [-Path] [-ImageSize ] [-Typeface ] + [-BackgroundColor ] [-ColorSet ] [-StrokeWidth ] [-StrokeColor ] + -FocusWord [-FocusWordAngle ] [-ExcludeWord ] [-IncludeWord ] + [-WordScale ] [-AllowRotation ] [-Padding ] [-WordBubble ] + [-DistanceStep ] [-RadialStep ] [-MaxColors ] [-RandomSeed ] [-Monochrome] + [-AllowStopWords] [-AllowOverflow] [-PassThru] [] +``` + +### FileBackground-WordTable +``` +New-WordCloud -WordSizes [-Path] -BackgroundImage [-Typeface ] + [-ColorSet ] [-StrokeWidth ] [-StrokeColor ] [-ExcludeWord ] + [-IncludeWord ] [-WordScale ] [-AllowRotation ] [-Padding ] + [-WordBubble ] [-DistanceStep ] [-RadialStep ] [-MaxColors ] + [-RandomSeed ] [-Monochrome] [-AllowStopWords] [-AllowOverflow] [-PassThru] [] +``` + +### FileBackground-FocusWord-WordTable +``` +New-WordCloud -WordSizes [-Path] -BackgroundImage [-Typeface ] + [-ColorSet ] [-StrokeWidth ] [-StrokeColor ] -FocusWord + [-FocusWordAngle ] [-ExcludeWord ] [-IncludeWord ] [-WordScale ] + [-AllowRotation ] [-Padding ] [-WordBubble ] + [-DistanceStep ] [-RadialStep ] [-MaxColors ] [-RandomSeed ] [-Monochrome] + [-AllowStopWords] [-AllowOverflow] [-PassThru] [] +``` + +## DESCRIPTION + +New-WordCloud takes input text either over the pipeline or directly to its -InputObject parameter, +and uses the word frequency distribution to create a word cloud image. More frequently-used words +are rendered larger in the final image. + +## EXAMPLES + +### Example 1 + +```powershell +PS> Get-Content .\MyEntireBook.txt | New-WordCloud -Path .\BookCloud.svg +``` + +Creates a word cloud and saves it to a file called BookCloud.svg in the current folder. + +The default font, color, and layout settings will be used. + +### Example 2 + +```powershell +PS> Get-ChildItem .\scripts -Recurse -Filter "*.ps1" | +>> Get-Content | +>> New-WordCloud -Path .\BookCloud.svg -FocusWord Scripts -ColorSet *blue*, *white*, *tan*, *yellow*, *gold* -Typeface Scriptina -StrokeWidth 2 -StrokeColor Brown -MaxRenderedWords 250 +``` + +Creates a word cloud from all PS1 files in the scripts directory, with the word "Scripts" emblazoned +in the centre. + +Word colors will be chosen from the specified set, including all named SKColors that match the +specified patterns. + +The font Scriptina will be used, and all words will have a brown stroke (outline). + +Up to 250 words will be used in the final image. + +### Example 3 + +```powershell +PS> $Params = @{ + Path = '.\BookCloud.svg' + FocusWord = 'News' + ColorSet = @( + @{Red = 200; Green = 180; Blue = 54} + "1899FF" + "*white*" + "*gold*" + ) + StrokeWidth = 0 + StrokeColor = @{Red = 10; Green = 5; Blue = 45; Alpha = 128} +} +PS> Get-Content .\Newspaper.md | New-WordCloud @Params +``` + +Creates a word cloud from the Newspaper.md file in the current directory, with the word "News" +emblazoned in the centre. + +Word colors will be chosen from the specified set, including all named SKColors that match the +specified patterns and those specified by use of the hashtable and hex-string values. + +The default font will be used, and all words will have a hairline-width semi-transparent dark blue +stroke (outline). + +## PARAMETERS + +### -AllowOverflow + +This option permits the word cloud to overflow the bounds of the canvas. + +Use this in conjunction with the -WordScale parameter to create partially-clipped word clouds. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: AllowBleed + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -AllowRotation + +Specify -AllowRotation None to prevent word rotation entirely, or use one of the options to permit specific rotation modes. + +All modes permit the "upright" standard orientation, as well as their specified additions. + +```yaml +Type: WordOrientations +Parameter Sets: (All) +Aliases: +Accepted values: None, Vertical, FlippedVertical, EitherVertical, UprightDiagonals, InvertedDiagonals, AllDiagonals, AllUpright, AllInverted, All + +Required: False +Position: Named +Default value: EitherVertical +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -AllowStopWords + +This option disables the use of the standard Stop Words list. By default, the following words are +ignored during text processing as they otherwise typically are extremely common and would dominate +the word cloud: + +a, about, above, after, again, against, all, am, an, and, any, are, aren't, as, at, be, because, +been, before, being, below, between, both, but, by, can't, cannot, could, couldn't, did, didn't, +do, does, doesn't, doing, don't, down, during, each, few, for, from, further, had, hadn't, has, +hasn't, have, haven't, having, he, he'd, he'll, he's, her, here, here's, hers, herself, him, +himself, his, how, how's, I, I'd, I'll, I'm, I've, if, in, into, is, isn't, it, it's, its, +itself, let's, me, more, most, mustn't, my, myself, no, nor, not, of, off, on, once, only, or, +other, ought, our, ours, ourselves, out, over, own, same, shan't, she, she'd, she'll, she's, should, +shouldn't, so, some, such, than, that, that's, the, their, theirs, them, themselves, then, there, +there's, these, they, they'd, they'll, they're, they've, this, those, through, to, too, under, +until, up, very, was, wasn't, we, we'd, we'll, we're, we've, were, weren't, what, what's, when, +when's, where, where's, which, while, who, who's, whom, why, why's, with, won't, would, wouldn't, +you, you'd, you'll, you're, you've, your, yours, yourself, yourselves + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: IgnoreStopWords + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -BackgroundColor + +Specifies the SKColor values used for the background of the canvas. Multiple values are accepted, +and each new word pulls the next color from the set. If the end of the set is reached, the next word +will reset the index to the start and retrieve the first color again. + +Accepts input as a complete SKColor object, or one of the following formats: + +1. One or more strings matching one of the named color fields in [SkiaSharp.SKColors]. These values + will be pulled for tab-completion automatically. Names containing wildcards may be used, and all + matching colors will be included in the set. The value "Transparent" is also accepted here. +2. A hexadecimal number string with or without the preceding #, in the form: AARRGGBB, RRGGBB, ARGB, + or RGB. +3. A hashtable or custom object with keys or properties named: "Red, Green, Blue", and/or "Alpha", + with values may range from 0-255. Omitted color values are assumed to be 0, but omitting alpha + defaults it to 255 (fully opaque). + +```yaml +Type: SKColor +Parameter Sets: ColorBackground, ColorBackground-FocusWord, ColorBackground-WordTable, ColorBackground-FocusWord-WordTable +Aliases: Backdrop, CanvasColor + +Required: False +Position: Named +Default value: Black +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -BackgroundImage + +Specifies the path to the background image to be used as a base for the final word cloud image. + +```yaml +Type: String +Parameter Sets: FileBackground, FileBackground-FocusWord, FileBackground-WordTable, FileBackground-FocusWord-WordTable +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ColorSet + +Specifies the SKColor values used for the words in the cloud. Multiple values are accepted, and each new word pulls the next color from the set. If the end of the set is reached, the next word will reset the index to the start and retrieve the first color again. + +Accepts input as a complete SKColor object, or one of the following formats: + +1. One or more strings matching one of the named color fields in [SkiaSharp.SKColors]. These values will be pulled for tab-completion automatically. Names containing wildcards may be used, and all matching colors will be included in the set. +2. A hexadecimal number string with or without the preceding #, in the form: AARRGGBB, RRGGBB, ARGB, or RGB. +3. A hashtable or custom object with keys or properties named: "Red, Green, Blue", and/or "Alpha", with values may range from 0-255. Omitted color values are assumed to be 0, but omitting alpha defaults it to 255 (fully opaque). + +```yaml +Type: SKColor[] +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: * +Accept pipeline input: False +Accept wildcard characters: True +``` + +### -DistanceStep + +Determines the value to scale the distance step by. +Larger numbers will result in more radially spread-out clouds. + +```yaml +Type: Single +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: 5.0 +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ExcludeWord + +Determines the words to be explicitly ignored when rendering the word cloud. +This is usually used to exclude irrelevant words, unwanted URL segments, etc. + +Values from -IncludeWord take precedence over those from this parameter. + +```yaml +Type: String[] +Parameter Sets: (All) +Aliases: ForbidWord, IgnoreWord + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -FocusWord + +Determines the focus word string to be used in the word cloud. This string will typically appear in +the centre of the cloud, larger than all the other words. + +```yaml +Type: String +Parameter Sets: ColorBackground-FocusWord, FileBackground-FocusWord, ColorBackground-FocusWord-WordTable, FileBackground-FocusWord-WordTable +Aliases: Title + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -FocusWordAngle + +Specify an angle in degrees to rotate the focus word by, overriding the default random rotations for the focus word only. + +Values from -360 to 360, including sub-degree increments, are permitted. + +```yaml +Type: Single +Parameter Sets: ColorBackground-FocusWord, FileBackground-FocusWord, ColorBackground-FocusWord-WordTable, FileBackground-FocusWord-WordTable +Aliases: RotateTitle, RotateFocusWord + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ImageSize + +Determines the canvas size for the word cloud image. + +Input can be passed directly as a [SkiaSharp.SKSizeI] object, or in one of the following formats: + +1. A predefined size string. One of: + - 720p (canvas size: 1280x720) + - 1080p (canvas size: 1920x1080) + - 4K (canvas size: 3840x2160) + - A4 (canvas size: 816x1056) + - Poster11x17 (canvas size: 1056x1632) + - Poster18x24 (canvas size: 1728x2304) + - Poster24x36 (canvas size: 2304x3456) + +2. Single integer (e.g., -ImageSize 1024). This will be used as both the width and height of the image, creating a square canvas. +3. Any image size string (e.g., 1024x768). The first number will be used as the width, and the second number used as the height of the canvas. +4. A hashtable or custom object with keys or properties named "Width" and "Height" that contain integer values + +```yaml +Type: SKSizeI +Parameter Sets: ColorBackground, ColorBackground-FocusWord, ColorBackground-WordTable, ColorBackground-FocusWord-WordTable +Aliases: + +Required: False +Position: Named +Default value: 3840x2160 +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -IncludeWord + +Specifies normally-excluded words to include in the word cloud. + +This parameter takes precedence over the -ExcludeWord parameter. + +```yaml +Type: String[] +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -InputObject + +Provides the input text to supply to the word cloud. +All input is accepted, but will be treated as string data regardless of the input type. +If you are entering complex object input, ensure the objects have a meaningful ToString() method override defined. + +```yaml +Type: PSObject +Parameter Sets: ColorBackground, ColorBackground-FocusWord, FileBackground, FileBackground-FocusWord +Aliases: InputString, Text, String, Words, Document, Page + +Required: True +Position: Named +Default value: None +Accept pipeline input: True (ByValue) +Accept wildcard characters: False +``` + +### -MaxColors + +Determines the maximum number of colors to use from the values contained in the -ColorSet parameter. + +The values from the -ColorSet parameter are shuffled before being trimmed down here, so that you are given a variety of color selections even under default conditions. + +```yaml +Type: Int32 +Parameter Sets: (All) +Aliases: MaxColours + +Required: False +Position: Named +Default value: [int]::MaxValue +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -MaxRenderedWords + +Specifies the maximum number of words to draw in the rendered cloud. +More words take longer to render, and after a few hundred words the visible sizes become unreadable in all but the largest images. + +However, an appropriate vector graphics viewer or editor is still capable of zooming in far enough to see them. + +```yaml +Type: Int32 +Parameter Sets: ColorBackground, ColorBackground-FocusWord, FileBackground, FileBackground-FocusWord +Aliases: MaxWords + +Required: False +Position: Named +Default value: 100 +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Monochrome + +If this option is specified, New-WordCloud draws the word cloud in monochrome (greyscale). +Only the Brightness values from the SKColors in the color set provided will be used. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: BlackAndWhite, Greyscale + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Padding + +Determines the float value to scale the padding space around the words by. + +```yaml +Type: Single +Parameter Sets: (All) +Aliases: Spacing + +Required: False +Position: Named +Default value: 5.0 +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -PassThru + +Specifying this option causes New-WordCloud to emit a FileInfo object representing the finished SVG file when it is completed. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Path + +The output file path to save the final SVG vector image to. Output is written as a stream, while the image is being generated. Terminating the command early may result in a usable but partially-formed image, or an invalid SVG file with missing tags. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: OutFile, ExportPath, ImagePath + +Required: True +Position: 0 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -RadialStep + +Determines the distance around each radial arc that the scanning algorithm takes for each circular sweep. +Larger values correspond to fewer points checked on each radial sweep. +This value is scaled according to distance from the center, so there will be more steps on a larger radius. + +```yaml +Type: Single +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: 15.0 +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -RandomSeed + +Determines the seed value for the random numbers used to vary the position and placement patterns. + +```yaml +Type: Int32 +Parameter Sets: (All) +Aliases: SeedValue + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -StrokeColor + +Determines the SKColor value used as the stroke color for the words in the image. +Accepts input as a complete SKColor object, or one of the following formats: + +1. A string color name matching one of the fields in SkiaSharp.SKColors. +These values will be pulled for tab-completion automatically. +Wildcards may be used only if the pattern matches exactly one color name. +2. A hexadecimal number string with or without the preceding #, in the form: AARRGGBB, RRGGBB, ARGB, or RGB. +3. A hashtable or custom object with keys or properties named: "Red, Green, Blue", and/or "Alpha", with values from 0-255. +Omitted color values are assumed to be 0, but omitting alpha defaults it to 255 (fully opaque) + +```yaml +Type: SKColor +Parameter Sets: (All) +Aliases: OutlineColor + +Required: False +Position: Named +Default value: Black +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -StrokeWidth + +Determines the width of the word outline. +Values from 0-10 are permitted. +A zero value indicates the special "Hairline" width, where the width of the stroke depends on the SVG viewing scale. + +```yaml +Type: Single +Parameter Sets: (All) +Aliases: OutlineWidth + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Typeface + +Gets or sets the typeface to be used in the word cloud. +Input can be processed as a SkiaSharp.SKTypeface object, or one of the following formats: + +1. String value matching a valid font name. These can be autocompleted by pressing [Tab]. +An invalid value will cause the system default to be used. +2. A custom object or hashtable object containing the following keys or properties: + - FamilyName: string value. If no font by this name is available, the system default will be used. + - FontWeight: "Invisible", "ExtraLight", Light", "Thin", "Normal", "Medium", "SemiBold", "Bold", + "ExtraBold", "Black", "ExtraBlack" (Default: "Normal") + - FontSlant: "Upright", "Italic", "Oblique" (Default: "Upright") + - FontWidth: "UltraCondensed", "ExtraCondensed", "Condensed", "SemiCondensed", "Normal", "SemiExpanded", + "Expanded", "ExtraExpanded", "UltraExpanded" (Default: "Normal") + +```yaml +Type: SKTypeface +Parameter Sets: (All) +Aliases: FontFamily, FontFace + +Required: False +Position: Named +Default value: Consolas +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WordBubble + +Gets or sets the shape of backdrop to place behind each word. +The default is no bubble. +Be aware that circle or square bubbles will take up a lot more space than most words typically do; you may need to reduce the `-WordSize` parameter accordingly if you start getting warnings about words being skipped due to insufficient space. + +```yaml +Type: WordBubbleShape +Parameter Sets: (All) +Aliases: +Accepted values: None, Rectangle, Square, Circle, Oval + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WordScale + +Applies a scaling value to the words in the cloud. +Use this parameter to shrink or expand your total word cloud area with respect to the size of the image. +The default of 1.0 is approximately equivalent to the total image size. +Scale as appropriate according to how much of the total canvas you would like the cloud to cover. + +The cloud size is restricted to the canvas size by default, so values above 1.0 will typically not have an impact without also supplying the -AllowOverflow option. + +```yaml +Type: Single +Parameter Sets: (All) +Aliases: ScaleFactor + +Required: False +Position: Named +Default value: 1.0 +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WordSizes + +Instead of supplying a chunk of text as the input, this parameter allows you to define your own relative word sizes. +Supply a dictionary or hashtable object where the keys are the words you want to draw in the cloud, and the values are their relative sizes. +Words will be scaled as a percentage of the largest sized word in the table. +In other words, if you have @{ text = 10; image = 100 }, then "text" will appear 10 times smaller than "image". + +```yaml +Type: IDictionary +Parameter Sets: ColorBackground-WordTable, ColorBackground-FocusWord-WordTable, FileBackground-WordTable, FileBackground-FocusWord-WordTable +Aliases: WordSizeTable, CustomWordSizes + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### System.Management.Automation.PSObject + +New-WordCloud accepts pipeline input of any type to its -InputObject parameter. +Due to the nature of the command, all inputs will be transformed to string before they are used in the final word cloud. +Complex objects may be reduced to their type names only, if they do not have a predefined conversion path to a string representation. + +## OUTPUTS + +### System.IO.FileInfo + +If the -PassThru switch is used, New-WordCloud will output the FileInfo object representing the completed image file. +Otherwise, there is no output to the console. + +## NOTES + +Due to its dependence on the SkiaSharp library, loading the New-WordCloud module will also expose the SkiaSharp library types for you to use. +This is both by necessity and for configurability. +SkiaSharp types are accessible in the SkiaSharp namespace, for example [SkiaSharp.SKTypeface]. +It is also possible to surface the type names with a using namespace declaration. + +While a lot of work has gone into the parameter transforms to ensure you can customise the final look of the word cloud as much as possible, New-WordCloud seamlessly accepts direct input of the SkiaSharp objects it utilises, so that you can obtain and use your own SkiaSharp library objects for maximum configurability. + +## RELATED LINKS + +[Online Version](https://github.com/vexx32/PSWordCloud/blob/main/docs/New-WordCloud.md) + +[SkiaSharp API Reference](https://docs.microsoft.com/en-us/dotnet/api/skiasharp)