├── requirements.txt ├── .docs └── hello.png ├── .gitignore ├── LICENSE ├── README.md ├── OFL.md └── main.py /requirements.txt: -------------------------------------------------------------------------------- 1 | svgwrite 2 | numpy 3 | cairosvg 4 | potracer -------------------------------------------------------------------------------- /.docs/hello.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dupontgu/scopin-sans-typeface/HEAD/.docs/hello.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .Python 2 | [Bb]in 3 | [Ii]nclude 4 | [Ll]ib 5 | [Ll]ib64 6 | [Ll]ocal 7 | [Ss]cripts 8 | pyvenv.cfg 9 | .venv 10 | pip-selfcheck.json 11 | outputs/ 12 | **/.DS_Store -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Guy Dupont 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scopin' Sans 2 | An open source typeface for hardware people! 3 | It renders text as if it were being viewed as serial data on an oscilloscope. 4 | There are currently 3 variations: 5 | 1. `Normal` 6 | 2. `FastBaud` - compressed horizontally 7 | 3. `NoNoise` - no baked-in noise, just square waves. 8 | 9 | 10 | 11 | ## Generating the typeface 12 | 1. Install [`fontforge`](https://fontforge.org/) on your computer. You will not need to use the GUI, but the installation of the app will provide the Python hooks used by this script. 13 | 2. Download/clone this repo, and navigate to it's root directory using a terminal. 14 | 3. Create a Python virtual environment: `python3 -m venv . ` 15 | 4. Edit your new `pyvenv.cfg` file to allow the use of system-site packages (this will allow the script to access `fontforge`). Set `include-system-site-packages = true` and save the file. 16 | 5. Install dependencies: `pip install -r requirements.txt` 17 | 6. Run the script: `python main.py` 18 | 7. Two sets of `ttf` files will be generated, choose one set to install from: 19 | 1. The set in `outputs/ScopinSans` are generated such that they are all a part of the same family. Each variation will be installed as a different _weight_. This keeps your system font list a little tidier, but certain applications may not let you choose font weights! 20 | 2. The set in `outputs/ScopinSans-Individuals` are generated such that each variant is a unique font. They will all show up as individual fonts on your system. 21 | 22 | ## Using the typeface 23 | Once the .ttf files are installed on your machine, you should be able to use Scopin' Sans like any other typeface. *Note that only ascii values are generated currently!* There are 1-bit utility characters that can be used to generate arbitrary waveforms: 24 | - `¥` - falling edge (0b10) 25 | - `¦` - rising edge (0b01) 26 | - [`§, ¨, ©, ª, «, ¬, ®, ¯`] - signal high (0b1) (there are multiple so you can randomize the noise) 27 | - [`°, ±, ², ³, ´, ¶, ·, ¸`] - signal low (0b0) (there are multiple so you can randomize the noise) -------------------------------------------------------------------------------- /OFL.md: -------------------------------------------------------------------------------- 1 | Copyright (c) `2023`, `Guy Dupont` (`gvy.dvupont@gmail.com`) 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | 6 | 7 | --- 8 | 9 | # SIL OPEN FONT LICENSE 10 | 11 | 12 | 13 | ##### *Version 1.1 - 26 February 2007* 14 | 15 | 16 | PREAMBLE 17 | ---------- 18 | 19 | The goals of the Open Font License (OFL) are to stimulate worldwide 20 | development of collaborative font projects, to support the font creation 21 | efforts of academic and linguistic communities, and to provide a free and 22 | open framework in which fonts may be shared and improved in partnership 23 | with others. 24 | 25 | The OFL allows the licensed fonts to be used, studied, modified and 26 | redistributed freely as long as they are not sold by themselves. The 27 | fonts, including any derivative works, can be bundled, embedded, 28 | redistributed and/or sold with any software provided that any reserved 29 | names are not used by derivative works. The fonts and derivatives, 30 | however, cannot be released under any other type of license. The 31 | requirement for fonts to remain under this license does not apply 32 | to any document created using the fonts or their derivatives. 33 | 34 | DEFINITIONS 35 | ------------- 36 | 37 | ***Font Software*** refers to the set of files released by the Copyright 38 | Holder(s) under this license and clearly marked as such. This may 39 | include source files, build scripts and documentation. 40 | 41 | ***Reserved Font Name*** refers to any names specified as such after the 42 | copyright statement(s). 43 | 44 | ***Original Version*** refers to the collection of Font Software components as 45 | distributed by the Copyright Holder(s). 46 | 47 | ***Modified Version*** refers to any derivative made by adding to, deleting, 48 | or substituting -- in part or in whole -- any of the components of the 49 | Original Version, by changing formats or by porting the Font Software to a 50 | new environment. 51 | 52 | ***Author*** refers to any designer, engineer, programmer, technical 53 | writer or other person who contributed to the Font Software. 54 | 55 | PERMISSION & CONDITIONS 56 | ------------------------ 57 | 58 | Permission is hereby granted, free of charge, to any person obtaining 59 | a copy of the ***Font Software***, to use, study, copy, merge, embed, modify, 60 | redistribute, and sell modified and unmodified copies of the ***Font 61 | Software***, subject to the following conditions: 62 | 63 | 1) Neither the ***Font Software*** nor any of its individual components, 64 | in ***Original*** or ***Modified Versions***, may be sold by itself. 65 | 66 | 2) ***Original*** or ***Modified Versions*** of the ***Font Software*** may be bundled, 67 | redistributed and/or sold with any software, provided that each copy 68 | contains the above copyright notice and this license. These can be 69 | included either as stand-alone text files, human-readable headers or 70 | in the appropriate machine-readable metadata fields within text or 71 | binary files as long as those fields can be easily viewed by the user. 72 | 73 | 3) No ***Modified Version*** of the ***Font Software*** may use the ***Reserved Font 74 | Name(s)*** unless explicit written permission is granted by the corresponding 75 | Copyright Holder. This restriction only applies to the primary font name as 76 | presented to the users. 77 | 78 | 4) The name(s) of the Copyright Holder(s) or the ***Author(s)*** of the ***Font 79 | Software*** shall not be used to promote, endorse or advertise any 80 | ***Modified Version***, except to acknowledge the contribution(s) of the 81 | Copyright Holder(s) and the ***Author(s)*** or with their explicit written 82 | permission. 83 | 84 | 5) The ***Font Software***, modified or unmodified, in part or in whole, 85 | must be distributed entirely under this license, and must not be 86 | distributed under any other license. The requirement for fonts to 87 | remain under this license does not apply to any document created 88 | using the ***Font Software***. 89 | 90 | TERMINATION 91 | ----------- 92 | 93 | This license becomes null and void if any of the above conditions are 94 | not met. 95 | 96 | 97 | DISCLAIMER 98 | ----------- 99 | 100 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 101 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 102 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 103 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 104 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 105 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 106 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 107 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 108 | OTHER DEALINGS IN THE FONT SOFTWARE. 109 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import fontforge 2 | import svgwrite 3 | import math 4 | import numpy as np 5 | import cairosvg 6 | from PIL import Image 7 | import potrace 8 | from pathlib import Path 9 | 10 | root_output_dir = "outputs" 11 | svg_working_file = f"{root_output_dir}/temp.svg" 12 | png_working_file = f"{root_output_dir}/temp.png" 13 | regen_svgs = True 14 | version = "0.4" 15 | 16 | # should probably parameterize these 17 | height = 200 18 | stroke_width = 4.5 19 | 20 | 21 | def createCharFromSvg(font, char, filename, translation_factor, force_scale=None): 22 | fontChar = font.createChar(ord(char)) 23 | fontChar.importOutlines(filename) 24 | box = fontChar.boundingBox() 25 | original_height = box[3] - box[1] 26 | original_width = box[2] - box[0] 27 | scale = (original_width / original_height) if force_scale is None else force_scale 28 | width = font.em * scale 29 | transformation_matrix = (1, 0, 0, 1, -4, 0) 30 | if force_scale is not None: 31 | width = min(original_width, width) 32 | fontChar.transform(transformation_matrix) 33 | elif scale > 1: 34 | transformation_matrix = (scale, 0, 0, scale, -4, translation_factor) 35 | fontChar.transform(transformation_matrix) 36 | # - 4 to ensure a little overlap with previous 37 | fontChar.width = math.ceil(width) - 6 38 | return scale 39 | 40 | 41 | # adapted from: https://github.com/tatarize/potrace/pull/8/files 42 | def path_to_svg(width, height, path, output_file): 43 | with open(output_file, "w") as fp: 44 | fp.write( 45 | f'' 50 | ) 51 | parts = [] 52 | for curve in path: 53 | fs = curve.start_point 54 | parts.append("M%f,%f" % (fs.x, fs.y)) 55 | for segment in curve.segments: 56 | if segment.is_corner: 57 | a = segment.c 58 | parts.append("L%f,%f" % (a.x, a.y)) 59 | b = segment.end_point 60 | parts.append("L%f,%f" % (b.x, b.y)) 61 | else: 62 | a = segment.c1 63 | b = segment.c2 64 | c = segment.end_point 65 | parts.append("C%f,%f %f,%f %f,%f" % (a.x, a.y, b.x, b.y, c.x, c.y)) 66 | parts.append("z") 67 | fp.write( 68 | f'' 69 | ) 70 | fp.write("") 71 | 72 | 73 | def svg_for_code( 74 | number: int, 75 | output_dir: str, 76 | samples_per_bit: int, 77 | noise_amount: float, 78 | start_bit: bool, 79 | end_bit: bool, 80 | digits: int = 8, 81 | start_high: bool = True, 82 | ): 83 | output_path = f"{output_dir}/{str(number)}.svg" 84 | if not regen_svgs: 85 | return output_path 86 | binary_string = format(number, f"0{digits}b") 87 | # LSB first 88 | binary_string = binary_string[::-1] 89 | if start_bit: 90 | binary_string = "0" + binary_string 91 | if end_bit: 92 | binary_string = binary_string + "1" 93 | # invert signal 94 | binary_signal = np.array([-1 if bool(int(bit)) else 1 for bit in binary_string]) 95 | signal = np.repeat(binary_signal, samples_per_bit) 96 | expanded_time = np.linspace(0, len(binary_string), len(signal)) 97 | noise = np.random.normal(0, noise_amount, expanded_time.shape) 98 | signal = signal + noise 99 | 100 | scale = height * (0.99 - (noise_amount * 2)) 101 | pos = (3, -scale if start_high else scale) 102 | samples = len(signal) 103 | # add some padding at the end to make room for the falling edge 104 | if end_bit: 105 | samples += 4 106 | 107 | dwg = svgwrite.Drawing( 108 | svg_working_file, 109 | profile="tiny", 110 | size=(f"{samples}mm", f"{2*height}mm"), 111 | viewBox=(f"0 {-height} {samples} {2*height}"), 112 | ) 113 | 114 | for p in signal: 115 | (prev_x, prev_y) = pos 116 | y_delt = prev_y - (p * scale) 117 | x = prev_x + 1 118 | dwg.add( 119 | dwg.line( 120 | pos, 121 | (x, prev_y - y_delt), 122 | stroke=svgwrite.rgb(0, 0, 0), 123 | stroke_width=stroke_width, 124 | ) 125 | ) 126 | pos = (x, prev_y - y_delt) 127 | if end_bit: 128 | dwg.add( 129 | dwg.line( 130 | pos, 131 | (prev_x + 2, prev_y), 132 | stroke=svgwrite.rgb(0, 0, 0), 133 | stroke_width=stroke_width, 134 | ) 135 | ) 136 | 137 | # You're gonna read this and think: "is he really going from svg -> png -> back to svg?" 138 | # Yes, I am! The potrace result looks more "organic", simplifies the final svg, 139 | # and allows me to write more simple svg generation code up front. 140 | dwg.save(pretty=True) 141 | cairosvg.svg2png( 142 | url=svg_working_file, 143 | write_to=png_working_file, 144 | scale=0.3, 145 | background_color="#FFF", 146 | ) 147 | image = Image.open(png_working_file).convert("L") 148 | bitmap = potrace.Bitmap(np.array(image)) 149 | path = bitmap.trace(opttolerance=0.8) 150 | path_to_svg(image.width, image.height, path, output_path) 151 | return output_path 152 | 153 | 154 | def create_font(weight, samples_per_bit, noise_amount): 155 | print(f"CREATE VARIANT: {weight}") 156 | svg_dir = f"{root_output_dir}/{weight}" 157 | Path(svg_dir).mkdir(parents=True, exist_ok=True) 158 | 159 | font = fontforge.font() 160 | font.encoding = "UnicodeFull" 161 | font.version = version 162 | font.copyright = ( 163 | "Copyright (c) 2023 Guy Dupont (gvy.dvupont@gmail.com), " 164 | + "soure code: https://github.com/dupontgu/scopin-sans-typeface, " 165 | + "OFL License: https://openfontlicense.org/documents/OFL.txt" 166 | ) 167 | font.em = 512 168 | max_scale = 0 169 | # This is not real math, these values just made it work for now. 170 | y_adjustment = -((10 * samples_per_bit) - (387 - (noise_amount * height))) 171 | 172 | for code in range(0, 128): 173 | print(code) 174 | svg_path = svg_for_code( 175 | number=code, 176 | output_dir=svg_dir, 177 | samples_per_bit=samples_per_bit, 178 | noise_amount=noise_amount, 179 | start_bit=True, 180 | end_bit=True, 181 | ) 182 | 183 | current_scale = createCharFromSvg(font, chr(code), svg_path, y_adjustment) 184 | if current_scale > max_scale: 185 | max_scale = current_scale 186 | 187 | # UTILITY CHARACTERS 188 | # for rising edge 189 | for c in ["¥"]: 190 | svg_path = svg_for_code( 191 | number=0b01, 192 | output_dir=svg_dir, 193 | samples_per_bit=samples_per_bit, 194 | noise_amount=noise_amount, 195 | start_bit=False, 196 | end_bit=False, 197 | digits=2, 198 | ) 199 | createCharFromSvg(font, c, svg_path, y_adjustment, force_scale=max_scale) 200 | 201 | # for falling edge 202 | for c in ["¦"]: 203 | svg_path = svg_for_code( 204 | number=0b10, 205 | output_dir=svg_dir, 206 | samples_per_bit=samples_per_bit, 207 | noise_amount=noise_amount, 208 | start_bit=False, 209 | end_bit=False, 210 | digits=2, 211 | start_high=False, 212 | ) 213 | createCharFromSvg(font, c, svg_path, y_adjustment, force_scale=max_scale) 214 | 215 | # for high states 216 | for c in ["§", "¨", "©", "ª", "«", "¬", "®", "¯"]: 217 | svg_path = svg_for_code( 218 | number=0b1, 219 | output_dir=svg_dir, 220 | samples_per_bit=samples_per_bit, 221 | noise_amount=noise_amount, 222 | start_bit=False, 223 | end_bit=False, 224 | digits=1, 225 | ) 226 | createCharFromSvg(font, c, svg_path, y_adjustment, force_scale=max_scale) 227 | 228 | # for low states 229 | for c in ["°", "±", "²", "³", "´", "¶", "·", "¸"]: 230 | svg_path = svg_for_code( 231 | number=0b0, 232 | output_dir=svg_dir, 233 | samples_per_bit=samples_per_bit, 234 | noise_amount=noise_amount, 235 | start_bit=False, 236 | end_bit=False, 237 | digits=1, 238 | start_high=False, 239 | ) 240 | createCharFromSvg(font, c, svg_path, 0, force_scale=max_scale) 241 | 242 | # generate font under the global ScopinSans family, where each variant is a "weight" 243 | # this makes for a cleaner install on some machines, but weights are unavailabe in some apps 244 | font_dir = f"{root_output_dir}/ScopinSans-{version}" 245 | Path(font_dir).mkdir(parents=True, exist_ok=True) 246 | font.weight = weight 247 | filename = f"ScopinSans-{weight}" 248 | font.fontname = filename 249 | font.familyname = "ScopinSans" 250 | font.fullname = filename 251 | font.generate(f"{font_dir}/{filename}.ttf") 252 | font.generate(f"{font_dir}/{filename}.woff2") 253 | 254 | # alternatively create a unique family for each variant, so each is installed independently 255 | individual_dir = f"{root_output_dir}/ScopinSans-Individuals-{version}" 256 | Path(individual_dir).mkdir(parents=True, exist_ok=True) 257 | filename = f"ScopinSans {weight}" 258 | font.familyname = filename 259 | font.fullname = filename 260 | font.weight = "Regular" 261 | font.generate(f"{individual_dir}/{filename}.ttf") 262 | font.close() 263 | 264 | 265 | create_font("NoNoise", samples_per_bit=80, noise_amount=0.0) 266 | create_font("Regular", samples_per_bit=100, noise_amount=0.04) 267 | create_font("FastBaud", samples_per_bit=30, noise_amount=0.04) 268 | --------------------------------------------------------------------------------