8
8
using System . Runtime . CompilerServices ;
9
9
using System . Text . RegularExpressions ;
10
10
using System . Threading . Tasks ;
11
+ using System . Xml ;
11
12
using SkiaSharp ;
12
13
13
14
[ assembly: InternalsVisibleTo ( "PSWordCloud.Tests" ) ]
@@ -30,11 +31,10 @@ public class NewWordCloudCommand : PSCmdlet
30
31
31
32
private const float FOCUS_WORD_SCALE = 1.3f ;
32
33
private const float BLEED_AREA_SCALE = 1.2f ;
33
- private const float MIN_SATURATION_VALUE = 5f ;
34
- private const float MIN_BRIGHTNESS_DISTANCE = 25f ;
35
34
private const float MAX_WORD_WIDTH_PERCENT = 1.0f ;
36
35
private const float PADDING_BASE_SCALE = 0.06f ;
37
36
private const float MAX_WORD_AREA_PERCENT = 0.0575f ;
37
+ private const float BUBBLE_INFLATION_SCALE = 0.25f ;
38
38
39
39
private const char ELLIPSIS = '…' ;
40
40
@@ -95,6 +95,7 @@ public class NewWordCloudCommand : PSCmdlet
95
95
public PSObject InputObject { get ; set ; }
96
96
97
97
/// <summary>
98
+ /// Gets or sets the input word dictionary.
98
99
/// Instead of supplying a chunk of text as the input, this parameter allows you to define your own relative
99
100
/// word sizes.
100
101
/// 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
330
331
[ Alias ( "Spacing" ) ]
331
332
public float Padding { get ; set ; } = 5 ;
332
333
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
+
333
345
/// <summary>
334
346
/// Gets or sets the value to scale the distance step by. Larger numbers will result in more radially spaced
335
347
/// out clouds.
@@ -428,6 +440,18 @@ private SKColor GetNextColor()
428
440
return color ;
429
441
}
430
442
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
+
431
455
private float NextDrawAngle ( )
432
456
{
433
457
return AllowRotation switch
@@ -469,7 +493,7 @@ private float NextDrawAngle()
469
493
} ;
470
494
}
471
495
472
- private float _paddingMultiplier => Padding * PADDING_BASE_SCALE ;
496
+ private float _paddingMultiplier { get => Padding * PADDING_BASE_SCALE ; }
473
497
474
498
#endregion privateVariables
475
499
@@ -526,6 +550,13 @@ protected override void ProcessRecord()
526
550
/// </summary>
527
551
protected override void EndProcessing ( )
528
552
{
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
+
529
560
int wordCount = 0 ;
530
561
float inflationValue ;
531
562
float maxWordWidth ;
@@ -536,7 +567,7 @@ protected override void EndProcessing()
536
567
SKPath wordPath = null ;
537
568
SKRegion clipRegion = null ;
538
569
SKRect wordBounds = SKRect . Empty ;
539
- SKRect drawableBounds = SKRect . Empty ;
570
+ SKRect viewbox = SKRect . Empty ;
540
571
SKBitmap backgroundImage = null ;
541
572
SKPoint centrePoint ;
542
573
List < string > sortedWordList ;
@@ -598,24 +629,27 @@ protected override void EndProcessing()
598
629
wordScaleDictionary [ FocusWord ] = highestWordFreq *= FOCUS_WORD_SCALE ;
599
630
}
600
631
632
+ // Get a sorted list of words by their sizes
601
633
sortedWordList = new List < string > ( SortWordList ( wordScaleDictionary , MaxRenderedWords ) ) ;
602
634
603
635
try
604
636
{
605
637
if ( MyInvocation . BoundParameters . ContainsKey ( nameof ( BackgroundImage ) ) )
606
638
{
639
+ // Set image size from the background size
607
640
WriteDebug ( $ "Importing background image from '{ _backgroundFullPath } '.") ;
608
641
backgroundImage = SKBitmap . Decode ( _backgroundFullPath ) ;
609
- drawableBounds = new SKRectI ( 0 , 0 , backgroundImage . Width , backgroundImage . Height ) ;
642
+ viewbox = new SKRectI ( 0 , 0 , backgroundImage . Width , backgroundImage . Height ) ;
610
643
}
611
644
else
612
645
{
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 ) ;
614
648
}
615
649
616
650
wordPath = new SKPath ( ) ;
617
651
clipRegion = new SKRegion ( ) ;
618
- clipRegion . SetRect ( SKRectI . Round ( drawableBounds ) ) ;
652
+ clipRegion . SetRect ( SKRectI . Round ( viewbox ) ) ;
619
653
620
654
_fontScale = FontScale (
621
655
clipRegion . Bounds ,
@@ -629,8 +663,8 @@ protected override void EndProcessing()
629
663
StringComparer . OrdinalIgnoreCase ) ;
630
664
631
665
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 ;
634
668
635
669
using SKPaint brush = new SKPaint
636
670
{
@@ -656,7 +690,7 @@ protected override void EndProcessing()
656
690
var adjustedTextWidth = textRect . Width * ( 1 + _paddingMultiplier ) + StrokeWidth * 2 * STROKE_BASE_SCALE ;
657
691
658
692
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 )
660
694
{
661
695
retry = true ;
662
696
_fontScale *= 1.05f ;
@@ -685,7 +719,7 @@ protected override void EndProcessing()
685
719
686
720
if ( ! AllowOverflow . IsPresent
687
721
&& ( 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 ) )
689
723
{
690
724
retry = true ;
691
725
_fontScale *= 0.95f ;
@@ -698,28 +732,33 @@ protected override void EndProcessing()
698
732
}
699
733
while ( retry ) ;
700
734
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 ) ;
703
737
704
738
// Remove all words that were cut from the final rendering list
705
739
sortedWordList . RemoveAll ( x => ! scaledWordSizes . ContainsKey ( x ) ) ;
706
740
707
- maxRadius = 9 * Math . Max ( drawableBounds . Width , drawableBounds . Height ) / 16f ;
741
+ maxRadius = 9 * Math . Max ( viewbox . Width , viewbox . Height ) / 16f ;
708
742
709
743
using SKDynamicMemoryWStream outputStream = new SKDynamicMemoryWStream ( ) ;
710
744
using SKXmlStreamWriter xmlWriter = new SKXmlStreamWriter ( outputStream ) ;
711
- using SKCanvas canvas = SKSvgCanvas . Create ( drawableBounds , xmlWriter ) ;
745
+ using SKCanvas canvas = SKSvgCanvas . Create ( viewbox , xmlWriter ) ;
712
746
using SKRegion occupiedSpace = new SKRegion ( ) ;
713
747
714
748
brush . IsAutohinted = true ;
715
749
brush . IsAntialias = true ;
716
750
brush . Typeface = Typeface ;
717
751
752
+ SKRect drawableBounds ;
718
753
if ( MyInvocation . BoundParameters . ContainsKey ( nameof ( AllowOverflow ) ) )
719
754
{
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 ;
723
762
}
724
763
725
764
if ( ParameterSetName . StartsWith ( FILE_SET ) )
@@ -799,7 +838,7 @@ protected override void EndProcessing()
799
838
foreach ( var point in radialPoints )
800
839
{
801
840
pointsChecked ++ ;
802
- if ( ! drawableBounds . Contains ( point ) && point != centrePoint )
841
+ if ( ! viewbox . Contains ( point ) && point != centrePoint )
803
842
{
804
843
continue ;
805
844
}
@@ -876,10 +915,56 @@ protected override void EndProcessing()
876
915
}
877
916
878
917
brush . IsStroke = false ;
879
- brush . Color = wordColor ;
880
918
brush . Style = SKPaintStyle . Fill ;
881
919
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 ;
883
968
canvas . DrawPath ( wordPath , brush ) ;
884
969
}
885
970
else
@@ -893,7 +978,7 @@ protected override void EndProcessing()
893
978
canvas . Dispose ( ) ;
894
979
outputStream . Flush ( ) ;
895
980
896
- SaveSvgData ( outputStream ) ;
981
+ SaveSvgData ( outputStream , viewbox ) ;
897
982
898
983
if ( PassThru . IsPresent )
899
984
{
@@ -928,22 +1013,46 @@ protected override void EndProcessing()
928
1013
929
1014
#region HelperMethods
930
1015
931
- private void SaveSvgData ( SKDynamicMemoryWStream outputStream )
1016
+ private void SaveSvgData ( SKDynamicMemoryWStream outputStream , SKRect viewbox )
932
1017
{
933
1018
string [ ] path = new [ ] { Path } ;
934
1019
935
1020
if ( InvokeProvider . Item . Exists ( Path , force : true , literalPath : true ) )
936
1021
{
937
1022
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
+ }
939
1037
}
940
1038
941
- using SKData data = outputStream . CopyToData ( ) ;
1039
+ using SKData data = outputStream . DetachAsData ( ) ;
942
1040
using var reader = new StreamReader ( data . AsStream ( ) ) ;
943
1041
using var writer = InvokeProvider . Content . GetWriter ( path , force : false , literalPath : true ) . First ( ) ;
944
1042
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
+
945
1054
WriteDebug ( $ "Saving data to '{ Path } '.") ;
946
- writer . Write ( new [ ] { reader . ReadToEnd ( ) } ) ;
1055
+ writer . Write ( new [ ] { imageXml . GetPrettyString ( ) } ) ;
947
1056
writer . Close ( ) ;
948
1057
}
949
1058
@@ -1034,15 +1143,12 @@ private static IEnumerable<SKColor> ProcessColorSet(
1034
1143
bool monochrome )
1035
1144
{
1036
1145
Shuffle ( set ) ;
1037
- background . ToHsv ( out _ , out _ , out float backgroundBrightness ) ;
1038
1146
1039
1147
foreach ( var color in set . Where ( x => x != stroke && x != background ) . Take ( maxCount ) )
1040
1148
{
1041
1149
if ( ! monochrome )
1042
1150
{
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 ) )
1046
1152
{
1047
1153
yield return color ;
1048
1154
}
0 commit comments