ruby_wordcram 1.0.1 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/.mvn/extensions.xml +8 -0
  4. data/.mvn/wrapper/maven-wrapper.properties +1 -0
  5. data/Rakefile +28 -5
  6. data/docs/_posts/2017-03-07-getting_started.md +3 -2
  7. data/docs/_posts/2017-03-07-under_the_hood.md +33 -0
  8. data/lib/WordCram.jar +0 -0
  9. data/lib/jsoup-1.10.2.jar +0 -0
  10. data/lib/ruby_wordcram/version.rb +1 -1
  11. data/lib/ruby_wordcram.rb +1 -2
  12. data/pom.rb +53 -0
  13. data/pom.xml +87 -0
  14. data/ruby_wordcram.gemspec +1 -2
  15. data/src/cue/lang/Counter.java +141 -0
  16. data/src/cue/lang/IterableText.java +10 -0
  17. data/src/cue/lang/NGramIterator.java +151 -0
  18. data/src/cue/lang/SentenceIterator.java +86 -0
  19. data/src/cue/lang/WordIterator.java +60 -0
  20. data/src/cue/lang/stop/StopWords.java +114 -0
  21. data/src/cue/lang/stop/arabic +351 -0
  22. data/src/cue/lang/stop/armenian +45 -0
  23. data/src/cue/lang/stop/catalan +219 -0
  24. data/src/cue/lang/stop/croatian +2024 -0
  25. data/src/cue/lang/stop/czech +256 -0
  26. data/src/cue/lang/stop/danish +94 -0
  27. data/src/cue/lang/stop/dutch +107 -0
  28. data/src/cue/lang/stop/english +183 -0
  29. data/src/cue/lang/stop/esperanto +180 -0
  30. data/src/cue/lang/stop/farsi +966 -0
  31. data/src/cue/lang/stop/finnish +235 -0
  32. data/src/cue/lang/stop/french +543 -0
  33. data/src/cue/lang/stop/german +231 -0
  34. data/src/cue/lang/stop/greek +637 -0
  35. data/src/cue/lang/stop/hebrew +220 -0
  36. data/src/cue/lang/stop/hindi +97 -0
  37. data/src/cue/lang/stop/hungarian +202 -0
  38. data/src/cue/lang/stop/italian +279 -0
  39. data/src/cue/lang/stop/latin +1 -0
  40. data/src/cue/lang/stop/norwegian +176 -0
  41. data/src/cue/lang/stop/polish +138 -0
  42. data/src/cue/lang/stop/portuguese +204 -0
  43. data/src/cue/lang/stop/romanian +284 -0
  44. data/src/cue/lang/stop/russian +652 -0
  45. data/src/cue/lang/stop/slovak +110 -0
  46. data/src/cue/lang/stop/slovenian +448 -0
  47. data/src/cue/lang/stop/spanish +308 -0
  48. data/src/cue/lang/stop/swedish +114 -0
  49. data/src/cue/lang/stop/turkish +117 -0
  50. data/src/cue/lang/unicode/BlockUtil.java +103 -0
  51. data/src/cue/lang/unicode/Normalizer.java +55 -0
  52. data/src/cue/lang/unicode/Normalizer6.java +32 -0
  53. data/src/license.txt +201 -0
  54. data/src/wordcram/Anglers.java +137 -0
  55. data/src/wordcram/BBTree.java +133 -0
  56. data/src/wordcram/BBTreeBuilder.java +61 -0
  57. data/src/wordcram/Colorers.java +52 -0
  58. data/src/wordcram/EngineWord.java +73 -0
  59. data/src/wordcram/Fonters.java +17 -0
  60. data/src/wordcram/HsbWordColorer.java +28 -0
  61. data/src/wordcram/ImageShaper.java +91 -0
  62. data/src/wordcram/Observer.java +9 -0
  63. data/src/wordcram/PlacerHeatMap.java +134 -0
  64. data/src/wordcram/Placers.java +74 -0
  65. data/src/wordcram/PlottingWordNudger.java +38 -0
  66. data/src/wordcram/PlottingWordPlacer.java +36 -0
  67. data/src/wordcram/ProcessingWordRenderer.java +42 -0
  68. data/src/wordcram/RandomWordNudger.java +44 -0
  69. data/src/wordcram/RenderOptions.java +10 -0
  70. data/src/wordcram/ShapeBasedPlacer.java +66 -0
  71. data/src/wordcram/Sizers.java +54 -0
  72. data/src/wordcram/SketchCallbackObserver.java +70 -0
  73. data/src/wordcram/SpiralWordNudger.java +31 -0
  74. data/src/wordcram/SvgWordRenderer.java +110 -0
  75. data/src/wordcram/SwirlWordPlacer.java +25 -0
  76. data/src/wordcram/UpperLeftWordPlacer.java +27 -0
  77. data/src/wordcram/WaveWordPlacer.java +25 -0
  78. data/src/wordcram/Word.java +357 -0
  79. data/src/wordcram/WordAngler.java +20 -0
  80. data/src/wordcram/WordArray.java +18 -0
  81. data/src/wordcram/WordBag.java +31 -0
  82. data/src/wordcram/WordColorer.java +25 -0
  83. data/src/wordcram/WordCounter.java +96 -0
  84. data/src/wordcram/WordCram.java +920 -0
  85. data/src/wordcram/WordCramEngine.java +196 -0
  86. data/src/wordcram/WordFonter.java +24 -0
  87. data/src/wordcram/WordNudger.java +44 -0
  88. data/src/wordcram/WordPlacer.java +44 -0
  89. data/src/wordcram/WordRenderer.java +10 -0
  90. data/src/wordcram/WordShaper.java +78 -0
  91. data/src/wordcram/WordSizer.java +46 -0
  92. data/src/wordcram/WordSkipReason.java +42 -0
  93. data/src/wordcram/WordSorterAndScaler.java +31 -0
  94. data/src/wordcram/WordSource.java +5 -0
  95. data/src/wordcram/text/Html.java +15 -0
  96. data/src/wordcram/text/Html2Text.java +17 -0
  97. data/src/wordcram/text/Text.java +15 -0
  98. data/src/wordcram/text/TextFile.java +23 -0
  99. data/src/wordcram/text/TextSource.java +5 -0
  100. data/src/wordcram/text/WebPage.java +23 -0
  101. metadata +94 -5
  102. data/lib/cue.language.jar +0 -0
  103. data/lib/jsoup-1.7.2.jar +0 -0
  104. data/vendors/Rakefile +0 -51
@@ -0,0 +1,196 @@
1
+ package wordcram;
2
+
3
+ import java.awt.Color; // awt: turns P5-land color into awt color, for renderers
4
+ import java.awt.Shape; // awt: word->shape, skip if too small, pass it on
5
+ import java.awt.geom.Rectangle2D; // awt: to get bounding box width & height
6
+ import java.util.ArrayList;
7
+ import java.util.ListIterator;
8
+
9
+ import processing.core.PFont;
10
+ import processing.core.PVector;
11
+
12
+ class WordCramEngine {
13
+
14
+ private final WordRenderer renderer;
15
+
16
+ private final WordFonter fonter;
17
+ private final WordSizer sizer;
18
+ private final WordColorer colorer;
19
+ private final WordAngler angler;
20
+ private final WordPlacer placer;
21
+ private final WordNudger nudger;
22
+
23
+ private final ArrayList<EngineWord> eWords;
24
+ private final ListIterator<EngineWord> eWordIter;
25
+ private final ArrayList<EngineWord> drawnWords = new ArrayList<>();
26
+ private final ArrayList<Word> skippedWords = new ArrayList<>();
27
+
28
+ private final RenderOptions renderOptions;
29
+ private final Observer observer;
30
+
31
+ // TODO Damn, really need to break down that list of arguments.
32
+ WordCramEngine(WordRenderer renderer, Word[] words, WordFonter fonter, WordSizer sizer, WordColorer colorer, WordAngler angler, WordPlacer placer, WordNudger nudger, WordShaper shaper, BBTreeBuilder bbTreeBuilder, RenderOptions renderOptions, Observer observer) {
33
+ this.renderer = renderer;
34
+
35
+ this.fonter = fonter;
36
+ this.sizer = sizer;
37
+ this.colorer = colorer;
38
+ this.angler = angler;
39
+ this.placer = placer;
40
+ this.nudger = nudger;
41
+ this.observer = observer;
42
+
43
+ this.renderOptions = renderOptions;
44
+ this.eWords = wordsIntoEngineWords(words, shaper, bbTreeBuilder);
45
+ this.eWordIter = eWords.listIterator();
46
+ }
47
+
48
+ private ArrayList<EngineWord> wordsIntoEngineWords(Word[] words, WordShaper wordShaper, BBTreeBuilder bbTreeBuilder) {
49
+ ArrayList<EngineWord> engineWords = new ArrayList<>();
50
+
51
+ int maxNumberOfWords = words.length;
52
+ if (renderOptions.maxNumberOfWordsToDraw >= 0) {
53
+ maxNumberOfWords = Math.min(maxNumberOfWords, renderOptions.maxNumberOfWordsToDraw);
54
+ }
55
+
56
+ for (int i = 0; i < maxNumberOfWords; i++) {
57
+ Word word = words[i];
58
+ EngineWord eWord = new EngineWord(word, i, words.length, bbTreeBuilder);
59
+
60
+ PFont wordFont = word.getFont(fonter);
61
+ float wordSize = word.getSize(sizer, i, words.length);
62
+ float wordAngle = word.getAngle(angler);
63
+
64
+ Shape shape = wordShaper.getShapeFor(eWord.word.word, wordFont, wordSize, wordAngle);
65
+ if (isTooSmall(shape, renderOptions.minShapeSize)) {
66
+ skipWord(word, WordSkipReason.SHAPE_WAS_TOO_SMALL);
67
+ }
68
+ else {
69
+ eWord.setShape(shape, renderOptions.wordPadding);
70
+ engineWords.add(eWord); // DON'T add eWords with no shape.
71
+ }
72
+ }
73
+
74
+ for (int i = maxNumberOfWords; i < words.length; i++) {
75
+ skipWord(words[i], WordSkipReason.WAS_OVER_MAX_NUMBER_OF_WORDS);
76
+ }
77
+
78
+ return engineWords;
79
+ }
80
+
81
+ private boolean isTooSmall(Shape shape, int minShapeSize) {
82
+ if (minShapeSize < 1) {
83
+ minShapeSize = 1;
84
+ }
85
+ Rectangle2D r = shape.getBounds2D();
86
+
87
+ // Most words will be wider than tall, so this basically boils down to height.
88
+ // For the odd word like "I", we check width, too.
89
+ return r.getHeight() < minShapeSize || r.getWidth() < minShapeSize;
90
+ }
91
+
92
+ private void skipWord(Word word, WordSkipReason reason) {
93
+ // TODO delete these properties when starting a sketch, in case it's a re-run w/ the same words.
94
+ // NOTE: keep these as properties, because they (will be) deleted when the WordCramEngine re-runs.
95
+ word.wasSkippedBecause(reason);
96
+ skippedWords.add(word);
97
+ observer.wordSkipped(word);
98
+ }
99
+
100
+ boolean hasMore() {
101
+ return eWordIter.hasNext();
102
+ }
103
+
104
+ void drawAll() {
105
+ observer.beginDraw();
106
+ while(hasMore()) {
107
+ drawNext();
108
+ }
109
+ renderer.finish();
110
+ observer.endDraw();
111
+ }
112
+
113
+ void drawNext() {
114
+ if (!hasMore()) return;
115
+ EngineWord eWord = eWordIter.next();
116
+ boolean wasPlaced = placeWord(eWord);
117
+ if (wasPlaced) { // TODO unit test (somehow)
118
+ drawWordImage(eWord);
119
+ observer.wordDrawn(eWord.word);
120
+ }
121
+ }
122
+
123
+ private boolean placeWord(EngineWord eWord) {
124
+ Word word = eWord.word;
125
+ Rectangle2D rect = eWord.getShape().getBounds2D(); // TODO can we move these into EngineWord.setDesiredLocation? Does that make sense?
126
+ int wordImageWidth = (int)rect.getWidth();
127
+ int wordImageHeight = (int)rect.getHeight();
128
+
129
+ eWord.setDesiredLocation(placer, eWords.size(), wordImageWidth, wordImageHeight, renderer.getWidth(), renderer.getHeight());
130
+
131
+ // Set maximum number of placement trials
132
+ int maxAttemptsToPlace = renderOptions.maxAttemptsToPlaceWord > 0 ?
133
+ renderOptions.maxAttemptsToPlaceWord :
134
+ calculateMaxAttemptsFromWordWeight(word);
135
+
136
+ EngineWord lastCollidedWith = null;
137
+ for (int attempt = 0; attempt < maxAttemptsToPlace; attempt++) {
138
+
139
+ eWord.nudge(nudger.nudgeFor(word, attempt));
140
+
141
+ PVector loc = eWord.getCurrentLocation();
142
+ if (loc.x < 0 || loc.y < 0 || loc.x + wordImageWidth >= renderer.getWidth() || loc.y + wordImageHeight >= renderer.getHeight()) {
143
+ continue;
144
+ }
145
+
146
+ if (lastCollidedWith != null && eWord.overlaps(lastCollidedWith)) {
147
+ continue;
148
+ }
149
+
150
+ boolean foundOverlap = false;
151
+ for (EngineWord otherWord : drawnWords) {
152
+ if (eWord.overlaps(otherWord)) {
153
+ foundOverlap = true;
154
+ lastCollidedWith = otherWord;
155
+ break;
156
+ }
157
+ }
158
+
159
+ if (!foundOverlap) {
160
+ eWord.finalizeLocation();
161
+ return true;
162
+ }
163
+ }
164
+
165
+ skipWord(eWord.word, WordSkipReason.NO_SPACE);
166
+ return false;
167
+ }
168
+
169
+ private int calculateMaxAttemptsFromWordWeight(Word word) {
170
+ return (int)((1.0 - word.weight) * 600) + 100;
171
+ }
172
+
173
+ private void drawWordImage(EngineWord word) {
174
+ drawnWords.add(word);
175
+ renderer.drawWord(word, new Color(word.word.getColor(colorer), true));
176
+ }
177
+
178
+ Word getWordAt(float x, float y) {
179
+ int size = drawnWords.size() - 1;
180
+ for (int i = size; i >= 0; i--) {
181
+ EngineWord eWord = drawnWords.get(i);
182
+ if (eWord.containsPoint(x, y)) {
183
+ return eWord.word;
184
+ }
185
+ }
186
+ return null;
187
+ }
188
+
189
+ Word[] getSkippedWords() {
190
+ return skippedWords.toArray(new Word[0]);
191
+ }
192
+
193
+ float getProgress() {
194
+ return (float) eWordIter.nextIndex() / eWords.size();
195
+ }
196
+ }
@@ -0,0 +1,24 @@
1
+ package wordcram;
2
+
3
+ import processing.core.PFont;
4
+
5
+ /**
6
+ * A WordFonter tells WordCram what font to render a word in.
7
+ * <p>
8
+ * Some useful implementations are available in {@link Fonters}.
9
+ * <p>
10
+ * <i>The name "WordFonter" was picked because it matches the others: WordColorer,
11
+ * WordPlacer, WordSizer, etc. Apologies if it sounds a bit weird to your ear; I
12
+ * eventually got used to it.</i>
13
+ *
14
+ * @author Dan Bernier
15
+ */
16
+ public interface WordFonter {
17
+
18
+ /**
19
+ * What font should this {@link Word} be drawn in?
20
+ * @param word the word to pick the PFont for
21
+ * @return the PFont for the word
22
+ */
23
+ public PFont fontFor(Word word);
24
+ }
@@ -0,0 +1,44 @@
1
+ package wordcram;
2
+
3
+ import processing.core.PVector;
4
+
5
+ /**
6
+ * Once a WordPlacer tells WordCram where a word <i>should</i> go, a WordNudger
7
+ * tells WordCram how to nudge it around the field, until it fits in with the
8
+ * other words around it, or the WordCram gives up on the word.
9
+ * <p>
10
+ * WordCram gets a PVector from the nudger, and adds it to the word's desired
11
+ * location, to find the next spot to try fitting the word. Note that the
12
+ * PVectors returned from a nudger <i><b>don't accumulate</b></i>: if the placer
13
+ * puts a Word at (0, 0), and the nudger returns (1, 1), and then (2, 2),
14
+ * WordCram will try the word at (1, 1), and then (2, 2) -- <i>not</i> (1, 1)
15
+ * and then (3, 3).
16
+ * <p>
17
+ * A WordNudger should probably start nudging the word only a little, to keep it
18
+ * near its desired location, and gradually nudge it more and more, so that,
19
+ * even if the desired area is congested, the word can still fit in somewhere.
20
+ * This is why the WordCram passes in <code>attemptNumber</code>: it's the
21
+ * number of times it's attempted to place the word. This could (for example)
22
+ * scale the PVector, since the nudges don't accumulate (see above).
23
+ *
24
+ * @see RandomWordNudger
25
+ * @see SpiralWordNudger
26
+ *
27
+ * @author Dan Bernier
28
+ */
29
+ public interface WordNudger {
30
+
31
+ /**
32
+ * How should this word be nudged, this time?
33
+ *
34
+ * @param word
35
+ * the word to nudge
36
+ * @param attemptNumber
37
+ * how many times WordCram has tried to place this word; starts
38
+ * at zero, and ends at
39
+ * <code>(int)((1.0-word.weight) * 600) + 100</code>
40
+ * @return the PVector to add to the word's desired location, to get the
41
+ * next spot to try fitting the word
42
+ */
43
+ public PVector nudgeFor(Word word, int attemptNumber);
44
+ }
@@ -0,0 +1,44 @@
1
+ package wordcram;
2
+
3
+ import processing.core.PVector;
4
+
5
+ /**
6
+ * A WordPlacer tells WordCram where to place a word (in x,y coordinates) on the field.
7
+ * <p>
8
+ * A WordPlacer only suggests: the WordCram will try to place the Word where the
9
+ * WordPlacer tells it to, but if the Word overlaps other Words, a WordNudger
10
+ * will suggest different near-by spots for the Word until it fits, or until the
11
+ * WordCram gives up.
12
+ * <p>
13
+ * Some useful implementations are available in {@link Placers}.
14
+ *
15
+ * @author Dan Bernier
16
+ */
17
+ public interface WordPlacer {
18
+
19
+ /**
20
+ * Where should this {@link Word} be drawn on the field?
21
+ *
22
+ * @param word
23
+ * The Word to place. A typical WordPlacer might use the Word's
24
+ * weight.
25
+ * @param wordIndex
26
+ * The index (rank) of the Word to place. Since this isn't a
27
+ * property of the Word, it's passed in as well.
28
+ * @param wordsCount
29
+ * The total number of words. Gives a context to wordIndex:
30
+ * "Word {wordIndex} of {wordsCount}".
31
+ * @param wordImageWidth
32
+ * The width of the word image.
33
+ * @param wordImageHeight
34
+ * The height of the word image.
35
+ * @param fieldWidth
36
+ * The width of the field.
37
+ * @param fieldHeight
38
+ * The height of the field.
39
+ * @return the desired location for a Word on the field, as a 2D PVector.
40
+ */
41
+ public abstract PVector place(Word word, int wordIndex, int wordsCount,
42
+ int wordImageWidth, int wordImageHeight, int fieldWidth,
43
+ int fieldHeight);
44
+ }
@@ -0,0 +1,10 @@
1
+ package wordcram;
2
+
3
+ import java.awt.Color; // awt: JUST for the interface (this IS an interface)
4
+
5
+ interface WordRenderer {
6
+ int getWidth();
7
+ int getHeight();
8
+ void drawWord(EngineWord word, Color color);
9
+ void finish();
10
+ }
@@ -0,0 +1,78 @@
1
+ package wordcram;
2
+
3
+ import java.awt.Font; // awt: font+word=shape
4
+ import java.awt.Shape; // awt: font+word=shape
5
+ import java.awt.font.FontRenderContext; // awt: font+word=shape
6
+ import java.awt.font.GlyphVector; // awt: font+word=shape
7
+ import java.awt.geom.AffineTransform; // awt: move and rotate shape
8
+ import java.awt.geom.Rectangle2D; // awt: move shape
9
+
10
+ import processing.core.PFont;
11
+
12
+ public class WordShaper {
13
+ private final FontRenderContext frc = new FontRenderContext(null, true, true);
14
+
15
+
16
+ private boolean rightToLeft;
17
+ public WordShaper(boolean rightToLeft) {
18
+ this.rightToLeft = rightToLeft;
19
+ }
20
+ public WordShaper() {
21
+ this(false);
22
+ }
23
+
24
+
25
+ public Shape getShapeFor(String word, Font font, float fontSize, float angle) {
26
+ Shape shape = makeShape(word, sizeFont(font, fontSize));
27
+ return moveToOrigin(rotate(shape, angle));
28
+ }
29
+
30
+ public Shape getShapeFor(String word, Font font) {
31
+ return getShapeFor(word, font, font.getSize2D(), 0);
32
+ }
33
+
34
+ public Shape getShapeFor(String word, PFont pFont, float fontSize, float angle) {
35
+ return getShapeFor(word, (Font)pFont.getNative(), fontSize, angle);
36
+ }
37
+
38
+ public Shape getShapeFor(String word, PFont pFont) {
39
+ return getShapeFor(word, (Font)pFont.getNative());
40
+ }
41
+
42
+
43
+
44
+ private Font sizeFont(Font unsizedFont, float fontSize) {
45
+ if (fontSize == unsizedFont.getSize2D()) {
46
+ return unsizedFont;
47
+ }
48
+ return unsizedFont.deriveFont(fontSize);
49
+ }
50
+
51
+ private Shape makeShape(String word, Font font) {
52
+ char[] chars = word.toCharArray();
53
+
54
+ // TODO hmm: this doesn't render newlines. Hrm. If your word text is "foo\nbar", you get "foobar".
55
+ GlyphVector gv = font.layoutGlyphVector(frc, chars, 0, chars.length,
56
+ this.rightToLeft ? Font.LAYOUT_RIGHT_TO_LEFT : Font.LAYOUT_LEFT_TO_RIGHT);
57
+
58
+ return gv.getOutline();
59
+ }
60
+
61
+ private Shape rotate(Shape shape, float rotation) {
62
+ if (rotation == 0) {
63
+ return shape;
64
+ }
65
+
66
+ return AffineTransform.getRotateInstance(rotation).createTransformedShape(shape);
67
+ }
68
+
69
+ private Shape moveToOrigin(Shape shape) {
70
+ Rectangle2D rect = shape.getBounds2D();
71
+
72
+ if (rect.getX() == 0 && rect.getY() == 0) {
73
+ return shape;
74
+ }
75
+
76
+ return AffineTransform.getTranslateInstance(-rect.getX(), -rect.getY()).createTransformedShape(shape);
77
+ }
78
+ }
@@ -0,0 +1,46 @@
1
+ package wordcram;
2
+
3
+ /**
4
+ * A WordSizer tells WordCram how big to render each word.
5
+ * You'll pass a WordSizer to WordCram via {@link WordCram#withSizer(WordSizer)}.
6
+ * <p>
7
+ * Some useful implementations are available in {@link Sizers}.
8
+ *
9
+ * @author Dan Bernier
10
+ */
11
+ public interface WordSizer {
12
+
13
+ /**
14
+ * How big should this {@link Word} be rendered?
15
+ * <p>
16
+ * Generally, a word cloud draws more important words bigger. Two typical
17
+ * ways to measure word's importance are its weight, and its rank (its
18
+ * position in the list of words, sorted by weight).
19
+ * <p>
20
+ * Given that, sizeFor is passed the Word (which knows its own weight), its
21
+ * rank, and the total number of words.
22
+ * <p>
23
+ * For example, given the text "I think I can I think", the words would look
24
+ * like this:
25
+ * <ul>
26
+ * <li>"I", weight 1.0 (3/3), rank 1</li>
27
+ * <li>"think", weight 0.667 (2/3), rank 2</li>
28
+ * <li>"can", weight 0.333 (1/3), rank 3</li>
29
+ * </ul>
30
+ * ...and the WordSizer would be called with the following values:
31
+ * <ul>
32
+ * <li>Word "I" (weight 1.0), 1, 3</li>
33
+ * <li>Word "think" (weight 0.667), 2, 3</li>
34
+ * <li>Word "can" (weight 0.333), 3, 3</li>
35
+ * </ul>
36
+ *
37
+ * @param word
38
+ * the Word to determine the size of
39
+ * @param wordRank
40
+ * the rank of the Word
41
+ * @param wordCount
42
+ * the total number of Words being rendered
43
+ * @return the size to render the Word
44
+ */
45
+ public float sizeFor(Word word, int wordRank, int wordCount);
46
+ }