9from urllib.request import urlretrieve
10from xml.etree import ElementTree as ET
14# See https://fontawesome.com/how-to-use/on-the-desktop/setup/getting-started
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):
25 Returns the (width, height, <path> element) of a Font Awesome icon.
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:
33 # fontawesome-free-5.15.2-desktop/svgs/solid/snowflake.svg
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:
43 # <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
44 # <!-- Font Awesome Free 5.15.2 by @fontawesome … -->
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()
55 if element.tag == "{http://www.w3.org/2000/svg}path":
56 return (int(width), int(height), ET.tostring(element).decode("ascii"))
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
65 Based on an algorithm from Kate's friend.
67 This will give you the (x, y) coordinates for the top left-hand corner
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)
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)
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):
97 Given the hex string of the background colour (e.g. #ff0000), generate
98 the hex strings of other, similar colours.
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
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)
120 min_lightness, max_lightness = (max(0, lightness - 0.2), lightness)
122 min_lightness, max_lightness = (lightness, min(lightness + 0.2, 1))
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}"
144 Creates an SVG file. Returns the path to the generated SVG.
146 :param background: A background colour as a six-char CSS colour
148 :param icon_name: The name of the Font Awesome icon to use
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.
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.
168 f'<svg viewBox="0 0 {width} {height}" style="background-color:{background}" xmlns="http://www.w3.org/2000/svg">',
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)
174 <rect style="fill:{background}" x="0" y="0" width="{width}" height="{height}"/>
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(
186 columns=width // 100,
191 avoid_center=avoid_center,
193 create_fill_colors(background_color=background)
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)
209 <filter id="blur{icon_blur}" x="0" y="0">
210 <feGaussianBlur in="SourceGraphic" stdDeviation="{icon_blur} {icon_blur}"/>
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})">
221 # Add the closing tags, write to the file. We tidy up the XML a bit.
222 lines.append("</svg>")
225 _, out_path = tempfile.mkstemp(suffix=".svg")
227 with open(out_path, "w") as outfile:
228 outfile.write("\n".join(lines))
234 parser = argparse.ArgumentParser(
235 description="Create low-contrast backgrounds from Font Awesome icons. Prints the path to a generated SVG file."
238 "--background", help="a six-digit CSS colour (e.g. #ff0000)", required=True
242 help="the Font Awesome icon to use (e.g. snowflake)",
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:
262 f"error: --min_icon_count={result.min_icon_count} should be less than or equal to --max_icon_count={result.max_icon_count}"
265 if result.min_scale > result.max_scale:
267 f"error: --min_scale={result.min_scale} should be less than or equal to --max_scale={result.max_scale}"
271 name: getattr(result, name) for name in dir(result) if not name.startswith("_")
274 if result["avoid_centre"]:
275 result["avoid_center"] = result["avoid_centre"]
276 del result["avoid_centre"]
281if __name__ == "__main__":
282 kwargs = parse_args()
283 out_path = create_svg(**kwargs)