diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..e1a08a3
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,41 @@
+name: Pages
+
+on:
+ push:
+ branches: [main]
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ pages:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ permissions:
+ pages: write
+ id-token: write
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.x"
+ - name: Install dependencies
+ run: |
+ python -m pip install pip --upgrade
+ python -m pip install -r requirements.txt
+ - name: Build
+ run: python -m tools.build
+ - name: Setup Pages
+ uses: actions/configure-pages@v5
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: ./www
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..22e0cd8
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,28 @@
+name: Release
+
+on:
+ release:
+ types: [published]
+
+jobs:
+ release:
+ permissions:
+ contents: write
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.x"
+ - name: Install dependencies
+ run: |
+ python -m pip install pip --upgrade
+ python -m pip install -r requirements.txt
+ - name: Build
+ run: python -m tools.build
+ - name: Release
+ uses: softprops/action-gh-release@v2
+ with:
+ files: ./build/releases/*.zip
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..be4413e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,144 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+.idea/
+
+# www
+www/fonts/
diff --git a/README.md b/README.md
index e1902c3..a2b126c 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
## Specifications
-
+
The characters are packed into a **tiny 5×10-pixel area**, **baseline 7 pixels down from the top**! **Capital letters** use up the full **7-pixel height**, meanwhile regular **lowercases** occupy a **5-pixel x-height** measured from the baseline upward.
diff --git a/bitroot.ttf b/bitroot.ttf
deleted file mode 100644
index bf5bc4b..0000000
Binary files a/bitroot.ttf and /dev/null differ
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..c1b2317
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+pixel-font-builder==0.0.36
+pixel-font-knife==0.0.13
diff --git a/preview.png b/src/preview.png
similarity index 100%
rename from preview.png
rename to src/preview.png
diff --git a/tools/__init__.py b/tools/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tools/build.py b/tools/build.py
new file mode 100644
index 0000000..9e0afbe
--- /dev/null
+++ b/tools/build.py
@@ -0,0 +1,206 @@
+import shutil
+import zipfile
+from datetime import datetime
+from pathlib import Path
+
+import png
+from pixel_font_builder import Glyph, FontBuilder, WeightName, SerifStyle, SlantStyle, WidthStyle, opentype
+from pixel_font_knife.mono_bitmap import MonoBitmap
+
+from tools import path_define
+
+
+def _load_src_png(file_path: Path) -> MonoBitmap:
+ """
+ The source image uses white as pixel,
+ so we need to customize the loading to convert white to 1 and others to 0
+ :param file_path: '.png' file path
+ :return: MonoBitmap
+ """
+ width, height, pixels, _ = png.Reader(filename=file_path).read()
+ bitmap = MonoBitmap()
+ bitmap.width = width
+ bitmap.height = height
+ for pixels_row in pixels:
+ bitmap_row = []
+ for i in range(0, width * 4, 4):
+ red = pixels_row[i]
+ green = pixels_row[i + 1]
+ blue = pixels_row[i + 2]
+ alpha = pixels_row[i + 3]
+ bitmap_row.append(1 if (red, green, blue, alpha) == (255, 255, 255, 255) else 0)
+ bitmap.append(bitmap_row)
+ return bitmap
+
+
+def main():
+ # -------------
+ # source glyphs
+ # -------------
+
+ src_bitmap = _load_src_png(path_define.src_dir.joinpath('font-sheet.png'))
+ src_alphabet = [
+ 'ABCDEFGHIJKLMNOPQR',
+ 'STUVWXYZ',
+ 'abcdefghijklmnopqr',
+ 'stuvwxyz',
+ '0123456789',
+ '$₵€£¥¤+-*/÷=%"\'#@&',
+ '_(),.;:¿?¡!\\{}<>[]',
+ '§¶µ´^~©®™',
+ ]
+
+ # ------------------
+ # Crop Mono bitmaps
+ # ------------------
+
+ bitmap_mapping = {} # code_point -> bitmap
+ for row, line in enumerate(src_alphabet):
+ for col, c in enumerate(line):
+ code_point = ord(c)
+ bitmap = src_bitmap.crop(7 * col + 2, 12 * row + 2, 5, 10)
+ bitmap_mapping[code_point] = bitmap
+ bitmap_mapping[-1] = src_bitmap.crop(7 * 8 + 2, 12 * 0 + 2, 5, 10) # notdef
+ bitmap_mapping[32] = src_bitmap.crop(7 * 8 + 2, 12 * 1 + 2, 5, 10) # space
+
+ # -----------------------------------------------------
+ # Create glyph mapping, this is what the font file need
+ # Keep glyphs array is sorted as Unicode
+ # ------------------------------------------------------
+
+ character_mapping = {} # code_point -> glyph_name
+ glyphs = []
+ for code_point, bitmap in sorted(bitmap_mapping.items()):
+ if code_point == -1:
+ glyph_name = '.notdef'
+ c = '.notdef'
+ else:
+ glyph_name = f'u{code_point:04X}'
+ c = chr(code_point)
+ character_mapping[code_point] = glyph_name
+
+ glyphs.append(Glyph(
+ name=glyph_name,
+ horizontal_offset=(0, -3),
+ advance_width=6, # Right 1px as char-gap
+ vertical_offset=(-3, 1),
+ advance_height=11, # Top 1px as line-gap
+ bitmap=bitmap.data,
+ ))
+
+ # Print and check if the bitmap is correct
+ print('--------------------\n')
+ print(f'glyph: {glyph_name}, char: {c}\n')
+ print(bitmap.draw(end='*'))
+
+ # ------------
+ # Fill in font
+ # ------------
+
+ builder = FontBuilder()
+ builder.font_metric.font_size = 11
+ builder.font_metric.horizontal_layout.ascent = 8 # Top 1px as line-gap
+ builder.font_metric.horizontal_layout.descent = -3
+ builder.font_metric.vertical_layout.ascent = 3
+ builder.font_metric.vertical_layout.descent = -3
+ builder.font_metric.x_height = 5
+ builder.font_metric.cap_height = 7
+ builder.font_metric.underline_position = -3
+ builder.font_metric.underline_thickness = 1
+ builder.font_metric.strikeout_position = 3
+ builder.font_metric.strikeout_thickness = 1
+
+ builder.meta_info.version = '0.0.0' # TODO <-- modify this each release
+ builder.meta_info.created_time = datetime.fromisoformat('2025-06-12T00:00:00Z') # TODO <-- modify this each release
+ builder.meta_info.modified_time = builder.meta_info.created_time
+ builder.meta_info.family_name = 'Bitroot'
+ builder.meta_info.weight_name = WeightName.REGULAR
+ builder.meta_info.serif_style = SerifStyle.SERIF
+ builder.meta_info.slant_style = SlantStyle.NORMAL
+ builder.meta_info.width_style = WidthStyle.MONOSPACED
+ builder.meta_info.manufacturer = 'Rio'
+ builder.meta_info.designer = 'Rio'
+ builder.meta_info.description = 'Sweet fonts to leave your worries behind you'
+ builder.meta_info.copyright_info = 'Copyright (c) 2025, Rio (engineer@disroot.org)'
+ builder.meta_info.license_info = 'SIL Open Font License 1.1'
+ builder.meta_info.vendor_url = 'https://github.com/serialexperimentsrio/bitroot'
+ builder.meta_info.designer_url = 'https://github.com/serialexperimentsrio'
+ builder.meta_info.license_url = 'https://github.com/serialexperimentsrio/bitroot/blob/main/LICENSE.txt'
+ builder.meta_info.sample_text = 'Jackfruit groves with zinc-fed topsoil maximize bounty'
+
+ builder.character_mapping.update(character_mapping)
+ builder.glyphs.extend(glyphs)
+
+ # ------------------
+ # Clean 'build' dir
+ # ------------------
+
+ if path_define.build_dir.exists():
+ shutil.rmtree(path_define.build_dir)
+ path_define.outputs_dir.mkdir(parents=True)
+ path_define.releases_dir.mkdir(parents=True)
+
+ # ------------------
+ # Build normal fonts
+ # ------------------
+
+ builder.save_otf(path_define.outputs_dir.joinpath('Bitroot.otf'))
+ builder.save_otf(path_define.outputs_dir.joinpath('Bitroot.otf.woff'), flavor=opentype.Flavor.WOFF)
+ builder.save_otf(path_define.outputs_dir.joinpath('Bitroot.otf.woff2'), flavor=opentype.Flavor.WOFF2)
+ builder.save_ttf(path_define.outputs_dir.joinpath('Bitroot.ttf'))
+ builder.save_ttf(path_define.outputs_dir.joinpath('Bitroot.ttf.woff'), flavor=opentype.Flavor.WOFF)
+ builder.save_ttf(path_define.outputs_dir.joinpath('Bitroot.ttf.woff2'), flavor=opentype.Flavor.WOFF2)
+ builder.save_bdf(path_define.outputs_dir.joinpath('Bitroot.bdf'))
+ builder.save_pcf(path_define.outputs_dir.joinpath('Bitroot.pcf'))
+
+ # ----------------------
+ # Build square dot fonts
+ # ----------------------
+
+ builder.meta_info.family_name = 'Bitroot SquareDot'
+ builder.opentype_config.outlines_painter = opentype.SquareDotOutlinesPainter()
+ builder.save_otf(path_define.outputs_dir.joinpath('Bitroot-SquareDot.otf'))
+ builder.save_otf(path_define.outputs_dir.joinpath('Bitroot-SquareDot.otf.woff'), flavor=opentype.Flavor.WOFF)
+ builder.save_otf(path_define.outputs_dir.joinpath('Bitroot-SquareDot.otf.woff2'), flavor=opentype.Flavor.WOFF2)
+ builder.save_ttf(path_define.outputs_dir.joinpath('Bitroot-SquareDot.ttf'))
+ builder.save_ttf(path_define.outputs_dir.joinpath('Bitroot-SquareDot.ttf.woff'), flavor=opentype.Flavor.WOFF)
+ builder.save_ttf(path_define.outputs_dir.joinpath('Bitroot-SquareDot.ttf.woff2'), flavor=opentype.Flavor.WOFF2)
+
+ # ----------------------
+ # Build circle dot fonts
+ # ----------------------
+
+ builder.meta_info.family_name = 'Bitroot CircleDot'
+ builder.opentype_config.outlines_painter = opentype.CircleDotOutlinesPainter()
+ builder.save_otf(path_define.outputs_dir.joinpath('Bitroot-CircleDot.otf'))
+ builder.save_otf(path_define.outputs_dir.joinpath('Bitroot-CircleDot.otf.woff'), flavor=opentype.Flavor.WOFF)
+ builder.save_otf(path_define.outputs_dir.joinpath('Bitroot-CircleDot.otf.woff2'), flavor=opentype.Flavor.WOFF2)
+ builder.save_ttf(path_define.outputs_dir.joinpath('Bitroot-CircleDot.ttf'))
+ builder.save_ttf(path_define.outputs_dir.joinpath('Bitroot-CircleDot.ttf.woff'), flavor=opentype.Flavor.WOFF)
+ builder.save_ttf(path_define.outputs_dir.joinpath('Bitroot-CircleDot.ttf.woff2'), flavor=opentype.Flavor.WOFF2)
+
+ # -------------------
+ # Pack release '.zip'
+ # -------------------
+
+ with zipfile.ZipFile(path_define.releases_dir.joinpath(f'bitroot-v{builder.meta_info.version}.zip'), 'w') as file:
+ file.write(path_define.project_root_dir.joinpath('LICENSE.txt'), 'LICENSE.txt')
+ for font_file_path in path_define.outputs_dir.iterdir():
+ if font_file_path.suffix in ('.otf', '.ttf', '.woff', '.woff2', '.bdf', '.pcf'):
+ file.write(font_file_path, font_file_path.name)
+
+ # -------------------------------------
+ # Copy '.otf.woff2' to 'www/fonts' dir
+ # -------------------------------------
+
+ if path_define.www_fonts_dir.exists():
+ shutil.rmtree(path_define.www_fonts_dir)
+ path_define.www_fonts_dir.mkdir(parents=True)
+
+ for font_file_path in path_define.outputs_dir.iterdir():
+ if font_file_path.name.endswith('.otf.woff2'):
+ shutil.copyfile(font_file_path, path_define.www_fonts_dir.joinpath(font_file_path.name))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/path_define.py b/tools/path_define.py
new file mode 100644
index 0000000..a6773a4
--- /dev/null
+++ b/tools/path_define.py
@@ -0,0 +1,12 @@
+from pathlib import Path
+
+project_root_dir = Path(__file__).parent.joinpath('..').resolve()
+
+src_dir = project_root_dir.joinpath('src')
+
+build_dir = project_root_dir.joinpath('build')
+outputs_dir = build_dir.joinpath('outputs')
+releases_dir = build_dir.joinpath('releases')
+
+www_dir = project_root_dir.joinpath('www')
+www_fonts_dir = www_dir.joinpath('fonts')
diff --git a/www/css/index.css b/www/css/index.css
new file mode 100644
index 0000000..e7b158e
--- /dev/null
+++ b/www/css/index.css
@@ -0,0 +1,65 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+@font-face {
+ font-family: Bitroot;
+ src: url("../fonts/Bitroot.otf.woff2");
+}
+
+@font-face {
+ font-family: Bitroot-SquareDot;
+ src: url("../fonts/Bitroot-SquareDot.otf.woff2");
+}
+
+@font-face {
+ font-family: Bitroot-CircleDot;
+ src: url("../fonts/Bitroot-CircleDot.otf.woff2");
+}
+
+body {
+ font-family: sans-serif;
+}
+
+.header {
+ .title {
+ margin-top: 16px;
+ margin-bottom: 16px;
+ text-align: center;
+ font-family: Bitroot, sans-serif;
+ font-size: 66px;
+ font-weight: normal;
+ }
+}
+
+.main {
+ padding: 16px;
+
+ .options {
+ margin-bottom: 16px;
+ text-align: center;
+
+ .option {
+ margin-left: 12px;
+ margin-right: 12px;
+ font-size: 16px;
+ font-weight: bold;
+ }
+ }
+
+ .input-box {
+ width: 100%;
+ height: 400px;
+ padding: 16px;
+ resize: vertical;
+ font-family: Bitroot, sans-serif;
+ font-size: 44px;
+ }
+}
+
+.footer {
+ text-align: center;
+ font-size: 16px;
+}
diff --git a/www/index.html b/www/index.html
new file mode 100644
index 0000000..f51cb13
--- /dev/null
+++ b/www/index.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+ Bitroot
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/www/js/index.js b/www/js/index.js
new file mode 100644
index 0000000..a09abf7
--- /dev/null
+++ b/www/js/index.js
@@ -0,0 +1,13 @@
+
+window.onFontStyleChange = fontStyle => {
+ let fontFamily
+ if (fontStyle === 'SquareDot') {
+ fontFamily = 'Bitroot-SquareDot, sans-serif'
+ } else if (fontStyle === 'CircleDot') {
+ fontFamily = 'Bitroot-CircleDot, sans-serif'
+ } else {
+ fontFamily = 'Bitroot, sans-serif'
+ }
+ document.getElementById('title').style.fontFamily = fontFamily
+ document.getElementById('input-box').style.fontFamily = fontFamily
+}