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 @@ + + + + + + + +🕺 +💃 +💑 + +love + +dance +🎵 +🎶 + +My +ミィ +I +💓 +紐育 +We +💗 +おんがく +🎼 +🕖 +🕋 + +🇪 +🈶 +📐 +🛢 +📮 +🚞 +😮 + +🔷 +📗 +🦎 + +😫 +🗒 +🥙 +👃 +💘 +🗂 +😥 + +🔮 + +🛃 +🕷 +🕳 + +🐑 +🛂 +👧 +🍝 +😊 +😜 +🌿 +🕶 +💮 +😬 + +📟 +🎳 +😴 +🎰 + +🌧 +🎧 +😯 +💸 +© +🎬 + +🐚 +🌮 + +🈚 +🥑 +🚔 + +📍 +🎛 +🎁 +🐡 +🐍 +🛬 +👈 +🔄 +🍇 +🐂 +💋 +🚇 +💨 +🕹 +🐧 +📂 +📸 +🐜 + +🕢 +🔲 +🌹 +🉑 +🍗 +🔒 +🈁 +🏽 +🔹 +🈴 +🍠 +🎆 +🚭 +🐌 +🍋 +👅 +📣 +🏚 +💛 +🗽 +👾 +🏷 + +📨 +🏵 +👫 +📢 + +🍞 +🌗 + +🥖 +👎 +💪 +🤜 +🎴 +🈹 +🍡 + +🛐 +🔛 +🏸 + + +🎙 +🆔 + +🍓 +🙅 + +🚰 +🖼 + + +🙍 + +🏣 +🦈 +🌊 +🐐 +🎮 + + +🆖 + +🐪 + +🗺 +🛴 +🐅 +🚘 +😟 +🤖 +👸 +🔩 +🍢 +🦂 +🌛 +💿 +📹 +🔣 +🔶 +🍰 + +🔔 +💄 +🇫 +💅 +🍪 +🌸 + +🎗 +😻 +🤑 +🔋 +🛀 +📥 +🔑 +🍷 +🔇 +🚿 +🔜 +🚴 + \ 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 @@ + + + + + +State +United States +Law +may +Congress +President +Section +Person +Office +Year +time +House +one +Case +Senate +Power +Constitution +Vote +Legislature +thereof +Senator +Officer +every +Citizen +Member +Representative +make +Bill +Article +Number +made +first +House Representatives +two thirds +several State +provide +within +three +Manner +two +except +Day +Elector +Vice President +executive +Authority +unless +Place +Treason +Union +chosen +second +five +elected +Service +thirty +chuse +eight +Impeachment +necessary +Money +Rule +Regulation +another +without Consent +establish +respective +Term +ten +New +fourth +present +subject +Judge +Majority +either +receive +enter +foreign +Coin +grant +Government +Act +Jurisdiction +Crime +supreme Court +vested +direct +according +bound +thousand +Election +third +Trust +Party +Trial +holding +Proceedings +Journal +Consent +Duties +post +inferior +Court +War +Land +Militia +Part +Appointment +equal +judicial +Convention +Amendment +Line +Ambassadors public +public Ministers +Justice +general +America +herein +consist +seven +Inhabitant +enumeration +Meeting +least +entitled +six +happen +Consequence +Class +Expiration +Resignation +exercise +Purpose +convicted +Concurrence +Judgment +Removal +hold +Profit +Punishment +prescribed +chusing +appoint +different +adjourn +require +entered +neither +Session +Compensation +Treasury +appointed +become +presented +Return +Objections +Adjournment +take +lay +Debts +uniform +throughout +Credit +committed +Seat +Department +hundred +Tax +Duty +Invasion +Attainder +laid +directed +Title +Emolument +will +Ballot +List +said +removed +Treaties +provided +Qualification +interlined +attained Age +whole Number +fill Vacancies +Oath Affirmation +Duties Imposts +think proper +Ministers Consuls +Service Labour +Page Word +People +Order +form +domestic +promote + \ 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 @@ + + + + + +States +United +State +アメリカ +憲法 +may +President +大統領 +合衆国 +あめりか +Congress +けんぽう +House +だいとうりょう +Section +Law +がっしゅうこく +Office +one +Senate +two +Person +Representatives +Constitution +Power +Cases +Number +time +Years +thereof +make +Consent +Laws +every +several +Legislature +made +first +thirds +States; +provide +Members +Year +within +three +Manner +Senators +Vice +Votes +public +Article +Electors +unless +Officers +without +except +Treason +Bill +Case +Duties +supreme +Citizens +chosen +second +five +elected +Persons +Service +chuse +eight +Authority +necessary +Union +establish +respective +Term +ten +thirty +Impeachment +Majority +Rules +either +Houses +receive +enter +Money +foreign +Government +another +Court +1 +Powers +vested +2 +Citizen +direct +according +bound +Vacancies +3 +Senator +third +present +Trust +Trial +Times +Places +holding +Day +Proceedings +Journal +Time +Imposts +inferior +War +grant +Militia +Officer +executive +equal +Ambassadors +judicial +Jurisdiction +Legislatures +Page +Justice +general +America +herein +consist +Representative +attained +Age +seven +Inhabitant +whole +Meeting +New +entitled +six +happen +Executive +fill +Vote +Consequence +Class +Expiration +fourth +Resignation +exercise +Oath +convicted +Concurrence +Judgment +hold +Profit +States: +Party +subject +Punishment +4 +prescribed +Regulations +chusing +appoint +different +adjourn +Member +require +entered +Session +Place +Compensation +Services +Treasury +Bills +Amendments +become +presented +Objections +take +lay +uniform +throughout +Coin +Offices +post +Regulation +proper +think +thousand +hundred +Tax +Attainder +laid +directed +Title +will +Ballot +List + \ 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( + '' + .format(int(wc.width * scale), int(wc.height * scale)) + ) + + font_paths = set() + + # Embed font, if requested + if self.embed_font: + # Import here, to avoid hard dependency on fonttools + import fontTools + import fontTools.subset + + # Subset options + options = fontTools.subset.Options( + + # Small impact on character shapes, but reduce size a lot + hinting=not self.optimize_embedded_font, + + # On small subsets, can improve size + desubroutinize=self.optimize_embedded_font, + + # Try to be lenient + ignore_missing_glyphs=True, + ) + + fonts = defaultdict(set) + for layout_item in wc.layout_: + for font_offset in layout_item.font_info.font_offsets: + for c in layout_item.word[font_offset.start:font_offset.end]: + fonts[font_offset.path].add(c) + + for font_path, characters in fonts.items(): + font_paths.add(font_path) + + # Load and subset font + ttf = fontTools.subset.load_font(font_path, options) + subsetter = fontTools.subset.Subsetter(options) + text = ''.join(characters) + subsetter.populate(text=text) + subsetter.subset(ttf) + + # Export as WOFF + # TODO is there a better method, i.e. directly export to WOFF? + buffer = io.BytesIO() + ttf.saveXML(buffer) + buffer.seek(0) + woff = fontTools.ttLib.TTFont(flavor='woff') + woff.importXML(buffer) + + _, fp = wc.font_collection.image_font(font_path, max_font_size) + + # Create stylesheet with embedded font face + buffer = io.BytesIO() + woff.save(buffer) + data = base64.b64encode(buffer.getbuffer()).decode('ascii') + url = 'data:application/font-woff;charset=utf-8;base64,' + data + result.append( + '' + .format( + fp.family, + fp.weight, + fp.style, + url + ) + ) + + # Add background + if wc.background_color is not None: + result.append( + '' + '' + .format(wc.background_color) + ) + + # Embed image, useful for debug purpose + if self.embed_image: + image = self.to_image() + data = io.BytesIO() + image.save(data, format='JPEG') + data = base64.b64encode(data.getbuffer()).decode('ascii') + result.append( + '' + .format(data) + ) + + # Gather all the font paths used, if not already computed while embedding + if not font_paths: + font_paths.update(font_offset.path + for layout_item in wc.layout_ + for font_offset in layout_item.font_info.font_offsets) + + # Select global style if only one font is used + if len(font_paths) == 1: + _, fp = wc.font_collection.image_font(list(font_paths)[0], max_font_size) + result.append( + '' + .format(fp.family, fp.weight, fp.style) + ) + mixed_fonts = False + else: + mixed_fonts = True + + # For each word in layout + for layout_item in wc.layout_: + result.extend(self.render_layout(layout_item, scale, mixed_fonts)) + + # TODO draw contour + + # Complete SVG file + 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( - '' - .format( - width * self.scale, - height * self.scale - ) - ) - - # Embed font, if requested - if embed_font: - - # Import here, to avoid hard dependency on fonttools - import fontTools - import fontTools.subset - - # Subset options - options = fontTools.subset.Options( - - # Small impact on character shapes, but reduce size a lot - hinting=not optimize_embedded_font, - - # On small subsets, can improve size - desubroutinize=optimize_embedded_font, - - # Try to be lenient - ignore_missing_glyphs=True, - ) - - # Load and subset font - ttf = fontTools.subset.load_font(self.font_path, options) - subsetter = fontTools.subset.Subsetter(options) - characters = {c for item in self.layout_ for c in item[0][0]} - text = ''.join(characters) - subsetter.populate(text=text) - subsetter.subset(ttf) - - # Export as WOFF - # TODO is there a better method, i.e. directly export to WOFF? - buffer = io.BytesIO() - ttf.saveXML(buffer) - buffer.seek(0) - woff = fontTools.ttLib.TTFont(flavor='woff') - woff.importXML(buffer) - - # Create stylesheet with embedded font face - buffer = io.BytesIO() - woff.save(buffer) - data = base64.b64encode(buffer.getbuffer()).decode('ascii') - url = 'data:application/font-woff;charset=utf-8;base64,' + data - result.append( - '' - .format( - font_family, - font_weight, - font_style, - url - ) - ) - - # Select global style - result.append( - '' - .format( - font_family, - font_weight, - font_style - ) - ) - - # Add background - if self.background_color is not None: - result.append( - '' - '' - .format(self.background_color) - ) - - # Embed image, useful for debug purpose - if embed_image: - image = self.to_image() - data = io.BytesIO() - image.save(data, format='JPEG') - data = base64.b64encode(data.getbuffer()).decode('ascii') - result.append( - '' - .format(data) - ) - - # For each word in layout - for (word, count), font_size, (y, x), orientation, color in self.layout_: - x *= self.scale - y *= self.scale - - # Get text metrics - font = ImageFont.truetype(self.font_path, int(font_size * self.scale)) - (size_x, size_y), (offset_x, offset_y) = font.font.getsize(word) - ascent, descent = font.getmetrics() - - # Compute text bounding box - min_x = -offset_x - max_x = size_x - offset_x - max_y = ascent - offset_y - - # Compute text attributes - attributes = {} - if orientation == Image.ROTATE_90: - x += max_y - y += max_x - min_x - transform = 'translate({},{}) rotate(-90)'.format(x, y) - else: - x += min_x - y += max_y - transform = 'translate({},{})'.format(x, y) - - # Create node - attributes = ' '.join('{}="{}"'.format(k, v) for k, v in attributes.items()) - result.append( - '' - '{}' - '' - .format( - transform, - font_size * self.scale, - color, - saxutils.escape(word) - ) - ) - - # TODO draw contour - - # Complete SVG file - 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."""