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