Skip to content

Commit

Permalink
✨ Changes for 3.1.0 (#39)
Browse files Browse the repository at this point in the history
* ✨ Add Viewbox attribute to output SVG

* 🎨 swallow some non-critical errors

* ✨ Use XML method to add attribute

+ Should be a fair bit more reliable than the regex route.

* ✨ add -WordBubble parameter

* 📝 version bump
  • Loading branch information
vexx32 authored Mar 18, 2020
1 parent b56c2b8 commit 88d7775
Show file tree
Hide file tree
Showing 4 changed files with 939 additions and 738 deletions.
Binary file modified Module/PSWordCloud.psd1
Binary file not shown.
166 changes: 136 additions & 30 deletions Module/src/PSWordCloud/NewWordCloudCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Xml;
using SkiaSharp;

[assembly: InternalsVisibleTo("PSWordCloud.Tests")]
Expand All @@ -30,11 +31,10 @@ public class NewWordCloudCommand : PSCmdlet

private const float FOCUS_WORD_SCALE = 1.3f;
private const float BLEED_AREA_SCALE = 1.2f;
private const float MIN_SATURATION_VALUE = 5f;
private const float MIN_BRIGHTNESS_DISTANCE = 25f;
private const float MAX_WORD_WIDTH_PERCENT = 1.0f;
private const float PADDING_BASE_SCALE = 0.06f;
private const float MAX_WORD_AREA_PERCENT = 0.0575f;
private const float BUBBLE_INFLATION_SCALE = 0.25f;

private const char ELLIPSIS = '…';

Expand Down Expand Up @@ -95,6 +95,7 @@ public class NewWordCloudCommand : PSCmdlet
public PSObject InputObject { get; set; }

/// <summary>
/// Gets or sets the input word dictionary.
/// Instead of supplying a chunk of text as the input, this parameter allows you to define your own relative
/// word sizes.
/// Supply a dictionary or hashtable object where the keys are the words you want to draw in the cloud, and the
Expand Down Expand Up @@ -330,6 +331,17 @@ public string BackgroundImage
[Alias("Spacing")]
public float Padding { get; set; } = 5;

/// <summary>
/// Get or sets the shape of backdrop to place behind each word.
/// The default is no bubble.
/// Be aware that circle or square bubbles will take up a lot more space than most words typically do;
/// you may need to reduce the `-WordSize` parameter accordingly if you start getting warnings about words
/// being skipped due to insufficient space.

/// </summary>
[Parameter()]
public WordBubbleShape WordBubble { get; set; } = WordBubbleShape.None;

/// <summary>
/// Gets or sets the value to scale the distance step by. Larger numbers will result in more radially spaced
/// out clouds.
Expand Down Expand Up @@ -428,6 +440,18 @@ private SKColor GetNextColor()
return color;
}

private SKColor GetContrastingColor(SKColor reference)
{
SKColor result;
do
{
result = GetNextColor();
}
while (!result.IsDistinctColor(reference));

return result;
}

private float NextDrawAngle()
{
return AllowRotation switch
Expand Down Expand Up @@ -469,7 +493,7 @@ private float NextDrawAngle()
};
}

private float _paddingMultiplier => Padding * PADDING_BASE_SCALE;
private float _paddingMultiplier { get => Padding * PADDING_BASE_SCALE; }

#endregion privateVariables

Expand Down Expand Up @@ -526,6 +550,13 @@ protected override void ProcessRecord()
/// </summary>
protected override void EndProcessing()
{
if ((WordSizes == null || WordSizes.Count == 0)
&& (_wordProcessingTasks == null || _wordProcessingTasks.Count == 0))
{
// No input was supplied; exit stage left.
return;
}

int wordCount = 0;
float inflationValue;
float maxWordWidth;
Expand All @@ -536,7 +567,7 @@ protected override void EndProcessing()
SKPath wordPath = null;
SKRegion clipRegion = null;
SKRect wordBounds = SKRect.Empty;
SKRect drawableBounds = SKRect.Empty;
SKRect viewbox = SKRect.Empty;
SKBitmap backgroundImage = null;
SKPoint centrePoint;
List<string> sortedWordList;
Expand Down Expand Up @@ -598,24 +629,27 @@ protected override void EndProcessing()
wordScaleDictionary[FocusWord] = highestWordFreq *= FOCUS_WORD_SCALE;
}

// Get a sorted list of words by their sizes
sortedWordList = new List<string>(SortWordList(wordScaleDictionary, MaxRenderedWords));

try
{
if (MyInvocation.BoundParameters.ContainsKey(nameof(BackgroundImage)))
{
// Set image size from the background size
WriteDebug($"Importing background image from '{_backgroundFullPath}'.");
backgroundImage = SKBitmap.Decode(_backgroundFullPath);
drawableBounds = new SKRectI(0, 0, backgroundImage.Width, backgroundImage.Height);
viewbox = new SKRectI(0, 0, backgroundImage.Width, backgroundImage.Height);
}
else
{
drawableBounds = new SKRectI(0, 0, ImageSize.Width, ImageSize.Height);
// Set image size from default or specified size
viewbox = new SKRectI(0, 0, ImageSize.Width, ImageSize.Height);
}

wordPath = new SKPath();
clipRegion = new SKRegion();
clipRegion.SetRect(SKRectI.Round(drawableBounds));
clipRegion.SetRect(SKRectI.Round(viewbox));

_fontScale = FontScale(
clipRegion.Bounds,
Expand All @@ -629,8 +663,8 @@ protected override void EndProcessing()
StringComparer.OrdinalIgnoreCase);

maxWordWidth = AllowRotation == WordOrientations.None
? drawableBounds.Width * MAX_WORD_WIDTH_PERCENT
: Math.Max(drawableBounds.Width, drawableBounds.Height) * MAX_WORD_WIDTH_PERCENT;
? viewbox.Width * MAX_WORD_WIDTH_PERCENT
: Math.Max(viewbox.Width, viewbox.Height) * MAX_WORD_WIDTH_PERCENT;

using SKPaint brush = new SKPaint
{
Expand All @@ -656,7 +690,7 @@ protected override void EndProcessing()
var adjustedTextWidth = textRect.Width * (1 + _paddingMultiplier) + StrokeWidth * 2 * STROKE_BASE_SCALE;

if (adjustedTextWidth > maxWordWidth
|| textRect.Width * textRect.Height < drawableBounds.Width * drawableBounds.Height * MAX_WORD_AREA_PERCENT)
|| textRect.Width * textRect.Height < viewbox.Width * viewbox.Height * MAX_WORD_AREA_PERCENT)
{
retry = true;
_fontScale *= 1.05f;
Expand Down Expand Up @@ -685,7 +719,7 @@ protected override void EndProcessing()

if (!AllowOverflow.IsPresent
&& (adjustedTextWidth > maxWordWidth
|| textRect.Width * textRect.Height > drawableBounds.Width * drawableBounds.Height * MAX_WORD_AREA_PERCENT))
|| textRect.Width * textRect.Height > viewbox.Width * viewbox.Height * MAX_WORD_AREA_PERCENT))
{
retry = true;
_fontScale *= 0.95f;
Expand All @@ -698,28 +732,33 @@ protected override void EndProcessing()
}
while (retry);

aspectRatio = drawableBounds.Width / (float)drawableBounds.Height;
centrePoint = new SKPoint(drawableBounds.MidX, drawableBounds.MidY);
aspectRatio = viewbox.Width / viewbox.Height;
centrePoint = new SKPoint(viewbox.MidX, viewbox.MidY);

// Remove all words that were cut from the final rendering list
sortedWordList.RemoveAll(x => !scaledWordSizes.ContainsKey(x));

maxRadius = 9 * Math.Max(drawableBounds.Width, drawableBounds.Height) / 16f;
maxRadius = 9 * Math.Max(viewbox.Width, viewbox.Height) / 16f;

using SKDynamicMemoryWStream outputStream = new SKDynamicMemoryWStream();
using SKXmlStreamWriter xmlWriter = new SKXmlStreamWriter(outputStream);
using SKCanvas canvas = SKSvgCanvas.Create(drawableBounds, xmlWriter);
using SKCanvas canvas = SKSvgCanvas.Create(viewbox, xmlWriter);
using SKRegion occupiedSpace = new SKRegion();

brush.IsAutohinted = true;
brush.IsAntialias = true;
brush.Typeface = Typeface;

SKRect drawableBounds;
if (MyInvocation.BoundParameters.ContainsKey(nameof(AllowOverflow)))
{
drawableBounds.Inflate(
drawableBounds.Width * BLEED_AREA_SCALE,
drawableBounds.Height * BLEED_AREA_SCALE);
drawableBounds = SKRect.Create(
viewbox.Location,
new SKSize(viewbox.Width * BLEED_AREA_SCALE, viewbox.Height * BLEED_AREA_SCALE));
}
else
{
drawableBounds = viewbox;
}

if (ParameterSetName.StartsWith(FILE_SET))
Expand Down Expand Up @@ -799,7 +838,7 @@ protected override void EndProcessing()
foreach (var point in radialPoints)
{
pointsChecked++;
if (!drawableBounds.Contains(point) && point != centrePoint)
if (!viewbox.Contains(point) && point != centrePoint)
{
continue;
}
Expand Down Expand Up @@ -876,10 +915,56 @@ protected override void EndProcessing()
}

brush.IsStroke = false;
brush.Color = wordColor;
brush.Style = SKPaintStyle.Fill;

occupiedSpace.Op(wordPath, SKRegionOperation.Union);
if (WordBubble != WordBubbleShape.None)
{
SKRect bubbleRect = wordPath.ComputeTightBounds();
bubbleRect.Inflate(
bubbleRect.Width * BUBBLE_INFLATION_SCALE,
bubbleRect.Height * BUBBLE_INFLATION_SCALE);

using SKPath bubblePath = new SKPath();
SKRoundRect wordBubble;
float radius;

switch (WordBubble)
{
case WordBubbleShape.Rectangle:
radius = bubbleRect.Height / 8;
wordBubble = new SKRoundRect(bubbleRect, radius, radius);
bubblePath.AddRoundRect(wordBubble);
break;

case WordBubbleShape.Square:
radius = Math.Max(bubbleRect.Width, bubbleRect.Height) / 8;
wordBubble = new SKRoundRect(bubbleRect.GetEnclosingSquare(), radius, radius);
bubblePath.AddRoundRect(wordBubble);
break;

case WordBubbleShape.Circle:
radius = Math.Max(bubbleRect.Width, bubbleRect.Height) / 2;
bubblePath.AddCircle(bubbleRect.MidX, bubbleRect.MidY, radius);
break;

case WordBubbleShape.Oval:
bubblePath.AddOval(bubbleRect);
break;
}

// If we're using word bubbles, the bubbles should more or less enclose the words.
occupiedSpace.Op(bubblePath, SKRegionOperation.Union);

brush.Color = GetContrastingColor(wordColor);
canvas.DrawPath(bubblePath, brush);
}
else
{
// If we're not using bubbles, record the exact space the word occupies.
occupiedSpace.Op(wordPath, SKRegionOperation.Union);
}

brush.Color = wordColor;
canvas.DrawPath(wordPath, brush);
}
else
Expand All @@ -893,7 +978,7 @@ protected override void EndProcessing()
canvas.Dispose();
outputStream.Flush();

SaveSvgData(outputStream);
SaveSvgData(outputStream, viewbox);

if (PassThru.IsPresent)
{
Expand Down Expand Up @@ -928,22 +1013,46 @@ protected override void EndProcessing()

#region HelperMethods

private void SaveSvgData(SKDynamicMemoryWStream outputStream)
private void SaveSvgData(SKDynamicMemoryWStream outputStream, SKRect viewbox)
{
string[] path = new[] { Path };

if (InvokeProvider.Item.Exists(Path, force: true, literalPath: true))
{
WriteDebug($"Clearing existing content from '{Path}'.");
InvokeProvider.Content.Clear(path, force: false, literalPath: true);
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}");
}
}

using SKData data = outputStream.CopyToData();
using SKData data = outputStream.DetachAsData();
using var reader = new StreamReader(data.AsStream());
using var writer = InvokeProvider.Content.GetWriter(path, force: false, literalPath: true).First();

var imageXml = new XmlDocument();
imageXml.LoadXml(reader.ReadToEnd());

var svgElement = imageXml.GetElementsByTagName("svg")[0] as XmlElement;
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[] { reader.ReadToEnd() });
writer.Write(new[] { imageXml.GetPrettyString() });
writer.Close();
}

Expand Down Expand Up @@ -1034,15 +1143,12 @@ private static IEnumerable<SKColor> ProcessColorSet(
bool monochrome)
{
Shuffle(set);
background.ToHsv(out _, out _, out float backgroundBrightness);

foreach (var color in set.Where(x => x != stroke && x != background).Take(maxCount))
{
if (!monochrome)
{
color.ToHsv(out _, out float saturation, out float brightness);
if (saturation >= MIN_SATURATION_VALUE
&& Math.Abs(brightness - backgroundBrightness) > MIN_BRIGHTNESS_DISTANCE)
if (color.IsDistinctColor(background))
{
yield return color;
}
Expand Down
Loading

0 comments on commit 88d7775

Please sign in to comment.