Skip to content

Commit 88d7775

Browse files
authored
✨ Changes for 3.1.0 (#39)
* ✨ 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
1 parent b56c2b8 commit 88d7775

File tree

4 files changed

+939
-738
lines changed

4 files changed

+939
-738
lines changed

Module/PSWordCloud.psd1

0 Bytes
Binary file not shown.

Module/src/PSWordCloud/NewWordCloudCommand.cs

+136-30
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using System.Runtime.CompilerServices;
99
using System.Text.RegularExpressions;
1010
using System.Threading.Tasks;
11+
using System.Xml;
1112
using SkiaSharp;
1213

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

3132
private const float FOCUS_WORD_SCALE = 1.3f;
3233
private const float BLEED_AREA_SCALE = 1.2f;
33-
private const float MIN_SATURATION_VALUE = 5f;
34-
private const float MIN_BRIGHTNESS_DISTANCE = 25f;
3534
private const float MAX_WORD_WIDTH_PERCENT = 1.0f;
3635
private const float PADDING_BASE_SCALE = 0.06f;
3736
private const float MAX_WORD_AREA_PERCENT = 0.0575f;
37+
private const float BUBBLE_INFLATION_SCALE = 0.25f;
3838

3939
private const char ELLIPSIS = '…';
4040

@@ -95,6 +95,7 @@ public class NewWordCloudCommand : PSCmdlet
9595
public PSObject InputObject { get; set; }
9696

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

334+
/// <summary>
335+
/// Get or sets the shape of backdrop to place behind each word.
336+
/// The default is no bubble.
337+
/// Be aware that circle or square bubbles will take up a lot more space than most words typically do;
338+
/// you may need to reduce the `-WordSize` parameter accordingly if you start getting warnings about words
339+
/// being skipped due to insufficient space.
340+
341+
/// </summary>
342+
[Parameter()]
343+
public WordBubbleShape WordBubble { get; set; } = WordBubbleShape.None;
344+
333345
/// <summary>
334346
/// Gets or sets the value to scale the distance step by. Larger numbers will result in more radially spaced
335347
/// out clouds.
@@ -428,6 +440,18 @@ private SKColor GetNextColor()
428440
return color;
429441
}
430442

443+
private SKColor GetContrastingColor(SKColor reference)
444+
{
445+
SKColor result;
446+
do
447+
{
448+
result = GetNextColor();
449+
}
450+
while (!result.IsDistinctColor(reference));
451+
452+
return result;
453+
}
454+
431455
private float NextDrawAngle()
432456
{
433457
return AllowRotation switch
@@ -469,7 +493,7 @@ private float NextDrawAngle()
469493
};
470494
}
471495

472-
private float _paddingMultiplier => Padding * PADDING_BASE_SCALE;
496+
private float _paddingMultiplier { get => Padding * PADDING_BASE_SCALE; }
473497

474498
#endregion privateVariables
475499

@@ -526,6 +550,13 @@ protected override void ProcessRecord()
526550
/// </summary>
527551
protected override void EndProcessing()
528552
{
553+
if ((WordSizes == null || WordSizes.Count == 0)
554+
&& (_wordProcessingTasks == null || _wordProcessingTasks.Count == 0))
555+
{
556+
// No input was supplied; exit stage left.
557+
return;
558+
}
559+
529560
int wordCount = 0;
530561
float inflationValue;
531562
float maxWordWidth;
@@ -536,7 +567,7 @@ protected override void EndProcessing()
536567
SKPath wordPath = null;
537568
SKRegion clipRegion = null;
538569
SKRect wordBounds = SKRect.Empty;
539-
SKRect drawableBounds = SKRect.Empty;
570+
SKRect viewbox = SKRect.Empty;
540571
SKBitmap backgroundImage = null;
541572
SKPoint centrePoint;
542573
List<string> sortedWordList;
@@ -598,24 +629,27 @@ protected override void EndProcessing()
598629
wordScaleDictionary[FocusWord] = highestWordFreq *= FOCUS_WORD_SCALE;
599630
}
600631

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

603635
try
604636
{
605637
if (MyInvocation.BoundParameters.ContainsKey(nameof(BackgroundImage)))
606638
{
639+
// Set image size from the background size
607640
WriteDebug($"Importing background image from '{_backgroundFullPath}'.");
608641
backgroundImage = SKBitmap.Decode(_backgroundFullPath);
609-
drawableBounds = new SKRectI(0, 0, backgroundImage.Width, backgroundImage.Height);
642+
viewbox = new SKRectI(0, 0, backgroundImage.Width, backgroundImage.Height);
610643
}
611644
else
612645
{
613-
drawableBounds = new SKRectI(0, 0, ImageSize.Width, ImageSize.Height);
646+
// Set image size from default or specified size
647+
viewbox = new SKRectI(0, 0, ImageSize.Width, ImageSize.Height);
614648
}
615649

616650
wordPath = new SKPath();
617651
clipRegion = new SKRegion();
618-
clipRegion.SetRect(SKRectI.Round(drawableBounds));
652+
clipRegion.SetRect(SKRectI.Round(viewbox));
619653

620654
_fontScale = FontScale(
621655
clipRegion.Bounds,
@@ -629,8 +663,8 @@ protected override void EndProcessing()
629663
StringComparer.OrdinalIgnoreCase);
630664

631665
maxWordWidth = AllowRotation == WordOrientations.None
632-
? drawableBounds.Width * MAX_WORD_WIDTH_PERCENT
633-
: Math.Max(drawableBounds.Width, drawableBounds.Height) * MAX_WORD_WIDTH_PERCENT;
666+
? viewbox.Width * MAX_WORD_WIDTH_PERCENT
667+
: Math.Max(viewbox.Width, viewbox.Height) * MAX_WORD_WIDTH_PERCENT;
634668

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

658692
if (adjustedTextWidth > maxWordWidth
659-
|| textRect.Width * textRect.Height < drawableBounds.Width * drawableBounds.Height * MAX_WORD_AREA_PERCENT)
693+
|| textRect.Width * textRect.Height < viewbox.Width * viewbox.Height * MAX_WORD_AREA_PERCENT)
660694
{
661695
retry = true;
662696
_fontScale *= 1.05f;
@@ -685,7 +719,7 @@ protected override void EndProcessing()
685719

686720
if (!AllowOverflow.IsPresent
687721
&& (adjustedTextWidth > maxWordWidth
688-
|| textRect.Width * textRect.Height > drawableBounds.Width * drawableBounds.Height * MAX_WORD_AREA_PERCENT))
722+
|| textRect.Width * textRect.Height > viewbox.Width * viewbox.Height * MAX_WORD_AREA_PERCENT))
689723
{
690724
retry = true;
691725
_fontScale *= 0.95f;
@@ -698,28 +732,33 @@ protected override void EndProcessing()
698732
}
699733
while (retry);
700734

701-
aspectRatio = drawableBounds.Width / (float)drawableBounds.Height;
702-
centrePoint = new SKPoint(drawableBounds.MidX, drawableBounds.MidY);
735+
aspectRatio = viewbox.Width / viewbox.Height;
736+
centrePoint = new SKPoint(viewbox.MidX, viewbox.MidY);
703737

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

707-
maxRadius = 9 * Math.Max(drawableBounds.Width, drawableBounds.Height) / 16f;
741+
maxRadius = 9 * Math.Max(viewbox.Width, viewbox.Height) / 16f;
708742

709743
using SKDynamicMemoryWStream outputStream = new SKDynamicMemoryWStream();
710744
using SKXmlStreamWriter xmlWriter = new SKXmlStreamWriter(outputStream);
711-
using SKCanvas canvas = SKSvgCanvas.Create(drawableBounds, xmlWriter);
745+
using SKCanvas canvas = SKSvgCanvas.Create(viewbox, xmlWriter);
712746
using SKRegion occupiedSpace = new SKRegion();
713747

714748
brush.IsAutohinted = true;
715749
brush.IsAntialias = true;
716750
brush.Typeface = Typeface;
717751

752+
SKRect drawableBounds;
718753
if (MyInvocation.BoundParameters.ContainsKey(nameof(AllowOverflow)))
719754
{
720-
drawableBounds.Inflate(
721-
drawableBounds.Width * BLEED_AREA_SCALE,
722-
drawableBounds.Height * BLEED_AREA_SCALE);
755+
drawableBounds = SKRect.Create(
756+
viewbox.Location,
757+
new SKSize(viewbox.Width * BLEED_AREA_SCALE, viewbox.Height * BLEED_AREA_SCALE));
758+
}
759+
else
760+
{
761+
drawableBounds = viewbox;
723762
}
724763

725764
if (ParameterSetName.StartsWith(FILE_SET))
@@ -799,7 +838,7 @@ protected override void EndProcessing()
799838
foreach (var point in radialPoints)
800839
{
801840
pointsChecked++;
802-
if (!drawableBounds.Contains(point) && point != centrePoint)
841+
if (!viewbox.Contains(point) && point != centrePoint)
803842
{
804843
continue;
805844
}
@@ -876,10 +915,56 @@ protected override void EndProcessing()
876915
}
877916

878917
brush.IsStroke = false;
879-
brush.Color = wordColor;
880918
brush.Style = SKPaintStyle.Fill;
881919

882-
occupiedSpace.Op(wordPath, SKRegionOperation.Union);
920+
if (WordBubble != WordBubbleShape.None)
921+
{
922+
SKRect bubbleRect = wordPath.ComputeTightBounds();
923+
bubbleRect.Inflate(
924+
bubbleRect.Width * BUBBLE_INFLATION_SCALE,
925+
bubbleRect.Height * BUBBLE_INFLATION_SCALE);
926+
927+
using SKPath bubblePath = new SKPath();
928+
SKRoundRect wordBubble;
929+
float radius;
930+
931+
switch (WordBubble)
932+
{
933+
case WordBubbleShape.Rectangle:
934+
radius = bubbleRect.Height / 8;
935+
wordBubble = new SKRoundRect(bubbleRect, radius, radius);
936+
bubblePath.AddRoundRect(wordBubble);
937+
break;
938+
939+
case WordBubbleShape.Square:
940+
radius = Math.Max(bubbleRect.Width, bubbleRect.Height) / 8;
941+
wordBubble = new SKRoundRect(bubbleRect.GetEnclosingSquare(), radius, radius);
942+
bubblePath.AddRoundRect(wordBubble);
943+
break;
944+
945+
case WordBubbleShape.Circle:
946+
radius = Math.Max(bubbleRect.Width, bubbleRect.Height) / 2;
947+
bubblePath.AddCircle(bubbleRect.MidX, bubbleRect.MidY, radius);
948+
break;
949+
950+
case WordBubbleShape.Oval:
951+
bubblePath.AddOval(bubbleRect);
952+
break;
953+
}
954+
955+
// If we're using word bubbles, the bubbles should more or less enclose the words.
956+
occupiedSpace.Op(bubblePath, SKRegionOperation.Union);
957+
958+
brush.Color = GetContrastingColor(wordColor);
959+
canvas.DrawPath(bubblePath, brush);
960+
}
961+
else
962+
{
963+
// If we're not using bubbles, record the exact space the word occupies.
964+
occupiedSpace.Op(wordPath, SKRegionOperation.Union);
965+
}
966+
967+
brush.Color = wordColor;
883968
canvas.DrawPath(wordPath, brush);
884969
}
885970
else
@@ -893,7 +978,7 @@ protected override void EndProcessing()
893978
canvas.Dispose();
894979
outputStream.Flush();
895980

896-
SaveSvgData(outputStream);
981+
SaveSvgData(outputStream, viewbox);
897982

898983
if (PassThru.IsPresent)
899984
{
@@ -928,22 +1013,46 @@ protected override void EndProcessing()
9281013

9291014
#region HelperMethods
9301015

931-
private void SaveSvgData(SKDynamicMemoryWStream outputStream)
1016+
private void SaveSvgData(SKDynamicMemoryWStream outputStream, SKRect viewbox)
9321017
{
9331018
string[] path = new[] { Path };
9341019

9351020
if (InvokeProvider.Item.Exists(Path, force: true, literalPath: true))
9361021
{
9371022
WriteDebug($"Clearing existing content from '{Path}'.");
938-
InvokeProvider.Content.Clear(path, force: false, literalPath: true);
1023+
try
1024+
{
1025+
InvokeProvider.Content.Clear(path, force: false, literalPath: true);
1026+
}
1027+
catch (Exception e)
1028+
{
1029+
// Unconditionally suppress errors from the Content.Clear() operation. Errors here may indicate that
1030+
// a provider is being written to that does not support the Content.Clear() interface, or that there
1031+
// is no existing item to clear.
1032+
// In either case, an error here does not necessarily mean we cannot write the data, so we can
1033+
// ignore this error. If there is an access denied error, it will be more clear to the user if we
1034+
// surface that from the Content.Write() interface in any case.
1035+
WriteDebug($"Error encountered while clearing content for item '{path}'. {e.Message}");
1036+
}
9391037
}
9401038

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

1043+
var imageXml = new XmlDocument();
1044+
imageXml.LoadXml(reader.ReadToEnd());
1045+
1046+
var svgElement = imageXml.GetElementsByTagName("svg")[0] as XmlElement;
1047+
if (svgElement.GetAttribute("viewbox") == string.Empty)
1048+
{
1049+
svgElement.SetAttribute(
1050+
"viewbox",
1051+
$"{viewbox.Location.X} {viewbox.Location.Y} {viewbox.Width} {viewbox.Height}");
1052+
}
1053+
9451054
WriteDebug($"Saving data to '{Path}'.");
946-
writer.Write(new[] { reader.ReadToEnd() });
1055+
writer.Write(new[] { imageXml.GetPrettyString() });
9471056
writer.Close();
9481057
}
9491058

@@ -1034,15 +1143,12 @@ private static IEnumerable<SKColor> ProcessColorSet(
10341143
bool monochrome)
10351144
{
10361145
Shuffle(set);
1037-
background.ToHsv(out _, out _, out float backgroundBrightness);
10381146

10391147
foreach (var color in set.Where(x => x != stroke && x != background).Take(maxCount))
10401148
{
10411149
if (!monochrome)
10421150
{
1043-
color.ToHsv(out _, out float saturation, out float brightness);
1044-
if (saturation >= MIN_SATURATION_VALUE
1045-
&& Math.Abs(brightness - backgroundBrightness) > MIN_BRIGHTNESS_DISTANCE)
1151+
if (color.IsDistinctColor(background))
10461152
{
10471153
yield return color;
10481154
}

0 commit comments

Comments
 (0)