Skip to content

Commit 08cb0fa

Browse files
authored
Add files via upload
1 parent 8aa18ee commit 08cb0fa

1 file changed

Lines changed: 276 additions & 0 deletions

File tree

src/qualcoder/simple_wordcloud.py

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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

Comments
 (0)