diff --git a/examples/emoji.py b/examples/emoji_example.py
similarity index 100%
rename from examples/emoji.py
rename to examples/emoji_example.py
diff --git a/examples/mix_fonts.py b/examples/mix_fonts.py
new file mode 100644
index 000000000..fc4413869
--- /dev/null
+++ b/examples/mix_fonts.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python
+"""
+Mix Fonts
+=========
+
+English, CJK, and emoji are mixed both across different words and
+within each word. Fonts are aligned at their baselines.
+
+"""
+import os
+import random
+
+import emoji
+import matplotlib.pyplot as plt
+import regex as re
+from fontTools.ttLib import TTFont
+
+from wordcloud import MixedFontPattern
+from wordcloud import WordCloud
+
+
+def get_supported_chars(font_path):
+ supported_chars = []
+ with TTFont(font_path, 0, allowVID=0,
+ ignoreDecompileErrors=True,
+ fontNumber=-1) as ttf:
+ for table in ttf["cmap"].tables:
+ for glyph in list(table.cmap.items()):
+ try:
+ char = chr(glyph[0])
+ supported_chars.append(char)
+ except Exception:
+ continue
+ return supported_chars
+
+
+font_emoji = "fonts/Symbola/Symbola.ttf"
+font_cjk = "fonts/SourceHanSerif/SourceHanSerifK-Light.otf"
+font_paths = [
+ MixedFontPattern(re.compile(pattern), path) for pattern, path in [
+ (r"[\p{Emoji=Yes}\p{Emoji_Presentation=Yes}]+", font_emoji),
+ (r"[\p{Han}\p{Katakana}\p{Hiragana}]+", font_cjk),
+ ]
+]
+
+# construct word frequency dict.
+wordfreq = {}
+supported = get_supported_chars(font_emoji)
+emojis = list(v for v in emoji.EMOJI_UNICODE.values() if v in supported)
+wordfreq.update({random.choice(emojis): random.randint(10, 40)
+ for _ in range(200)})
+wordfreq.update({
+ emoji.emojize(word, use_aliases=True): n for word, n in {
+ u"愛Myミィ": 75,
+ u"I:beating_heart:紐育": 75,
+ u"We:growing_heart:おんがく": 75,
+ u"男": 90,
+ u"女": 90,
+ u":man_dancing:": 90,
+ u":woman_dancing:": 90,
+ u":couple_with_heart:": 90,
+ u"愛": 90,
+ u"love": 90,
+ u"舞": 90,
+ u"dance": 90,
+ u":musical_note:": 90,
+ u":musical_notes:": 90,
+ u":musical_score:": 45,
+ }.items()
+})
+
+wc = WordCloud(width=600,
+ height=600,
+ scale=1.5,
+ relative_scaling=0.6,
+ repeat=False,
+ max_words=len(wordfreq),
+ font_path=font_paths)
+wc.generate_from_frequencies(wordfreq)
+
+# generate an svg outout.
+with open(os.path.splitext(os.path.basename(__file__))[0] + ".svg", 'w') as f:
+ f.write(wc.to_svg(embed_font=True))
+
+plt.figure(figsize=(8, 8))
+plt.imshow(wc, interpolation="bilinear")
+plt.axis("off")
+plt.show()
diff --git a/examples/mix_fonts.svg b/examples/mix_fonts.svg
new file mode 100644
index 000000000..d173228d4
--- /dev/null
+++ b/examples/mix_fonts.svg
@@ -0,0 +1,208 @@
+
\ No newline at end of file
diff --git a/examples/mix_fonts_random.py b/examples/mix_fonts_random.py
new file mode 100755
index 000000000..e92136600
--- /dev/null
+++ b/examples/mix_fonts_random.py
@@ -0,0 +1,40 @@
+#!/usr/bin/env python
+"""
+Mix Fonts with Random Draw Example
+==================================
+
+Generating a word cloud from the US constitution. The words are
+rendered with a font randomly picked from a list.
+
+"""
+import os
+
+import matplotlib.pyplot as plt
+
+from wordcloud import WordCloud
+
+
+# get data directory (using getcwd() is needed to support running
+# example in generated IPython notebook).
+d = os.path.dirname(__file__) if "__file__" in locals() else os.getcwd()
+
+# read the whole text.
+text = open(os.path.join(d, 'constitution.txt')).read()
+
+# provide paths to fonts for random draw.
+font_paths = [
+ "../wordcloud/DroidSansMono.ttf",
+ "fonts/Symbola/Symbola.ttf",
+ "fonts/SourceHanSerif/SourceHanSerifK-Light.otf"
+]
+
+# generate a word cloud image.
+wc = WordCloud(width=1200, height=600, font_path=font_paths).generate(text)
+
+# generate an svg outout.
+with open(os.path.splitext(os.path.basename(__file__))[0] + ".svg", 'w') as f:
+ f.write(wc.to_svg(embed_font=True))
+
+plt.imshow(wc, interpolation='bilinear')
+plt.axis("off")
+plt.show()
diff --git a/examples/mix_fonts_random.svg b/examples/mix_fonts_random.svg
new file mode 100644
index 000000000..01f020465
--- /dev/null
+++ b/examples/mix_fonts_random.svg
@@ -0,0 +1,206 @@
+
\ No newline at end of file
diff --git a/examples/mix_fonts_random_and_match.py b/examples/mix_fonts_random_and_match.py
new file mode 100755
index 000000000..b5cbc2abf
--- /dev/null
+++ b/examples/mix_fonts_random_and_match.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python
+"""
+Mix Fonts with Random Draw Example and Match
+============================================
+
+Generating a word cloud from the US constitution. The words are
+rendered with a font randomly picked from a list except for CJK
+characters, to which a CJK font is applied.
+
+"""
+import os
+import regex as re
+from collections import defaultdict
+
+import matplotlib.pyplot as plt
+
+from wordcloud import MixedFontPattern
+from wordcloud import STOPWORDS
+from wordcloud import WordCloud
+
+
+# get data directory (using getcwd() is needed to support running
+# example in generated IPython notebook).
+d = os.path.dirname(__file__) if "__file__" in locals() else os.getcwd()
+
+# read the whole text.
+text = open(os.path.join(d, 'constitution.txt')).read()
+
+wordfreq = defaultdict(int)
+for word in text.split():
+ word = word.strip('.,')
+ if word.lower() not in STOPWORDS:
+ wordfreq[word] += 1
+
+# add some cjk language entries for demonstrating font matching
+for phrase, n in {'アメリカ': 40, '憲法': 35, '大統領': 32, '合衆国': 30,
+ 'あめりか': 30, 'けんぽう': 25, 'だいとうりょう': 23,
+ 'がっしゅうこく': 20}.items():
+ wordfreq[phrase] += n
+
+# provide paths to fonts for random draw plus cjk font when word matches
+font_paths = [
+ "../wordcloud/DroidSansMono.ttf",
+ "fonts/Symbola/Symbola.ttf",
+ "fonts/SourceHanSerif/SourceHanSerifK-Light.otf",
+ MixedFontPattern(re.compile(r"[\p{Han}\p{Katakana}\p{Hiragana}]+"),
+ "fonts/SourceHanSerif/SourceHanSerifK-Light.otf")
+]
+
+# generate a word cloud image.
+wc = WordCloud(width=1200, height=600, font_path=font_paths)
+wc.generate_from_frequencies(wordfreq)
+
+# generate an svg outout.
+with open(os.path.splitext(os.path.basename(__file__))[0] + ".svg", 'w') as f:
+ f.write(wc.to_svg(embed_font=True))
+
+plt.imshow(wc, interpolation='bilinear')
+plt.axis("off")
+plt.show()
diff --git a/examples/mix_fonts_random_and_match.svg b/examples/mix_fonts_random_and_match.svg
new file mode 100644
index 000000000..7ebf70b32
--- /dev/null
+++ b/examples/mix_fonts_random_and_match.svg
@@ -0,0 +1,206 @@
+
\ No newline at end of file
diff --git a/test/test_wordcloud.py b/test/test_wordcloud.py
index b5bc868d7..439f9aa1c 100644
--- a/test/test_wordcloud.py
+++ b/test/test_wordcloud.py
@@ -351,7 +351,9 @@ def test_recolor_too_small_set_default():
def test_small_canvas():
# check font size fallback works on small canvas
- WordCloud(max_words=50, width=20, height=20).generate(THIS)
+ wc = WordCloud(max_words=50, width=21, height=21)
+ wc.generate(THIS)
+ assert len(wc.layout_) == 1
def test_tiny_canvas():
@@ -359,6 +361,7 @@ def test_tiny_canvas():
w = WordCloud(max_words=50, width=1, height=1)
with pytest.raises(ValueError, match="Couldn't find space to draw"):
w.generate(THIS)
+ assert len(w.layout_) == 0
def test_coloring_black_works():
@@ -382,7 +385,7 @@ def test_repeat():
# all frequencies are 1
assert len(wc.words_) == 3
assert_array_equal(list(wc.words_.values()), 1)
- frequencies = [w[0][1] for w in wc.layout_]
+ frequencies = [w.frequency for w in wc.layout_]
assert_array_equal(frequencies, 1)
repetition_text = "Some short text with text"
wc = WordCloud(max_words=52, stopwords=[], repeat=True)
@@ -392,7 +395,7 @@ def test_repeat():
assert wc.words_['text'] == 1
assert wc.words_['with'] == .5
assert len(wc.layout_), wc.max_words
- frequencies = [w[0][1] for w in wc.layout_]
+ frequencies = [w.frequency for w in wc.layout_]
# check that frequencies are sorted
assert np.all(np.diff(frequencies) <= 0)
@@ -403,4 +406,4 @@ def test_zero_frequencies():
word_cloud.generate_from_frequencies({'test': 1, 'test1': 0, 'test2': 0})
assert len(word_cloud.layout_) == 1
- assert word_cloud.layout_[0][0][0] == 'test'
+ assert word_cloud.layout_[0].word == 'test'
diff --git a/test/test_wordcloud_cli.py b/test/test_wordcloud_cli.py
index 78a47d2f6..c41d2a03f 100644
--- a/test/test_wordcloud_cli.py
+++ b/test/test_wordcloud_cli.py
@@ -6,7 +6,10 @@
import wordcloud as wc
from wordcloud import wordcloud_cli as cli
-from mock import patch
+try:
+ from unittest.mock import patch
+except ImportError:
+ from mock import patch
import pytest
import matplotlib
diff --git a/wordcloud/__init__.py b/wordcloud/__init__.py
index 5b7b4e9db..3b843f3e4 100644
--- a/wordcloud/__init__.py
+++ b/wordcloud/__init__.py
@@ -1,8 +1,8 @@
-from .wordcloud import (WordCloud, STOPWORDS, random_color_func,
+from .wordcloud import (WordCloud, STOPWORDS, MixedFontPattern, random_color_func,
get_single_color_func)
from .color_from_image import ImageColorGenerator
-__all__ = ['WordCloud', 'STOPWORDS', 'random_color_func',
+__all__ = ['WordCloud', 'STOPWORDS', 'MixedFontPattern', 'random_color_func',
'get_single_color_func', 'ImageColorGenerator',
'__version__']
diff --git a/wordcloud/wordcloud.py b/wordcloud/wordcloud.py
index a026bb6e8..45900f5f6 100644
--- a/wordcloud/wordcloud.py
+++ b/wordcloud/wordcloud.py
@@ -8,7 +8,6 @@
from __future__ import division
import warnings
-from random import Random
import io
import os
import re
@@ -17,7 +16,9 @@
import colorsys
import matplotlib
import numpy as np
+from collections import defaultdict, namedtuple
from operator import itemgetter
+from random import choice, Random
from xml.sax import saxutils
from PIL import Image
@@ -150,15 +151,527 @@ def single_color_func(word=None, font_size=None, position=None,
return single_color_func
+class BoxSize(object):
+ """Bounding box size for a word."""
+ def __init__(self, w, h):
+ self.w = w
+ self.h = h
+
+ def scale(self, scale):
+ return BoxSize(int(self.w * scale), int(self.h * scale))
+
+ def rotate(self):
+ self.w, self.h = self.h, self.w
+
+
+FontOffset = namedtuple('FontOffset', 'start end path x y w h ascent descent')
+"""Stores the info about the font used for the substring in the
+word. Each font used for the word is given a ``FontOffset`` object,
+stored in ``FontInfo.font_offsets``. This object is partly used to
+align multiple fonts along the baseline, taking into account varying
+ascents and descents for different fonts.
+
+Parameters
+----------
+start : int
+ Starting index for the substring in the word.
+
+end : int
+ Ending index + 1 for the substring in the word.
+
+path : str
+ Font path.
+
+x : int
+ x offset for the substring within the enclosing bounding box of the word.
+
+y : int
+ y offset for the substring within the enclosing bounding box of the word.
+
+w : int
+ Width of the bounding box for the substring.
+
+h : int
+ Height of the bounding box for the substring.
+
+ascent: int
+ Ascent for the font.
+
+descent: int
+ Descent for the font.
+
+"""
+
+
+MixedFontPattern = namedtuple('MixedFontPattern', 'pattern path')
+"""Provide the font to be used for the matched substring in the word.
+
+Parameters
+----------
+pattern : re.compile
+ Regex pattern to match substring to which the font is applied.
+
+path : str
+ Font path to a font file, in either OTF or TTF.
+
+"""
+
+
+class FontCollection(object):
+ """Manages the collection of fonts."""
+
+ _image_font_cache = {}
+
+ def __init__(self, font_paths):
+ self.font_paths = []
+ self.mixed_font_paths = []
+
+ if font_paths is None:
+ self.font_paths.append(FONT_PATH)
+ elif isinstance(font_paths, str):
+ self.font_paths = [font_paths]
+ else:
+ for x in font_paths:
+ if isinstance(x, str):
+ self.font_paths.append(x)
+ elif isinstance(x, MixedFontPattern):
+ self.mixed_font_paths.append((x.pattern, FontCollection(x.path)))
+
+ # ensure at least one font is defined
+ if len(self.font_paths) == 0:
+ self.font_paths.append(FONT_PATH)
+
+ @property
+ def default_font_path(self):
+ return self.font_paths[0]
+
+ def image_font(self, path, size):
+ key = (path, size)
+ if key in self._image_font_cache:
+ return self._image_font_cache[key]
+ font = ImageFont.truetype(path, size)
+ props = self._image_font_props(font)
+ self._image_font_cache[key] = font, props
+ return font, props
+
+ def _image_font_props(self, font):
+ raw_font_family, raw_font_style = font.getname()
+ # TODO properly escape/quote this name?
+ font_family = repr(raw_font_family)
+ # TODO better support for uncommon font styles/weights?
+ raw_font_style = raw_font_style.lower()
+
+ if 'bold' in raw_font_style:
+ font_weight = 'bold'
+ else:
+ font_weight = 'normal'
+
+ if 'italic' in raw_font_style:
+ font_style = 'italic'
+ elif 'oblique' in raw_font_style:
+ font_style = 'oblique'
+ else:
+ font_style = 'normal'
+
+ FontProp = namedtuple('FontProp', 'family weight style')
+ return FontProp(font_family, font_weight, font_style)
+
+ @classmethod
+ def clear_image_font_cache(cls):
+ cls._image_font_cache.clear()
+
+ def _match_font_paths(self, word):
+ result = [choice(self.font_paths)] * len(word)
+ if self.mixed_font_paths:
+ for pattern, fontcol in reversed(self.mixed_font_paths):
+ for m in pattern.finditer(word):
+ start = m.start()
+ end = m.end()
+ subresult = fontcol._match_font_paths(word[start:end])
+ for i, font_path in zip(range(start, end), subresult):
+ result[i] = font_path
+ return result
+
+ def _pick(self, word, font_paths, font_size, orientation):
+ def get_bounds(buff, current_font):
+ font, _ = self.image_font(current_font, font_size)
+ w, h = font.getsize(buff)
+ ascent, descent = font.getmetrics()
+ return (w, h), (ascent, descent)
+
+ buff = ''
+ offsets = []
+ for idx, (c, font_path) in enumerate(zip(word, font_paths)):
+ if buff == '':
+ # init buff
+ buff = c
+ current_font_path = font_path
+ elif font_path == current_font_path:
+ buff += c
+ else:
+ offsets.append((idx, current_font_path,
+ get_bounds(buff, current_font_path)))
+
+ # restart buff
+ buff = c
+ current_font_path = font_path
+ else:
+ offsets.append((idx + 1, current_font_path,
+ get_bounds(buff, current_font_path)))
+
+ max_ascent = max(ascent for _, _, (_, (ascent, _)) in offsets)
+
+ box_size = BoxSize(0, 0)
+ font_offsets = []
+ start_idx = 0
+ for idx, font_path, ((w, h), (ascent, descent)) in offsets:
+ x, y = [box_size.w, 0]
+ if ascent < max_ascent:
+ # adjust vertical offset till baseline aligns
+ y = max_ascent - ascent
+ font_offsets.append(
+ FontOffset(start_idx, idx, font_path, x, y, w, h, ascent, descent)
+ )
+ start_idx = idx
+ box_size.w += w
+ box_size.h = max(box_size.h, h + y)
+ return font_offsets, box_size
+
+ def pick(self, word, font_size, orientation):
+ """Pick font(s) for the given word.
+
+ Returns
+ -------
+ FontInfo
+
+ """
+ font_paths = self._match_font_paths(word)
+ font_offsets, box_size = self._pick(word, font_paths, font_size, orientation)
+ if orientation is not None:
+ box_size.rotate()
+ return FontInfo(font_offsets, font_size, box_size, orientation, self)
+
+
+class FontInfo(object):
+ """Helper object to collect info on the font(s) used for the word."""
+
+ def __init__(self, font_offsets, size, box_size, orientation, font_collection):
+ self.font_offsets = font_offsets
+ self.size = size
+ self.box_size = box_size
+ self.orientation = orientation
+ self.font_collection = font_collection
+
+
+class LayoutItem(object):
+ """Helper object to collect layout information."""
+
+ def __init__(self, word, frequency, font_info, color, x, y):
+ self.word = word
+ self.frequency = frequency
+ self.font_info = font_info
+ self.color = color
+ self.x = x
+ self.y = y
+
+ def recolor(self, color_func, random_state):
+ self.color = color_func(
+ word=self.word,
+ font_size=self.font_info.size,
+ position=(self.x, self.y),
+ orientation=self.font_info.orientation,
+ random_state=random_state,
+ font_path=self.font_info.font_collection.default_font_path
+ )
+
+
+class Renderer(object):
+ """Output renderer supplies the consistent interface for rendering output."""
+
+ def __init__(self, word_cloud):
+ self.wc = word_cloud
+
+ def render(self):
+ raise NotImplementedError
+
+ def render_layout(self):
+ raise NotImplementedError
+
+
+class ImageRenderer(Renderer):
+ """Image output renderer."""
+
+ def __init__(self, word_cloud, draw):
+ super(ImageRenderer, self).__init__(word_cloud)
+ self.draw = draw
+
+ def _get_mask(self, layout_item, scale):
+ word = layout_item.word
+ font_info = layout_item.font_info
+ box_size = font_info.box_size.scale(scale)
+ if font_info.orientation is not None:
+ box_size.rotate()
+ font_size = int(font_info.size * scale)
+
+ mask_draw = ImageDraw.Draw(Image.new('L', (box_size.w, box_size.h), 0))
+
+ for font_offset in font_info.font_offsets:
+ font, _ = font_info.font_collection.image_font(font_offset.path, font_size)
+ mask_draw.text((int(font_offset.x * scale), int(font_offset.y * scale)),
+ word[font_offset.start:font_offset.end],
+ fill="white",
+ font=font)
+
+ return mask_draw.im
+
+ def render(self):
+ for layout_item in self.wc.layout_:
+ self.render_layout(layout_item, self.wc.scale)
+
+ def render_layout(self, layout_item, scale):
+ font_info = layout_item.font_info
+ mask = self._get_mask(layout_item, scale)
+
+ x = int(layout_item.x * scale)
+ y = int(layout_item.y * scale)
+
+ # see ImageDraw.text
+ ink, fill = self.draw._getink(layout_item.color)
+ if ink is None:
+ ink = fill
+ if ink is not None:
+ if font_info.orientation is not None:
+ mask = mask.transpose(font_info.orientation)
+ self.draw.draw.draw_bitmap((y, x), mask, ink)
+
+
+class SVGRenderer(Renderer):
+ """SVG output renderer."""
+
+ def __init__(self,
+ word_cloud,
+ embed_font=False,
+ optimize_embedded_font=True,
+ embed_image=False):
+ super(SVGRenderer, self).__init__(word_cloud)
+ self.embed_font = embed_font
+ self.optimize_embedded_font = optimize_embedded_font
+ self.embed_image = embed_image
+
+ def render(self):
+ # TODO should add option to specify URL for font (i.e. WOFF file)
+
+ wc = self.wc
+ scale = wc.scale
+
+ # Get max font size
+ max_font_size = int(
+ (max(w.font_info.size for w in wc.layout_) if wc.max_font_size is None
+ else self.wc.max_font_size) * scale
+ )
+
+ # Text buffer
+ result = []
+
+ # Add header
+ result.append(
+ '')
+ return '\n'.join(result)
+
+ def render_layout(self, layout_item, scale, mixed_fonts=False):
+ font_info = layout_item.font_info
+ font_size = int(font_info.size * scale)
+
+ result = []
+ for font_offset in font_info.font_offsets:
+ x = layout_item.y
+ y = layout_item.x
+
+ if font_info.orientation is None:
+ x += font_offset.x
+ y += font_offset.y + font_offset.ascent
+ else:
+ x += font_offset.y + font_offset.ascent
+ y += font_info.box_size.h - font_offset.x
+
+ transform = 'translate({},{})'.format(int(x * scale),
+ int(y * scale))
+ if font_info.orientation is not None:
+ transform += ' rotate(-90)'
+
+ params = {
+ "fill": layout_item.color,
+ "font_size": font_size,
+ "text_length": int(font_offset.w * scale),
+ "transform": transform,
+ "word": saxutils.escape(
+ layout_item.word[font_offset.start:font_offset.end]
+ ),
+ }
+ if mixed_fonts:
+ _, fp = self.wc.font_collection.image_font(font_offset.path, font_size)
+ params.update(font_family=fp.family,
+ font_style=fp.style,
+ font_weight=fp.weight)
+ text_prop = ('font-family="{font_family}" '
+ 'font-size="{font_size}" '
+ 'font-style="{font_style}" '
+ 'font-weight="{font_weight}" '
+ 'style="fill:{fill}" '
+ 'textLength="{text_length}" '
+ 'transform="{transform}"')
+ else:
+ text_prop = ('font-size="{font_size}" '
+ 'style="fill:{fill}" '
+ 'textLength="{text_length}" '
+ 'transform="{transform}"')
+
+ # Create node
+ result.append(('{word}').format(**params))
+
+ return result
+
+
class WordCloud(object):
r"""Word cloud object for generating and drawing.
Parameters
----------
- font_path : string
+ font_path : str or list of str or MixedFontPattern
Font path to the font that will be used (OTF or TTF).
Defaults to DroidSansMono path on a Linux machine. If you are on
another OS or don't have this font, you need to adjust this path.
+ This can also be a list of str, in which case a font is randomly
+ drawn from them for each word. The list can also include a
+ ``MixedFontPattern``(s), in which case matched substrings in the word
+ are rendered with the font specified in that object.
width : int (default=400)
Width of the canvas.
@@ -281,9 +794,8 @@ class WordCloud(object):
.. versionchanged: 2.0
``words_`` is now a dictionary
- ``layout_`` : list of tuples (string, int, (int, int), int, color))
- Encodes the fitted word cloud. Encodes for each word the string, font
- size, position, orientation and color.
+ ``layout_`` : list of LayoutItem objects
+ Encodes the fitted word cloud.
Notes
-----
@@ -304,8 +816,6 @@ def __init__(self, font_path=None, width=400, height=200, margin=2,
colormap=None, normalize_plurals=True, contour_width=0,
contour_color='black', repeat=False,
include_numbers=False, min_word_length=0):
- if font_path is None:
- font_path = FONT_PATH
if color_func is None and colormap is None:
version = matplotlib.__version__
if version[0] < "2" and version[2] < "5":
@@ -314,9 +824,15 @@ def __init__(self, font_path=None, width=400, height=200, margin=2,
colormap = "viridis"
self.colormap = colormap
self.collocations = collocations
- self.font_path = font_path
- self.width = width
- self.height = height
+ self.font_collection = FontCollection(font_path)
+
+ if mask is not None:
+ self.width = mask.shape[1]
+ self.height = mask.shape[0]
+ else:
+ self.width = width
+ self.height = height
+
self.margin = margin
self.prefer_horizontal = prefer_horizontal
self.mask = mask
@@ -405,20 +921,13 @@ def generate_from_frequencies(self, frequencies, max_font_size=None): # noqa: C
else:
random_state = Random()
- if self.mask is not None:
- boolean_mask = self._get_bolean_mask(self.mask)
- width = self.mask.shape[1]
- height = self.mask.shape[0]
- else:
- boolean_mask = None
- height, width = self.height, self.width
- occupancy = IntegralOccupancyMap(height, width, boolean_mask)
+ boolean_mask = None if self.mask is None else self._get_bolean_mask(self.mask)
+ occupancy = IntegralOccupancyMap(self.height, self.width, boolean_mask)
# create image
- img_grey = Image.new("L", (width, height))
- draw = ImageDraw.Draw(img_grey)
+ img_grey = Image.new("L", (self.width, self.height))
img_array = np.asarray(img_grey)
- font_sizes, positions, orientations, colors = [], [], [], []
+ renderer = ImageRenderer(self, ImageDraw.Draw(img_grey))
last_freq = 1.
@@ -436,7 +945,7 @@ def generate_from_frequencies(self, frequencies, max_font_size=None): # noqa: C
self.generate_from_frequencies(dict(frequencies[:2]),
max_font_size=self.height)
# find font sizes
- sizes = [x[1] for x in self.layout_]
+ sizes = [x.font_info.size for x in self.layout_]
try:
font_size = int(2 * sizes[0] * sizes[1]
/ (sizes[0] + sizes[1]))
@@ -467,6 +976,8 @@ def generate_from_frequencies(self, frequencies, max_font_size=None): # noqa: C
frequencies.extend([(word, freq * downweight ** (i + 1))
for word, freq in frequencies_org])
+ layout = []
+
# start drawing grey image
for word, freq in frequencies:
if freq == 0:
@@ -481,18 +992,15 @@ def generate_from_frequencies(self, frequencies, max_font_size=None): # noqa: C
else:
orientation = Image.ROTATE_90
tried_other_orientation = False
+
while True:
- # try to find a position
- font = ImageFont.truetype(self.font_path, font_size)
- # transpose font optionally
- transposed_font = ImageFont.TransposedFont(
- font, orientation=orientation)
- # get size of resulting text
- box_size = draw.textsize(word, font=transposed_font)
+ font_info = self.font_collection.pick(word, font_size, orientation)
+
# find possible places using integral image:
- result = occupancy.sample_position(box_size[1] + self.margin,
- box_size[0] + self.margin,
+ result = occupancy.sample_position(font_info.box_size.h + self.margin,
+ font_info.box_size.w + self.margin,
random_state)
+
if result is not None or font_size < self.min_font_size:
# either we found a place or font-size went too small
break
@@ -511,16 +1019,11 @@ def generate_from_frequencies(self, frequencies, max_font_size=None): # noqa: C
break
x, y = np.array(result) + self.margin // 2
- # actually draw the text
- draw.text((y, x), word, fill="white", font=transposed_font)
- positions.append((x, y))
- orientations.append(orientation)
- font_sizes.append(font_size)
- colors.append(self.color_func(word, font_size=font_size,
- position=(x, y),
- orientation=orientation,
- random_state=random_state,
- font_path=self.font_path))
+ layout_item = LayoutItem(word, freq, font_info, "white", x, y)
+ renderer.render_layout(layout_item, scale=1)
+ layout_item.recolor(self.color_func, random_state)
+ layout.append(layout_item)
+
# recompute integral image
if self.mask is None:
img_array = np.asarray(img_grey)
@@ -531,8 +1034,7 @@ def generate_from_frequencies(self, frequencies, max_font_size=None): # noqa: C
occupancy.update(img_array, x, y)
last_freq = freq
- self.layout_ = list(zip(frequencies, font_sizes, positions,
- orientations, colors))
+ self.layout_ = layout
return self
def process_text(self, text):
@@ -629,25 +1131,11 @@ def _check_generated(self):
def to_image(self):
self._check_generated()
- if self.mask is not None:
- width = self.mask.shape[1]
- height = self.mask.shape[0]
- else:
- height, width = self.height, self.width
-
- img = Image.new(self.mode, (int(width * self.scale),
- int(height * self.scale)),
+ img = Image.new(self.mode, (int(self.width * self.scale),
+ int(self.height * self.scale)),
self.background_color)
- draw = ImageDraw.Draw(img)
- for (word, count), font_size, position, orientation, color in self.layout_:
- font = ImageFont.truetype(self.font_path,
- int(font_size * self.scale))
- transposed_font = ImageFont.TransposedFont(
- font, orientation=orientation)
- pos = (int(position[1] * self.scale),
- int(position[0] * self.scale))
- draw.text(pos, word, fill=color, font=transposed_font)
-
+ renderer = ImageRenderer(self, ImageDraw.Draw(img))
+ renderer.render()
return self._draw_contour(img=img)
def recolor(self, random_state=None, color_func=None, colormap=None):
@@ -683,13 +1171,9 @@ def recolor(self, random_state=None, color_func=None, colormap=None):
color_func = self.color_func
else:
color_func = colormap_color_func(colormap)
- self.layout_ = [(word_freq, font_size, position, orientation,
- color_func(word=word_freq[0], font_size=font_size,
- position=position, orientation=orientation,
- random_state=random_state,
- font_path=self.font_path))
- for word_freq, font_size, position, orientation, _
- in self.layout_]
+
+ for item in self.layout_:
+ item.recolor(color_func, random_state)
return self
def to_file(self, filename):
@@ -776,209 +1260,12 @@ def to_svg(self, embed_font=False, optimize_embedded_font=True, embed_image=Fals
content : string
Word cloud image as SVG string
"""
-
- # TODO should add option to specify URL for font (i.e. WOFF file)
-
- # Make sure layout is generated
self._check_generated()
-
- # Get output size, in pixels
- if self.mask is not None:
- width = self.mask.shape[1]
- height = self.mask.shape[0]
- else:
- height, width = self.height, self.width
-
- # Get max font size
- if self.max_font_size is None:
- max_font_size = max(w[1] for w in self.layout_)
- else:
- max_font_size = self.max_font_size
-
- # Text buffer
- result = []
-
- # Get font information
- font = ImageFont.truetype(self.font_path, int(max_font_size * self.scale))
- raw_font_family, raw_font_style = font.getname()
- # TODO properly escape/quote this name?
- font_family = repr(raw_font_family)
- # TODO better support for uncommon font styles/weights?
- raw_font_style = raw_font_style.lower()
- if 'bold' in raw_font_style:
- font_weight = 'bold'
- else:
- font_weight = 'normal'
- if 'italic' in raw_font_style:
- font_style = 'italic'
- elif 'oblique' in raw_font_style:
- font_style = 'oblique'
- else:
- font_style = 'normal'
-
- # Add header
- result.append(
- '')
- return '\n'.join(result)
+ renderer = SVGRenderer(self,
+ embed_font=embed_font,
+ optimize_embedded_font=optimize_embedded_font,
+ embed_image=embed_image)
+ return renderer.render()
def _get_bolean_mask(self, mask):
"""Cast to two dimensional boolean mask."""