|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | + |
| 3 | +""" |
| 4 | +This file is part of QualCoder. |
| 5 | +
|
| 6 | +QualCoder is free software: you can redistribute it and/or modify it under the |
| 7 | +terms of the GNU Lesser General Public License as published by the Free Software |
| 8 | +Foundation, either version 3 of the License, or (at your option) any later version. |
| 9 | +
|
| 10 | +QualCoder is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; |
| 11 | +without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
| 12 | +See the GNU General Public License for more details. |
| 13 | +
|
| 14 | +You should have received a copy of the GNU Lesser General Public License along with QualCoder. |
| 15 | +If not, see <https://www.gnu.org/licenses/>. |
| 16 | +
|
| 17 | +Author: Colin Curtain (ccbogel) |
| 18 | +https://github.com/ccbogel/QualCoder |
| 19 | +https://qualcoder.wordpress.com/ |
| 20 | +https://qualcoder-org.github.io/ |
| 21 | +""" |
| 22 | + |
| 23 | +import webbrowser |
| 24 | +from copy import deepcopy |
| 25 | +import datetime |
| 26 | +import logging |
| 27 | +import os |
| 28 | +from PyQt6 import QtWidgets |
| 29 | +import sys |
| 30 | +import traceback |
| 31 | +from wordcloud import WordCloud |
| 32 | +from PIL import ImageColor |
| 33 | + |
| 34 | +path = os.path.abspath(os.path.dirname(__file__)) |
| 35 | +logger = logging.getLogger(__name__) |
| 36 | + |
| 37 | + |
| 38 | +color_ranges = [ |
| 39 | + {"name": "blue to yellow", |
| 40 | + "range": ["#115f9a", "#1984c5", "#22a7f0", "#48b5c4", "#76c68f", "#a6d75b", "#c9e52f", "#d0ee11", "#d0f400"]}, |
| 41 | + {"name": "grey to red", |
| 42 | + "range": ["#d7e1ee", "#cbd6e4", "#bfcbdb", "#b3bfd1", "#a4a2a8", "#df8879", "#c86558", "#b04238", "#991f17"]}, |
| 43 | + {"name": "black to pink", |
| 44 | + "range": ["#2e2b28", "#3b3734", "#474440", "#54504c", "#6b506b", "#ab3da9", "#de25da", "#eb44e8", "#ff80ff"]}, |
| 45 | + {"name": "blue to red", |
| 46 | + "range": ["#1984c5", "#22a7f0", "#63bff0", "#a7d5ed", "#e2e2e2", "#e1a692", "#de6e56", "#e14b31", "#c23728"]}, |
| 47 | + {"name": "blue to orange", |
| 48 | + "range": ["#003F5C", "#2F4B7C", "#665191", "#A05195", "#D45087", "#F95D6A", "#FF7C43", "#FFA600"]}, |
| 49 | + {"name": "orange to purple", |
| 50 | + "range": ["#ffb400", "#d2980d", "#a57c1b", "#786028", "#363445", "#48446e", "#5e569b", "#776bcd", "#9080ff"]}, |
| 51 | + {"name": "salmon to aqua", |
| 52 | + "range": ["#e27c7c", "#a86464", "#6d4b4b", "#503f3f", "#333333", "#3c4e4b", "#466964", "#599e94", "#6cd4c5"]}, |
| 53 | + {"name": "green to blue", "range": ["#00D40E", "#00BA2D", "#009658", "#007185", "#0053AB", "#003193"]}, |
| 54 | + {"name": "yellow to green", "range": ["#FEFB01", "#CEFB02", "#87FA00", "#3AF901", "#00ED01"]}, |
| 55 | + {"name": "aqua to pink", |
| 56 | + "range": ["#54bebe", "#76c8c8", "#98d1d1", "#badbdb", "#dedad2", "#e4bcad", "#df979e", "#d7658b", "#c80064"]}, |
| 57 | + {"name": "river nights", |
| 58 | + "range": ["#b30000", "#7c1158", "#4421af", "#1a53ff", "#0d88e6", "#00b7c7", "#5ad45a", "#8be04e", "#ebdc78"]}, |
| 59 | + {"name": "blue to aqua", |
| 60 | + "range": ["#004C6D", "#006083", "#007599", "#008BAD", "#00A1C1", "#00B8D3", "#00CFE3", "#00E7F2", "#00FFFF"]}, |
| 61 | + {"name": "greens", |
| 62 | + "range": ["#198450", "#27A567", "#2EB774", "#38CB82", "#41DC8E", "#64E3A1", "#84EAB3", "#AAF0C9", "#CBF5DD"]}, |
| 63 | + {"name": "oranges", "range": ["#FF5500", "#FF6500", "#ff7500", "#FF8500", "#FF9500"]}, |
| 64 | + {"name": "blues", |
| 65 | + "range": ["#0000b3", "#0010d9", "#0020ff", "#0040ff", "#0060ff", "#0080ff", "#009fff", "#00bfff", "#00ffff"]}, |
| 66 | + {"name": "pinks", "range": ["#A73CA4", "#C353C0", "#D178CF", "#DF9DDD", "#ECC3EB"]}, |
| 67 | + {"name": "greys", "range": ["#F2F2F2", "#C2C2C2", "#929292", "#616161", "#414141", "#202020"]}, |
| 68 | + {"name": "yellows", |
| 69 | + "range": ["#E47200", "#E69B00", "#E6B400", "#E6CC00", "#E5DE00", "#E8E337", "#ECE75F", "#F1EE8E", "#F7F5BC"]}, |
| 70 | + {"name": "reds", |
| 71 | + "range": ["#C61A09", "#DF2C14", "#ED3419", "#FB3B1E", "#FF4122", "#FF6242", "#FF8164", "#FFA590", "#FFC9BB"]} |
| 72 | +] |
| 73 | + |
| 74 | +stopwords = ["a", "about", "above", "after", "again", "against", "all", "am", "an", "and", "any", "are", "aren't", "as", |
| 75 | + "at", |
| 76 | + "b", "be", "because", "been", "before", "being", "below", "between", "both", "but", "by", "c", "can", |
| 77 | + "can't", "could", "couldn't", |
| 78 | + "d", "did", "didn't", "do", "does", "doesn't", "doing", "don't", "down", "during", |
| 79 | + "e", "each", "f", "few", "for", "from", "further", "g", "get", "got", |
| 80 | + "h", "had", "hadn't", "has", "hasn't", "have", "haven't", "having", "he", "he's", "her", "here", |
| 81 | + "hers", "herself", "him", "himself", "his", "how", |
| 82 | + "i", "i'll", "i'm", "i've", "if", "in", "into", "is", "is'nt", "isn't", "it", "it's", "its", "itself", |
| 83 | + "j", "just", "k", "l", "m", "me", "more", "most", "my", "myself", "n", "no", "nor", "not", "now", |
| 84 | + "o", "of", "off", "oh", "on", "once", "only", "or", "other", "our", "ours", "ourselves", "out", "over", |
| 85 | + "own", |
| 86 | + "p", "pre", "put", "q", "r", "re", |
| 87 | + "s", "same", "she", "she'd", "she's", "should", "shouldn't", "so", "some", "such", |
| 88 | + "t", "than", "that", "that's", "the", "their", "theirs", "them", "themselves", "then", "there", "there's", |
| 89 | + "these", |
| 90 | + "they", "they'd", "they'll", "they're", "they've", "this", "those", "through", "to", "too", |
| 91 | + "u", "uh", "um", "under", "until", "up", "us", "v", "very", |
| 92 | + "w", "was", "wasn't", "we", "we're", "we've", "were", "weren't", "what", |
| 93 | + "what's", "when", "where", "which", "while", |
| 94 | + "who", "who's", "whom", "why", "will", "with", "would", "wouldn't", |
| 95 | + "x", "y", "you", "you'd", "you'ld", "you're", "you've", "your", "yours", "yourself", "yourselves", "z"] |
| 96 | + |
| 97 | + |
| 98 | +class Wordcloud: |
| 99 | + """Create a wordcloud using the `wordcloud` package. |
| 100 | +
|
| 101 | + Args are mostly compatible with the original implementation: |
| 102 | + app: kept for compatibility (not used here except for potential future use) |
| 103 | + fulltext: source text |
| 104 | + width, height: image dimensions |
| 105 | + max_words: maximum number of words |
| 106 | + background_color: passed directly to WordCloud |
| 107 | + text_color: |
| 108 | + - "random": use wordcloud's default random coloring |
| 109 | + - a named color ("red", "#ff0000", etc.) |
| 110 | + - a color range name from color_ranges above |
| 111 | + reverse_colors: reverse order of the chosen color range |
| 112 | + ngrams: 1 for single words, >1 for n-grams |
| 113 | + stopwords_filepath2: alternative stopwords file path |
| 114 | + """ |
| 115 | + |
| 116 | + def __init__( |
| 117 | + self, |
| 118 | + app, |
| 119 | + fulltext, |
| 120 | + width=800, |
| 121 | + height=600, |
| 122 | + max_words=200, |
| 123 | + background_color="black", |
| 124 | + text_color="random", |
| 125 | + reverse_colors=False, |
| 126 | + ngrams=1, |
| 127 | + stopwords_filepath2=None |
| 128 | + ): |
| 129 | + self.app = app |
| 130 | + self.width = width |
| 131 | + self.height = height |
| 132 | + self.max_words = max_words |
| 133 | + self.background_color = background_color |
| 134 | + self.text_color = text_color |
| 135 | + self.ngrams = ngrams |
| 136 | + self.reverse_colors = reverse_colors |
| 137 | + |
| 138 | + # Font in ~/.qualcoder |
| 139 | + self.font_path = os.path.join(os.path.expanduser('~'), ".qualcoder", "DroidSansMono.ttf") |
| 140 | + |
| 141 | + # Stopwords: file in ~/.qualcoder or provided path, fallback to built-in list |
| 142 | + stopwords_file_path = os.path.join(os.path.expanduser('~'), ".qualcoder", "stopwords.txt") |
| 143 | + if stopwords_filepath2 is not None: |
| 144 | + stopwords_file_path = stopwords_filepath2 |
| 145 | + |
| 146 | + self.stopwords = [] |
| 147 | + try: |
| 148 | + with open(stopwords_file_path, "r", encoding="utf-8", errors="backslashreplace") as stopwords_file: |
| 149 | + while True: |
| 150 | + stopword = stopwords_file.readline() |
| 151 | + if not stopword: |
| 152 | + break |
| 153 | + if stopword[0:6] == "": # BOM from some editors |
| 154 | + stopword = stopword[6:] |
| 155 | + self.stopwords.append(stopword.strip()) |
| 156 | + except FileNotFoundError as err: |
| 157 | + print(err) |
| 158 | + self.stopwords = stopwords |
| 159 | + |
| 160 | + # ---- TEXT PREPROCESSING + NGRAMS ---- |
| 161 | + # 1) Clean text (letters + apostrophes, lowercased) |
| 162 | + cleaned = self._clean_text(fulltext) |
| 163 | + |
| 164 | + # 2) Tokenize |
| 165 | + tokens = cleaned.split() |
| 166 | + |
| 167 | + # 3) Build n-grams before applying stopwords, |
| 168 | + # then drop n-grams that are entirely stopwords. |
| 169 | + if self.ngrams > 1: |
| 170 | + units = self.make_ngrams(tokens, self.ngrams) |
| 171 | + # Optional: filter out n-grams where every word is a stopword |
| 172 | + filtered_units = [] |
| 173 | + sw_set = set(self.stopwords) |
| 174 | + for phrase in units: |
| 175 | + words = phrase.split() |
| 176 | + if not all(w in sw_set for w in words): |
| 177 | + filtered_units.append(phrase) |
| 178 | + units = filtered_units |
| 179 | + else: |
| 180 | + units = tokens |
| 181 | + |
| 182 | + # 4) Remove pure stopword tokens for ngrams=1 |
| 183 | + if self.ngrams == 1: |
| 184 | + sw_set = set(self.stopwords) |
| 185 | + units = [t for t in units if t not in sw_set] |
| 186 | + |
| 187 | + # 5) Build frequency dictionary |
| 188 | + freq = {} |
| 189 | + for u in units: |
| 190 | + freq[u] = freq.get(u, 0) + 1 |
| 191 | + |
| 192 | + if not freq: |
| 193 | + freq = {"NO WORDS": 1} |
| 194 | + |
| 195 | + # ---- COLOR HANDLING ---- |
| 196 | + color_func, colormap = self._build_color_function() |
| 197 | + |
| 198 | + # ---- WORDCLOUD CONSTRUCTION ---- |
| 199 | + wc = WordCloud( |
| 200 | + width=self.width, |
| 201 | + height=self.height, |
| 202 | + max_words=self.max_words, |
| 203 | + background_color=self.background_color, |
| 204 | + stopwords=set(self.stopwords), |
| 205 | + font_path=self.font_path, |
| 206 | + color_func=color_func, |
| 207 | + colormap=colormap, # used only if color_func is None |
| 208 | + scale=3 # higher-quality PNG |
| 209 | + ) |
| 210 | + |
| 211 | + if self.reverse_colors: |
| 212 | + if wc.colormap is not None and hasattr(wc.colormap, "reversed"): |
| 213 | + wc.colormap = wc.colormap.reversed() |
| 214 | + |
| 215 | + # Generate image from frequencies |
| 216 | + wc.generate_from_frequencies(freq) |
| 217 | + |
| 218 | + temp_filepath = os.path.join(os.path.expanduser("~"), ".qualcoder", "wordcloud_temp.png") |
| 219 | + wc.to_file(temp_filepath) |
| 220 | + webbrowser.open(temp_filepath) |
| 221 | + |
| 222 | + # ---------------- helper methods ---------------- |
| 223 | + |
| 224 | + def _clean_text(self, fulltext: str) -> str: |
| 225 | + """Remove most punctuation except apostrophe; convert to lowercase.""" |
| 226 | + chars = [] |
| 227 | + for ch in fulltext: |
| 228 | + if ch.isalpha() or ch == "'": |
| 229 | + chars.append(ch) |
| 230 | + else: |
| 231 | + chars.append(" ") |
| 232 | + return "".join(chars).lower() |
| 233 | + |
| 234 | + def make_ngrams(self, tokens, number_of_words): |
| 235 | + """ Create trigrams from words list. """ |
| 236 | + |
| 237 | + ngrams_list = [] |
| 238 | + for i in range(len(tokens) - number_of_words + 1): |
| 239 | + tokens_list = tokens[i: i + number_of_words] |
| 240 | + ngrams_list.append(" ".join(tokens_list)) |
| 241 | + return ngrams_list |
| 242 | + |
| 243 | + def _build_color_function(self): |
| 244 | + """Return (color_func, colormap) suitable for WordCloud based on text_color. |
| 245 | +
|
| 246 | + - If text_color is a named color range: build a color_func cycling through that list. |
| 247 | + - If text_color is 'random': use default WordCloud random coloring (return (None, None)). |
| 248 | + - Otherwise: treat text_color as a fixed color string (#rrggbb or named) and |
| 249 | + build a color_func returning that color. |
| 250 | + """ |
| 251 | + # Determine if text_color matches one of the defined color ranges |
| 252 | + color_range_chosen = [] |
| 253 | + for color_range in color_ranges: |
| 254 | + if color_range["name"] == self.text_color: |
| 255 | + color_range_chosen = deepcopy(color_range["range"]) |
| 256 | + break |
| 257 | + |
| 258 | + if color_range_chosen: |
| 259 | + def color_func(word, font_size, position, orientation, random_state=None, **kwargs): |
| 260 | + idx = abs(hash(word)) % len(color_range_chosen) |
| 261 | + return color_range_chosen[idx] |
| 262 | + return color_func, None |
| 263 | + |
| 264 | + if self.text_color == "random": |
| 265 | + return None, None |
| 266 | + |
| 267 | + fixed_color = self.text_color |
| 268 | + |
| 269 | + def single_color_func(word, font_size, position, orientation, random_state=None, **kwargs): |
| 270 | + return fixed_color |
| 271 | + |
| 272 | + return single_color_func, None |
| 273 | + |
| 274 | +if __name__ == "__main__": |
| 275 | + test_text = "qualcoder qualcoder qualcoder qualcoder dogs cats birds qualitative analysis qualitative analysis qualitative analysis research research" |
| 276 | + Wordcloud(test_text) |
0 commit comments