Skip to main content

happybackgrounds.py

1#!/usr/bin/env python
3import argparse
4import colorsys
5import os
6import random
7import sys
8import tempfile
9from urllib.request import urlretrieve
10from xml.etree import ElementTree as ET
11import zipfile
14# See https://fontawesome.com/how-to-use/on-the-desktop/setup/getting-started
15FA_VERSION = "5.15.2"
16FA_URL = f"https://use.fontawesome.com/releases/v{FA_VERSION}/fontawesome-free-{FA_VERSION}-desktop.zip"
18# Where the Font Awesome icons will be saved. If this is a persistent
19# directory, then it will be saved between different runs of the script.
20FA_LOCAL_PATH = os.path.basename(FA_URL)
23def get_font_awesome_icon_path(*, icon_name):
24 """
25 Returns the (width, height, <path> element) of a Font Awesome icon.
26 """
27 if not os.path.exists(FA_LOCAL_PATH):
28 urlretrieve(url=FA_URL, filename=FA_LOCAL_PATH)
30 with zipfile.ZipFile(FA_LOCAL_PATH) as zf:
31 # The names in this archive are something like:
32 #
33 # fontawesome-free-5.15.2-desktop/svgs/solid/snowflake.svg
34 #
35 # I've picked the solid icons for now. I'm not sure what the
36 # difference between "solid" and "regular" is, and I'm assuming
37 # @happyautomata doesn't want "brands".
38 inner_name = f"fontawesome-free-{FA_VERSION}-desktop/svgs/solid/{icon_name}.svg"
39 icon_contents = zf.read(inner_name)
41 # The contents of a Font Awesome SVG goes something like:
42 #
43 # <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
44 # <!-- Font Awesome Free 5.15.2 by @fontawesome … -->
45 # <path d="…"/>
46 # </svg>
47 #
48 # The Python XML parser ignores comments; this gets added separately.
49 # We care about getting the <path> element which defines the icon.
50 root = ET.fromstring(icon_contents)
52 _, _, width, height = root.attrib["viewBox"].split()
54 for element in root:
55 if element.tag == "{http://www.w3.org/2000/svg}path":
56 return (int(width), int(height), ET.tostring(element).decode("ascii"))
57 else: # no return
58 raise RuntimeError(f"Could not find <path> in {inner_name}!")
61def create_xy_positions(
62 *, width, height, columns, rows, min_nudge, max_nudge, count, avoid_center
63):
64 """
65 Based on an algorithm from Kate's friend.
67 This will give you the (x, y) coordinates for the top left-hand corner
68 of the icon.
69 """
70 # Put the icons randomly on an N×M grid
71 valid_positions = [(x, y) for x in range(columns) for y in range(rows)]
72 random.shuffle(valid_positions)
74 if avoid_center:
75 valid_positions = [
76 (x, y)
77 for (x, y) in valid_positions
78 if (x <= columns / 4 or x >= 3 * columns / 4)
79 and (y <= rows / 4 or y >= 3 * rows / 4)
80 ]
82 # Choose a direction and amount to nudge them by
83 nudge = random.uniform(min_nudge, max_nudge)
84 for x, y in valid_positions[:count]:
85 # x = x + ((1 - random.random() * 2) * nudge)
86 # y = y + ((1 - random.random() * 2) * nudge)
88 # How does this map to our canvas?
89 row_height = height / rows
90 column_width = width / columns
92 yield (x * column_width, y * row_height)
95def create_fill_colors(*, background_color):
96 """
97 Given the hex string of the background colour (e.g. #ff0000), generate
98 the hex strings of other, similar colours.
99 """
100 # Parse the CSS colour. We're only going to vary the lightness of the
101 # generated icons, so work out what we're varying it between -- i.e. are
102 # we going darker than the background, or lighter?
103 red = int(background_color[1:3], 16) / 255
104 green = int(background_color[3:5], 16) / 255
105 blue = int(background_color[5:7], 16) / 255
107 hue, lightness, saturation = colorsys.rgb_to_hls(red, green, blue)
109 # Light background => darken
110 # Dark background => lighten
111 # Middling => choose at random
112 #
113 # 0.2 is chosen at random
114 is_light_bg = lightness > 0.8
115 is_dark_bg = lightness < 0.2
117 darken_icons = is_light_bg or (not is_dark_bg and random.random() > 0.5)
119 if darken_icons:
120 min_lightness, max_lightness = (max(0, lightness - 0.2), lightness)
121 else:
122 min_lightness, max_lightness = (lightness, min(lightness + 0.2, 1))
124 while True:
125 icon_lightness = random.uniform(min_lightness, max_lightness)
126 r, g, b = colorsys.hls_to_rgb(hue, icon_lightness, saturation)
127 r, g, b = (int(r * 255), int(g * 255), int(b * 255))
128 yield f"#{r:02x}{g:02x}{b:02x}"
131def create_svg(
132 *,
133 background,
134 icon_name,
135 min_icon_count,
136 max_icon_count,
137 min_scale,
138 max_scale,
139 max_rotation,
140 avoid_center=False,
141 out_path=None,
142):
143 """
144 Creates an SVG file. Returns the path to the generated SVG.
146 :param background: A background colour as a six-char CSS colour
147 (e.g. #ff0000).
148 :param icon_name: The name of the Font Awesome icon to use
149 (e.g. snowflake)
150 :param min_icon_count: What's the smallest number of icons to add?
151 :param max_icon_count: What's the most icons to add?
152 :param min_scale: How small can the icons get?
153 :param max_scale: How big can the icons get?
154 :param out_path: Where to save the file.
155 :param avoid_center: Should it avoid the centre?
157 Note: because of the way icons are added, the min/max icon counts
158 are rough estimates, and you may see fewer icons in the viewbox than
159 you actually asked for.
161 """
162 width = 1600
163 height = 900
165 # We want an SVG with a 16:9 ratio and the specified background color.
166 # I'm including a <rect> so that ImageMagick picks up the background.
167 lines = [
168 f'<svg viewBox="0 0 {width} {height}" style="background-color:{background}" xmlns="http://www.w3.org/2000/svg">',
169 f"""
170 <!--
171 Font Awesome Free {FA_VERSION} by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free
172 (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
173 -->
174 <rect style="fill:{background}" x="0" y="0" width="{width}" height="{height}"/>
175 """,
176 ]
178 # Get the icon path, and render it at various points on the background.
179 count = random.randint(min_icon_count, max_icon_count)
180 icon_width, icon_height, icon_path = get_font_awesome_icon_path(icon_name=icon_name)
182 for (x, y), fill_color in zip(
183 create_xy_positions(
184 width=width,
185 height=height,
186 columns=width // 100,
187 rows=height // 100,
188 min_nudge=-0.2,
189 max_nudge=0.2,
190 count=count,
191 avoid_center=avoid_center,
192 ),
193 create_fill_colors(background_color=background)
194 ):
195 scale = random.uniform(min_scale, max_scale)
197 rotation_angle = random.randint(-max_rotation, max_rotation)
198 rotation_center_x = scale * icon_width // 2
199 rotation_center_y = scale * icon_height // 2
201 scale = random.uniform(min_scale, max_scale)
203 # icon_blur = random.randint(0, 20)
204 icon_blur = 0
206 lines.append(
207 f"""
208 <defs>
209 <filter id="blur{icon_blur}" x="0" y="0">
210 <feGaussianBlur in="SourceGraphic" stdDeviation="{icon_blur} {icon_blur}"/>
211 </filter>
212 </defs>
213 <g transform="translate({x} {y})
214 rotate({rotation_angle} {rotation_center_x} {rotation_center_y})
215 scale({scale} {scale})"
216 style="fill: {fill_color}; filter: url(#blur{icon_blur})">
217 {icon_path}
218 </g>"""
219 )
221 # Add the closing tags, write to the file. We tidy up the XML a bit.
222 lines.append("</svg>")
224 if out_path is None:
225 _, out_path = tempfile.mkstemp(suffix=".svg")
227 with open(out_path, "w") as outfile:
228 outfile.write("\n".join(lines))
230 return out_path
233def parse_args():
234 parser = argparse.ArgumentParser(
235 description="Create low-contrast backgrounds from Font Awesome icons. Prints the path to a generated SVG file."
236 )
237 parser.add_argument(
238 "--background", help="a six-digit CSS colour (e.g. #ff0000)", required=True
239 )
240 parser.add_argument(
241 "--icon_name",
242 help="the Font Awesome icon to use (e.g. snowflake)",
243 required=True,
244 )
245 parser.add_argument("--min_icon_count", type=int, default=5, help="(default: 5)")
246 parser.add_argument("--max_icon_count", type=int, default=30, help="(default: 30)")
247 parser.add_argument("--min_scale", type=float, default=0.15, help="(default: 0.15)")
248 parser.add_argument("--max_scale", type=float, default=0.3, help="(default: 0.3)")
249 parser.add_argument("--max_rotation", type=float, default=180, help="(default: 180)")
251 parser.add_argument("--avoid_center", action="store_true")
253 # Make a consideration for British people ;-)
254 parser.add_argument("--avoid_centre", action="store_true")
256 parser.add_argument("--out_path", help="Where to save the SVG")
258 result = parser.parse_args()
260 if result.min_icon_count > result.max_icon_count:
261 sys.exit(
262 f"error: --min_icon_count={result.min_icon_count} should be less than or equal to --max_icon_count={result.max_icon_count}"
263 )
265 if result.min_scale > result.max_scale:
266 sys.exit(
267 f"error: --min_scale={result.min_scale} should be less than or equal to --max_scale={result.max_scale}"
268 )
270 result = {
271 name: getattr(result, name) for name in dir(result) if not name.startswith("_")
272 }
274 if result["avoid_centre"]:
275 result["avoid_center"] = result["avoid_centre"]
276 del result["avoid_centre"]
278 return result
281if __name__ == "__main__":
282 kwargs = parse_args()
283 out_path = create_svg(**kwargs)
284 print(out_path)