feat(fonts): add dynamic font selection and font manager improvements (#232)

* feat(fonts): add dynamic font selection and font manager improvements

- Add font-selector widget for dynamic font selection in plugin configs
- Enhance /api/v3/fonts/catalog with filename, display_name, and type
- Add /api/v3/fonts/preview endpoint for server-side font rendering
- Add /api/v3/fonts/<family> DELETE endpoint with system font protection
- Fix /api/v3/fonts/upload to actually save uploaded font files
- Update font manager tab with dynamic dropdowns, server-side preview, and font deletion
- Add new BDF fonts: 6x10, 6x12, 6x13, 7x13, 7x14, 8x13, 9x15, 9x18, 10x20 (with bold/oblique variants)
- Add tom-thumb, helvR12, clR6x12, texgyre-27 fonts

Plugin authors can use x-widget: "font-selector" in schemas to enable
dynamic font selection that automatically shows all available fonts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): security fixes and code quality improvements

- Fix README.md typos and add language tags to code fences
- Remove duplicate delete_font function causing Flask endpoint collision
- Add safe integer parsing for size parameter in preview endpoint
- Fix path traversal vulnerability in /fonts/preview endpoint
- Fix path traversal vulnerability in /fonts/<family> DELETE endpoint
- Fix XSS vulnerability in fonts.html by using DOM APIs instead of innerHTML
- Move baseUrl to shared scope to fix ReferenceError in multiple functions

Security improvements:
- Validate font filenames reject path separators and '..'
- Validate paths are within fonts_dir before file operations
- Use textContent and data attributes instead of inline onclick handlers
- Restrict file extensions to known font types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): address code issues and XSS vulnerabilities

- Move `import re` to module level, remove inline imports
- Remove duplicate font_file assignment in upload_font()
- Remove redundant validation with inconsistent allowed extensions
- Remove redundant PathLib import, use already-imported Path
- Fix XSS vulnerabilities in fonts.html by using DOM APIs instead of
  innerHTML with template literals for user-controlled data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): add size limits to font preview endpoint

Add input validation to prevent DoS via large image generation:
- MAX_TEXT_CHARS (100): Limit text input length
- MAX_TEXT_LINES (3): Limit number of newlines
- MAX_DIM (1024): Limit max width/height
- MAX_PIXELS (500000): Limit total pixel count

Validates text early before processing and checks computed
dimensions after bbox calculation but before image allocation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): improve error handling, catalog keys, and BDF preview

- Add structured logging for cache invalidation failures instead of
  silent pass (FontUpload, FontDelete, FontCatalog contexts)
- Use filename as unique catalog key to prevent collisions when
  multiple font files share the same family_name from metadata
- Return explicit error for BDF font preview instead of showing
  misleading preview with default font

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): address nitpick issues in font management

Frontend (fonts.html):
- Remove unused escapeHtml function (dead code)
- Add max-attempts guard (50 retries) to initialization loop
- Add response.ok checks before JSON parsing in deleteFont,
  addFontOverride, deleteFontOverride, uploadSelectedFonts
- Use is_system flag from API instead of hardcoded client-side list

Backend (api_v3.py):
- Move SYSTEM_FONTS to module-level frozenset for single source of truth
- Add is_system flag to font catalog entries
- Simplify delete_font system font check using frozenset lookup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): align frontend upload validation with backend

- Add .otf to accepted file extensions (HTML accept attribute, JS filter)
- Update validation regex to allow hyphens (matching backend)
- Preserve hyphens in auto-generated font family names
- Update UI text to reflect all supported formats

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): fix lint errors and missing variable

- Remove unused exception binding in set_cached except block
- Define font_family_lower before case-insensitive fallback loop
- Add response.ok check to font preview fetch (consistent with other handlers)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): address nitpick code quality issues

- Add return type hints to get_font_preview and delete_font endpoints
- Catch specific PIL exceptions (IOError/OSError) when loading fonts
- Replace innerHTML with DOM APIs for trash icon (consistency)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): remove unused exception bindings in cache-clearing blocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-02-11 18:21:27 -05:00
committed by GitHub
parent b99be88cec
commit 448a15c1e6
30 changed files with 1051336 additions and 95 deletions

140567
assets/fonts/10x20.bdf Normal file

File diff suppressed because it is too large Load Diff

31042
assets/fonts/6x10.bdf Normal file

File diff suppressed because it is too large Load Diff

86121
assets/fonts/6x12.bdf Normal file

File diff suppressed because it is too large Load Diff

82452
assets/fonts/6x13.bdf Normal file

File diff suppressed because it is too large Load Diff

25672
assets/fonts/6x13B.bdf Normal file

File diff suppressed because it is too large Load Diff

15432
assets/fonts/6x13O.bdf Normal file

File diff suppressed because it is too large Load Diff

64553
assets/fonts/7x13.bdf Normal file

File diff suppressed because it is too large Load Diff

20093
assets/fonts/7x13B.bdf Normal file

File diff suppressed because it is too large Load Diff

16653
assets/fonts/7x13O.bdf Normal file

File diff suppressed because it is too large Load Diff

54128
assets/fonts/7x14.bdf Normal file

File diff suppressed because it is too large Load Diff

21221
assets/fonts/7x14B.bdf Normal file

File diff suppressed because it is too large Load Diff

74092
assets/fonts/8x13.bdf Normal file

File diff suppressed because it is too large Load Diff

22852
assets/fonts/8x13B.bdf Normal file

File diff suppressed because it is too large Load Diff

25932
assets/fonts/8x13O.bdf Normal file

File diff suppressed because it is too large Load Diff

105126
assets/fonts/9x15.bdf Normal file

File diff suppressed because it is too large Load Diff

37168
assets/fonts/9x15B.bdf Normal file

File diff suppressed because it is too large Load Diff

119182
assets/fonts/9x18.bdf Normal file

File diff suppressed because it is too large Load Diff

19082
assets/fonts/9x18B.bdf Normal file

File diff suppressed because it is too large Load Diff

42
assets/fonts/AUTHORS Normal file
View File

@@ -0,0 +1,42 @@
The identity of the designer(s) of the original ASCII repertoire and
the later Latin-1 extension of the misc-fixed BDF fonts appears to
have been lost in history. (It is likely that many of these 7-bit
ASCII fonts were created in the early or mid 1980s as part of MIT's
Project Athena, or at its industrial partner, DEC.)
In 1997, Markus Kuhn at the University of Cambridge Computer
Laboratory initiated and headed a project to extend the misc-fixed BDF
fonts to as large a subset of Unicode/ISO 10646 as is feasible for
each of the available font sizes, as part of a wider effort to
encourage users of POSIX systems to migrate from ISO 8859 to UTF-8.
Robert Brady <rwb197@ecs.soton.ac.uk> and Birger Langkjer
<birger.langkjer@image.dk> contributed thousands of glyphs and made
very substantial contributions and improvements on almost all fonts.
Constantine Stathopoulos <cstath@irismedia.gr> contributed all the
Greek characters. Markus Kuhn <http://www.cl.cam.ac.uk/~mgk25/> did
most 6x13 glyphs and the italic fonts and provided many more glyphs,
coordination, and quality assurance for the other fonts. Mark Leisher
<mleisher@crl.nmsu.edu> contributed to 6x13 Armenian, Georgian, the
first version of Latin Extended Block A and some Cyrillic. Serge V.
Vakulenko <vak@crox.net.kiae.su> donated the original Cyrillic glyphs
from his 6x13 ISO 8859-5 font. Nozomi Ytow <nozomi@biol.tsukuba.ac.jp>
contributed 6x13 halfwidth Katakana. Henning Brunzel
<hbrunzel@meta-systems.de> contributed glyphs to 10x20.bdf. Theppitak
Karoonboonyanan <thep@linux.thai.net> contributed Thai for 7x13,
7x13B, 7x13O, 7x14, 7x14B, 8x13, 8x13B, 8x13O, 9x15, 9x15B, and 10x20.
Karl Koehler <koehler@or.uni-bonn.de> contributed Arabic to 9x15,
9x15B, and 10x20 and Roozbeh Pournader <roozbeh@sharif.ac.ir> and
Behdad Esfahbod revised and extended Arabic in 10x20. Raphael Finkel
<raphael@cs.uky.edu> revised Hebrew/Yiddish in 10x20. Jungshik Shin
<jshin@pantheon.yale.edu> prepared 18x18ko.bdf. Won-kyu Park
<wkpark@chem.skku.ac.kr> prepared the Hangul glyphs used in 12x13ja.
Janne V. Kujala <jvk@iki.fi> contributed 4x6. Daniel Yacob
<perl@geez.org> revised some Ethiopic glyphs. Ted Zlatanov
<tzz@lifelogs.com> did some 7x14. Mikael Öhman <micketeer@gmail.com>
worked on 6x12.
The fonts are still maintained by Markus Kuhn and the original
distribution can be found at:
http://www.cl.cam.ac.uk/~mgk25/ucs-fonts.html

369
assets/fonts/README Normal file
View File

@@ -0,0 +1,369 @@
Unicode versions of the X11 "misc-fixed-*" fonts
------------------------------------------------
Markus Kuhn <http://www.cl.cam.ac.uk/~mgk25/> -- 2008-04-21
This package contains the X Window System bitmap fonts
-Misc-Fixed-*-*-*--*-*-*-*-C-*-ISO10646-1
These are Unicode (ISO 10646-1) extensions of the classic ISO 8859-1
X11 terminal fonts that are widely used with many X11 applications
such as xterm, emacs, etc.
COVERAGE
--------
None of these fonts covers Unicode completely. Complete coverage
simply would not make much sense here. Unicode 5.1 contains over
100000 characters, and the large majority of them are
Chinese/Japanese/Korean Han ideographs (~70000) and Korean Hangul
Syllables (~11000) that cannot adequately be displayed in the small
pixel sizes of the fixed fonts. Similarly, Arabic characters are
difficult to fit nicely together with European characters into the
fixed character cells and X11 lacks the ligature substitution
mechanisms required for using Indic scripts.
Therefore these fonts primarily attempt to cover Unicode subsets that
fit together with European scripts. This includes the Latin, Greek,
Cyrillic, Armenian, Georgian, and Hebrew scripts, plus a lot of
linguistic, technical and mathematical symbols. Some of the fixed
fonts now also cover Arabic, Thai, Ethiopian, halfwidth Katakana, and
some other non-European scripts.
We have defined 3 different target character repertoires (ISO 10646-1
subsets) that the various fonts were checked against for minimal
guaranteed coverage:
TARGET1 617 characters
Covers all characters of ISO 8859 part 1-5,7-10,13-16,
CEN MES-1, ISO 6937, Microsoft CP1251/CP1252, DEC VT100
graphics symbols, and the replacement and default
character. It is intended for small bold, italic, and
proportional fonts, for which adding block graphics
characters would make little sense. This repertoire
covers the following ISO 10646-1:2000 collections
completely: 1-3, 8, 12.
TARGET2 886 characters
Adds to TARGET1 the characters of the Adobe/Microsoft
Windows Glyph List 4 (WGL4), plus a selected set of
mathematical characters (covering most of ISO 31-11
high-school level math symbols) and some combining
characters. It is intended to be covered by all normal
"fixed" fonts and covers all European IBM, Microsoft, and
Macintosh character sets. This repertoire covers the
following ISO 10646-1:2000 (including Amd 1:2002)
collections completely: 1-3, 8, 12, 33, 45.
TARGET3 3282 characters
Adds to TARGET2 all characters of all European scripts
(Latin, Greek, Cyrillic, Armenian, Georgian), all
phonetic alphabet symbols, many mathematical symbols
(including all those available in LaTeX), all typographic
punctuation, all box-drawing characters, control code
pictures, graphical shapes and some more that you would
expect in a very comprehensive Unicode 4.0 font for
European users. It is intended for some of the more
useful and more widely used normal "fixed" fonts. This
repertoire is, with two exceptions, a superset of all
graphical characters in CEN MES-3A and covers the
following ISO 10646-1:2000 (including Amd 1:2002)
collections completely: 1-12, 27, 30-31, 32 (only
graphical characters), 33-42, 44-47, 63, 65, 70 (only
graphical characters).
[The two MES-3A characters deliberately omitted are the
angle bracket characters U+2329 and U+232A. ISO and CEN
appears to have included these into collection 40 and
MES-3A by accident, because there they are the only
characters in the Unicode EastAsianWidth "wide" class.]
CURRENT STATUS:
6x13.bdf 8x13.bdf 9x15.bdf 9x18.bdf 10x20.bdf:
Complete (TARGET3 reached and checked)
5x7.bdf 5x8.bdf 6x9.bdf 6x10.bdf 6x12.bdf 7x13.bdf 7x14.bdf clR6x12.bdf:
Complete (TARGET2 reached and checked)
6x13B.bdf 7x13B.bdf 7x14B.bdf 8x13B.bdf 9x15B.bdf 9x18B.bdf:
Complete (TARGET1 reached and checked)
6x13O.bdf 7x13O.bdf 8x13O.bdf
Complete (TARGET1 minus Hebrew and block graphics)
[None of the above fonts contains any character that has in Unicode
the East Asian Width Property "W" or "F" assigned. This way, the
desired combination of "half-width" and "full-width" glyphs can be
achieved easily. Most font mechanisms display a character that is not
covered in a font by using a glyph from another font that appears
later in a priority list, which can be arranged to be a "full-width"
font.]
The supplement package
http://www.cl.cam.ac.uk/~mgk25/download/ucs-fonts-asian.tar.gz
contains the following additional square fonts with Han characters for
East Asian users:
12x13ja.bdf:
Covers TARGET2, JIS X 0208, Hangul, and a few more. This font is
primarily intended to provide Japanese full-width Hiragana,
Katakana, and Kanji for applications that take the remaining
("halfwidth") characters from 6x13.bdf. The Greek lowercase
characters in it are still a bit ugly and will need some work.
18x18ja.bdf:
Covers all JIS X 0208, JIS X 0212, GB 2312-80, KS X 1001:1992,
ISO 8859-1,2,3,4,5,7,9,10,15, CP437, CP850 and CP1252 characters,
plus a few more, where priority was given to Japanese han style
variants. This font should have everything needed to cover the
full ISO-2022-JP-2 (RFC 1554) repertoire. This font is primarily
intended to provide Japanese full-width Hiragana, Katakana, and
Kanji for applications that take the remaining ("halfwidth")
characters from 9x18.bdf.
18x18ko.bdf:
Covers the same repertoire as 18x18ja plus full coverage of all
Hangul syllables and priority was given to Hanja glyphs in the
unified CJK area as they are used for writing Korean.
The 9x18 and 6x12 fonts are recommended for use with overstriking
combining characters.
Bug reports, suggestions for improvement, and especially contributed
extensions are very welcome!
INSTALLATION
------------
You install the fonts under Unix roughly like this (details depending
on your system of course):
System-wide installation (root access required):
cd submission/
make
su
mv -b *.pcf.gz /usr/lib/X11/fonts/misc/
cd /usr/lib/X11/fonts/misc/
mkfontdir
xset fp rehash
Alternative: Installation in your private user directory:
cd submission/
make
mkdir -p ~/local/lib/X11/fonts/
mv *.pcf.gz ~/local/lib/X11/fonts/
cd ~/local/lib/X11/fonts/
mkfontdir
xset +fp ~/local/lib/X11/fonts (put this last line also in ~/.xinitrc)
Now you can have a look at say the 6x13 font with the command
xfd -fn '-misc-fixed-medium-r-semicondensed--13-120-75-75-c-60-iso10646-1'
If you want to have short names for the Unicode fonts, you can also
append the fonts.alias file to that in the directory where you install
the fonts, call "mkfontdir" and "xset fp rehash" again, and then you
can also write
xfd -fn 6x13U
Note: If you use an old version of xfontsel, you might notice that it
treats every font that contains characters >0x00ff as a Japanese JIS
font and therefore selects inappropriate sample characters for display
of ISO 10646-1 fonts. An updated xfontsel version with this bug fixed
comes with XFree86 4.0 / X11R6.8 or newer.
If you use the Exceed X server on Microsoft Windows, then you will
have to convert the BDF files into Microsoft FON files using the
"Compile Fonts" function of Exceed xconfig. See the file exceed.txt
for more information.
There is one significant efficiency problem that X11R6 has with the
sparsely populated ISO10646-1 fonts. X11 transmits and allocates 12
bytes with the XFontStruct data structure for the difference between
the lowest and the highest code value found in a font, no matter
whether the code positions in between are used for characters or not.
Even a tiny font that contains only two glyphs at positions 0x0000 and
0xfffd causes 12 bytes * 65534 codes = 786 kbytes to be requested and
stored by the client. Since all the ISO10646-1 BDF files provided in
this package contain characters in the U+00xx (ASCII) and U+ffxx
(ligatures, etc.) range, all of them would result in 786 kbyte large
XCharStruct arrays in the per_char array of the corresponding
XFontStruct (even for CharCell fonts!) when loaded by an X client.
Until this problem is fixed by extending the X11 font protocol and
implementation, non-CJK ISO10646-1 fonts that lack the (anyway not
very interesting) characters above U+31FF seem to be the best
compromise. The bdftruncate.pl program in this package can be used to
deactivate any glyphs above a threshold code value in BDF files. This
way, we get relatively memory-economic ISO10646-1 fonts that cause
"only" 150 kbyte large XCharStruct arrays to be allocated. The
deactivated glyphs are still present in the BDF files, but with an
encoding value of -1 that causes them to be ignored.
The ISO10646-1 fonts can not only be used directly by Unicode aware
software, they can also be used to create any 8-bit font. The
ucs2any.pl Perl script converts a ISO10646-1 BDF font into a BDF font
file with some different encoding. For instance the command
perl ucs2any.pl 6x13.bdf MAPPINGS/8859-7.TXT ISO8859-7
will generate the file 6x13-ISO8859-7.bdf according to the 8859-7.TXT
Latin/Greek mapping table, which available from
<ftp://ftp.unicode.org/Public/MAPPINGS/>. [The shell script
./map_fonts automatically generates a subdirectory derived-fonts/ with
many *.bdf and *.pcf.gz 8-bit versions of all the
-misc-fixed-*-iso10646-1 fonts.]
When you do a "make" in the submission/ subdirectory as suggested in
the installation instructions above, this will generate exactly the
set of fonts that have been submitted to the XFree86 project for
inclusion into XFree86 4.0. These consists of all the ISO10646-1 fonts
processed with "bdftruncate.pl U+3200" plus a selected set of derived
8-bit fonts generated with ucs2any.pl.
Every font comes with a *.repertoire-utf8 file that lists all the
characters in this font.
CONTRIBUTING
------------
If you want to help me in extending or improving the fonts, or if you
want to start your own ISO 10646-1 font project, you will have to edit
BDF font files. This is most comfortably done with the gbdfed font
editor (version 1.3 or higher), which is available from
http://crl.nmsu.edu/~mleisher/gbdfed.html
Once you are familiar with gbdfed, you will notice that it is no
problem to design up to 100 nice characters per hour (even more if
only placing accents is involved).
Information about other X11 font tools and Unicode fonts for X11 in
general can be found on
http://www.cl.cam.ac.uk/~mgk25/ucs-fonts.html
The latest version of this package is available from
http://www.cl.cam.ac.uk/~mgk25/download/ucs-fonts.tar.gz
If you want to contribute, then get the very latest version of this
package, check which glyphs are still missing or inappropriate for
your needs, and send me whatever you had the time to add and fix. Just
email me the extended BDF-files back, or even better, send me a patch
file of what you changed. The best way of preparing a patch file is
./touch_id newfile.bdf
diff -d -u -F STARTCHAR oldfile.bdf newfile.bdf >file.diff
which ensures that the patch file preserves information about which
exact version you worked on and what character each "hunk" changes.
I will try to update this packet on a daily basis. By sending me
extensions to these fonts, you agree that the resulting improved font
files will remain in the public domain for everyone's free use. Always
make sure to load the very latest version of the package immediately
before your start, and send me your results as soon as you are done,
in order to avoid revision overlaps with other contributors.
Please try to be careful with the glyphs you generate:
- Always look first at existing similar characters in order to
preserve a consistent look and feel for the entire font and
within the font family. For block graphics characters and geometric
symbols, take care of correct alignment.
- Read issues.txt, which contains some design hints for certain
characters.
- All characters of CharCell (C) fonts must strictly fit into
the pixel matrix and absolutely no out-of-box ink is allowed.
- The character cells will be displayed directly next to each other,
without any additional pixels in between. Therefore, always make
sure that at least the rightmost pixel column remains white, as
otherwise letters will stick together, except of course for
characters -- like Arabic or block graphics -- that are supposed to
stick together.
- Place accents as low as possible on the Latin characters.
- Try to keep the shape of accents consistent among each other and
with the combining characters in the U+03xx range.
- Use gbdfed only to edit the BDF file directly and do not import
the font that you want to edit from the X server. Use gbdfed 1.3
or higher.
- The glyph names should be the Adobe names for Unicode characters
defined at
http://www.adobe.com/devnet/opentype/archives/glyph.html
which gbdfed can set automatically. To make the Edit/Rename Glyphs/
Adobe Names function work, you have to download the file
http://www.adobe.com/devnet/opentype/archives/glyphlist.txt
and configure its location either in Edit/Preferences/Editing Options/
Adobe Glyph List, or as "adobe_name_file" in "~/.gbdfed".
- Be careful to not change the FONTBOUNDINGBOX box accidentally in
a patch.
You should have a copy of the ISO 10646 standard
ISO/IEC 10646:2003, Information technology -- Universal
Multiple-Octet Coded Character Set (UCS),
International Organization for Standardization, Geneva, 2003.
http://standards.iso.org/ittf/PubliclyAvailableStandards/
and/or the Unicode 5.0 book:
The Unicode Consortium: The Unicode Standard, Version 5.0,
Reading, MA, Addison-Wesley, 2006,
ISBN 9780321480910.
http://www.amazon.com/exec/obidos/ASIN/0321480910/mgk25
All these fonts are from time to time resubmitted to the X.Org
project, XFree86 (they have been in there since XFree86 4.0), and to
other X server developers for inclusion into their normal X11
distributions.
Starting with XFree86 4.0, xterm has included UTF-8 support. This
version is also available from
http://dickey.his.com/xterm/xterm.html
Please make the developer of your favourite software aware of the
UTF-8 definition in RFC 2279 and of the existence of this font
collection. For more information on how to use UTF-8, please check out
http://www.cl.cam.ac.uk/~mgk25/unicode.html
ftp://ftp.ilog.fr/pub/Users/haible/utf8/Unicode-HOWTO.html
where you will also find information on joining the
linux-utf8@nl.linux.org mailing list.
A number of UTF-8 example text files can be found in the examples/
subdirectory or on
http://www.cl.cam.ac.uk/~mgk25/ucs/examples/

72
assets/fonts/README.md Normal file
View File

@@ -0,0 +1,72 @@
## Provided fonts
These are BDF fonts, a simple bitmap font-format that can be created
by many font tools. Given that these are bitmap fonts, they will look good on
very low resolution screens such as the LED displays.
Fonts in this directory (except tom-thumb.bdf) are public domain (see the [README](./README)) and
help you to get started with the font support in the API or the `text-util`
from the utils/ directory.
Tom-Thumb.bdf is included in this directory under [MIT license](http://vt100.tarunz.org/LICENSE). Tom-thumb.bdf was created by [@robey](http://twitter.com/robey) and originally published at https://robey.lag.net/2010/01/23/tiny-monospace-font.html
The texgyre-27.bdf font was created using the [otf2bdf] tool from the TeX Gyre font.
```bash
otf2bdf -v -o texgyre-27.bdf -r 72 -p 27 texgyreadventor-regular.otf
```
## Create your own
Fonts are in a human-readable and editable `*.bdf` format, but unless you
like reading and writing pixels in hex, generating them is probably easier :)
You can use any font-editor to generate a BDF font or use the conversion
tool [otf2bdf] to create one from some other font format.
Here is an example how you could create a 30-pixel high BDF font from some
TrueType font:
```bash
otf2bdf -v -o myfont.bdf -r 72 -p 30 /path/to/font-Bold.ttf
```
## Getting otf2bdf
Installing the tool should be fairly straightforward.
```bash
sudo apt-get install otf2bdf
```
## Compiling otf2bdf
If you like to compile otf2bdf, you might notice that the configure script
uses some old way of getting the freetype configuration. There does not seem
to be much activity on the mature code, so let's patch that first:
```bash
sudo apt-get install -y libfreetype6-dev pkg-config autoconf
git clone https://github.com/jirutka/otf2bdf.git # check it out
cd otf2bdf
patch -p1 <<"EOF"
--- a/configure.in
+++ b/configure.in
@@ -5,8 +5,8 @@ AC_INIT(otf2bdf.c)
AC_PROG_CC
OLDLIBS=$LIBS
-LIBS="$LIBS `freetype-config --libs`"
-CPPFLAGS="$CPPFLAGS `freetype-config --cflags`"
+LIBS="$LIBS `pkg-config freetype2 --libs`"
+CPPFLAGS="$CPPFLAGS `pkg-config freetype2 --cflags`"
AC_CHECK_LIB(freetype, FT_Init_FreeType, LIBS="$LIBS -lfreetype",[
AC_MSG_ERROR([Can't find Freetype library! Compile FreeType first.])])
AC_SUBST(LIBS)
EOF
autoconf # rebuild configure script
./configure # run configure
make # build the software
sudo make install # install it
```
[otf2bdf]: https://github.com/jirutka/otf2bdf

22736
assets/fonts/clR6x12.bdf Normal file

File diff suppressed because it is too large Load Diff

32869
assets/fonts/helvR12.bdf Normal file

File diff suppressed because it is too large Load Diff

30577
assets/fonts/texgyre-27.bdf Normal file

File diff suppressed because it is too large Load Diff

2365
assets/fonts/tom-thumb.bdf Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
from flask import Blueprint, request, jsonify, Response, send_from_directory
import json
import os
import re
import sys
import subprocess
import time
@@ -37,6 +38,20 @@ operation_history = None
# Get project root directory (web_interface/../..)
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
# System fonts that cannot be deleted (used by catalog API and delete endpoint)
SYSTEM_FONTS = frozenset([
'pressstart2p-regular', 'pressstart2p',
'4x6-font', '4x6',
'5by7.regular', '5by7', '5x7',
'5x8', '6x9', '6x10', '6x12', '6x13', '6x13b', '6x13o',
'7x13', '7x13b', '7x13o', '7x14', '7x14b',
'8x13', '8x13b', '8x13o',
'9x15', '9x15b', '9x18', '9x18b',
'10x20',
'matrixchunky8', 'matrixlight6', 'tom-thumb',
'clr6x12', 'helvr12', 'texgyre-27'
])
api_v3 = Blueprint('api_v3', __name__)
def _ensure_cache_manager():
@@ -5388,9 +5403,31 @@ def get_fonts_catalog():
# Store relative path from project root
relative_path = str(filepath.relative_to(PROJECT_ROOT))
catalog[family_name] = {
font_type = 'ttf' if filename.endswith('.ttf') else 'otf' if filename.endswith('.otf') else 'bdf'
# Generate human-readable display name from family_name
display_name = family_name.replace('-', ' ').replace('_', ' ')
# Add space before capital letters for camelCase names
display_name = re.sub(r'([a-z])([A-Z])', r'\1 \2', display_name)
# Add space before numbers that follow letters
display_name = re.sub(r'([a-zA-Z])(\d)', r'\1 \2', display_name)
# Clean up multiple spaces
display_name = ' '.join(display_name.split())
# Use filename (without extension) as unique key to avoid collisions
# when multiple files share the same family_name from font metadata
catalog_key = os.path.splitext(filename)[0]
# Check if this is a system font (cannot be deleted)
is_system = catalog_key.lower() in SYSTEM_FONTS
catalog[catalog_key] = {
'filename': filename,
'family_name': family_name,
'display_name': display_name,
'path': relative_path,
'type': 'ttf' if filename.endswith('.ttf') else 'otf' if filename.endswith('.otf') else 'bdf',
'type': font_type,
'is_system': is_system,
'metadata': metadata if metadata else None
}
@@ -5399,7 +5436,7 @@ def get_fonts_catalog():
try:
set_cached('fonts_catalog', catalog, ttl_seconds=300)
except Exception:
pass # Cache write failed, but continue
logger.error("[FontCatalog] Failed to cache fonts_catalog", exc_info=True)
return jsonify({'status': 'success', 'data': {'catalog': catalog}})
except Exception as e:
@@ -5476,28 +5513,282 @@ def upload_font():
if not is_valid:
return jsonify({'status': 'error', 'message': error_msg}), 400
font_file = request.files['font_file']
font_family = request.form.get('font_family', '')
if not font_file or not font_family:
if not font_family:
return jsonify({'status': 'error', 'message': 'Font file and family name required'}), 400
# Validate file type
allowed_extensions = ['.ttf', '.bdf']
file_extension = font_file.filename.lower().split('.')[-1]
if f'.{file_extension}' not in allowed_extensions:
return jsonify({'status': 'error', 'message': 'Only .ttf and .bdf files are allowed'}), 400
# Validate font family name
if not font_family.replace('_', '').replace('-', '').isalnum():
return jsonify({'status': 'error', 'message': 'Font family name must contain only letters, numbers, underscores, and hyphens'}), 400
# This would integrate with the actual font system to save the file
# For now, just return success
return jsonify({'status': 'success', 'message': f'Font {font_family} uploaded successfully', 'font_family': font_family})
# Save the font file to assets/fonts directory
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True)
# Create filename from family name
original_ext = os.path.splitext(font_file.filename)[1].lower()
safe_filename = f"{font_family}{original_ext}"
filepath = fonts_dir / safe_filename
# Check if file already exists
if filepath.exists():
return jsonify({'status': 'error', 'message': f'Font with name {font_family} already exists'}), 400
# Save the file
font_file.save(str(filepath))
# Clear font catalog cache
try:
from web_interface.cache import delete_cached
delete_cached('fonts_catalog')
except ImportError as e:
logger.warning("[FontUpload] Cache module not available: %s", e)
except Exception:
logger.error("[FontUpload] Failed to clear fonts_catalog cache", exc_info=True)
return jsonify({
'status': 'success',
'message': f'Font {font_family} uploaded successfully',
'font_family': font_family,
'filename': safe_filename,
'path': f'assets/fonts/{safe_filename}'
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
@api_v3.route('/fonts/preview', methods=['GET'])
def get_font_preview() -> tuple[Response, int] | Response:
"""Generate a preview image of text rendered with a specific font"""
try:
from PIL import Image, ImageDraw, ImageFont
import io
import base64
# Limits to prevent DoS via large image generation on constrained devices
MAX_TEXT_CHARS = 100
MAX_TEXT_LINES = 3
MAX_DIM = 1024 # Max width or height in pixels
MAX_PIXELS = 500000 # Max total pixels (e.g., ~700x700)
font_filename = request.args.get('font', '')
text = request.args.get('text', 'Sample Text 123')
bg_color = request.args.get('bg', '000000')
fg_color = request.args.get('fg', 'ffffff')
# Validate text length and line count early
if len(text) > MAX_TEXT_CHARS:
return jsonify({'status': 'error', 'message': f'Text exceeds maximum length of {MAX_TEXT_CHARS} characters'}), 400
if text.count('\n') >= MAX_TEXT_LINES:
return jsonify({'status': 'error', 'message': f'Text exceeds maximum of {MAX_TEXT_LINES} lines'}), 400
# Safe integer parsing for size
try:
size = int(request.args.get('size', 12))
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid font size'}), 400
if not font_filename:
return jsonify({'status': 'error', 'message': 'Font filename required'}), 400
# Validate size
if size < 4 or size > 72:
return jsonify({'status': 'error', 'message': 'Font size must be between 4 and 72'}), 400
# Security: Validate font_filename to prevent path traversal
# Only allow alphanumeric, hyphen, underscore, and dot (for extension)
safe_name = Path(font_filename).name # Strip any directory components
if safe_name != font_filename or '..' in font_filename:
return jsonify({'status': 'error', 'message': 'Invalid font filename'}), 400
# Validate extension
allowed_extensions = ['.ttf', '.otf', '.bdf']
has_valid_ext = any(safe_name.lower().endswith(ext) for ext in allowed_extensions)
name_without_ext = safe_name.rsplit('.', 1)[0] if '.' in safe_name else safe_name
# Find the font file
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
if not fonts_dir.exists():
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
font_path = fonts_dir / safe_name
if not font_path.exists() and not has_valid_ext:
# Try finding by family name (without extension)
for ext in allowed_extensions:
potential_path = fonts_dir / f"{name_without_ext}{ext}"
if potential_path.exists():
font_path = potential_path
break
# Final security check: ensure path is within fonts_dir
try:
font_path.resolve().relative_to(fonts_dir.resolve())
except ValueError:
return jsonify({'status': 'error', 'message': 'Invalid font path'}), 400
if not font_path.exists():
return jsonify({'status': 'error', 'message': f'Font file not found: {font_filename}'}), 404
# Parse colors
try:
bg_rgb = tuple(int(bg_color[i:i+2], 16) for i in (0, 2, 4))
fg_rgb = tuple(int(fg_color[i:i+2], 16) for i in (0, 2, 4))
except (ValueError, IndexError):
bg_rgb = (0, 0, 0)
fg_rgb = (255, 255, 255)
# Load font
font = None
if str(font_path).endswith('.bdf'):
# BDF fonts require complex per-glyph rendering via freetype
# Return explicit error rather than showing misleading preview with default font
return jsonify({
'status': 'error',
'message': 'BDF font preview not supported. BDF fonts will render correctly on the LED matrix.'
}), 400
else:
# TTF/OTF fonts
try:
font = ImageFont.truetype(str(font_path), size)
except (IOError, OSError) as e:
# IOError/OSError raised for invalid/corrupt font files
logger.warning("[FontPreview] Failed to load font %s: %s", font_path, e)
font = ImageFont.load_default()
# Calculate text size
temp_img = Image.new('RGB', (1, 1))
temp_draw = ImageDraw.Draw(temp_img)
bbox = temp_draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# Create image with padding
padding = 10
img_width = max(text_width + padding * 2, 100)
img_height = max(text_height + padding * 2, 30)
# Validate resulting image size to prevent memory/CPU spikes
if img_width > MAX_DIM or img_height > MAX_DIM:
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
if img_width * img_height > MAX_PIXELS:
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
img = Image.new('RGB', (img_width, img_height), bg_rgb)
draw = ImageDraw.Draw(img)
# Center text
x = (img_width - text_width) // 2
y = (img_height - text_height) // 2
draw.text((x, y), text, font=font, fill=fg_rgb)
# Convert to base64
buffer = io.BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
return jsonify({
'status': 'success',
'data': {
'image': f'data:image/png;base64,{img_base64}',
'width': img_width,
'height': img_height
}
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
@api_v3.route('/fonts/<font_family>', methods=['DELETE'])
def delete_font(font_family: str) -> tuple[Response, int] | Response:
"""Delete a user-uploaded font file"""
try:
# Security: Validate font_family to prevent path traversal
# Reject if it contains path separators or ..
if '..' in font_family or '/' in font_family or '\\' in font_family:
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
# Only allow safe characters: alphanumeric, hyphen, underscore, dot
if not re.match(r'^[a-zA-Z0-9_\-\.]+$', font_family):
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
# Check if this is a system font (uses module-level SYSTEM_FONTS frozenset)
if font_family.lower() in SYSTEM_FONTS:
return jsonify({'status': 'error', 'message': 'Cannot delete system fonts'}), 403
# Find and delete the font file
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
# Ensure fonts directory exists
if not fonts_dir.exists() or not fonts_dir.is_dir():
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
deleted = False
deleted_filename = None
# Only try valid font extensions (no empty string to avoid matching directories)
for ext in ['.ttf', '.otf', '.bdf']:
potential_path = fonts_dir / f"{font_family}{ext}"
# Security: Verify path is within fonts_dir
try:
potential_path.resolve().relative_to(fonts_dir.resolve())
except ValueError:
continue # Path escapes fonts_dir, skip
if potential_path.exists() and potential_path.is_file():
potential_path.unlink()
deleted = True
deleted_filename = f"{font_family}{ext}"
break
if not deleted:
# Try case-insensitive match within fonts directory
font_family_lower = font_family.lower()
for filename in os.listdir(fonts_dir):
# Only consider files with valid font extensions
if not any(filename.lower().endswith(ext) for ext in ['.ttf', '.otf', '.bdf']):
continue
name_without_ext = os.path.splitext(filename)[0]
if name_without_ext.lower() == font_family_lower:
filepath = fonts_dir / filename
# Security: Verify path is within fonts_dir
try:
filepath.resolve().relative_to(fonts_dir.resolve())
except ValueError:
continue # Path escapes fonts_dir, skip
if filepath.is_file():
filepath.unlink()
deleted = True
deleted_filename = filename
break
if not deleted:
return jsonify({'status': 'error', 'message': f'Font not found: {font_family}'}), 404
# Clear font catalog cache
try:
from web_interface.cache import delete_cached
delete_cached('fonts_catalog')
except ImportError as e:
logger.warning("[FontDelete] Cache module not available: %s", e)
except Exception:
logger.error("[FontDelete] Failed to clear fonts_catalog cache", exc_info=True)
return jsonify({
'status': 'success',
'message': f'Font {deleted_filename} deleted successfully'
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
@api_v3.route('/plugins/assets/upload', methods=['POST'])
def upload_plugin_asset():
"""Upload asset files for a plugin"""
@@ -6044,15 +6335,6 @@ def list_plugin_assets():
import traceback
return jsonify({'status': 'error', 'message': str(e), 'traceback': traceback.format_exc()}), 500
@api_v3.route('/fonts/delete/<font_family>', methods=['DELETE'])
def delete_font(font_family):
"""Delete font"""
try:
# This would integrate with the actual font system
return jsonify({'status': 'success', 'message': f'Font {font_family} deleted'})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
@api_v3.route('/logs', methods=['GET'])
def get_logs():
"""Get system logs from journalctl"""

View File

@@ -0,0 +1,298 @@
/**
* LEDMatrix Font Selector Widget
*
* Dynamic font selector that fetches available fonts from the API.
* Automatically shows all fonts in assets/fonts/ directory.
*
* Schema example:
* {
* "font": {
* "type": "string",
* "title": "Font Family",
* "x-widget": "font-selector",
* "x-options": {
* "placeholder": "Select a font...",
* "showPreview": false,
* "filterTypes": ["ttf", "bdf"]
* },
* "default": "PressStart2P-Regular.ttf"
* }
* }
*
* @module FontSelectorWidget
*/
(function() {
'use strict';
const base = window.BaseWidget ? new window.BaseWidget('FontSelector', '1.0.0') : null;
// Cache for font catalog to avoid repeated API calls
let fontCatalogCache = null;
let fontCatalogPromise = null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
}
function triggerChange(fieldId, value) {
if (base) {
base.triggerChange(fieldId, value);
} else {
const event = new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true,
cancelable: true
});
document.dispatchEvent(event);
}
}
/**
* Generate a human-readable display name from font filename
* @param {string} filename - Font filename (e.g., "PressStart2P-Regular.ttf")
* @returns {string} Display name (e.g., "Press Start 2P Regular")
*/
function generateDisplayName(filename) {
if (!filename) return '';
// Remove extension
let name = filename.replace(/\.(ttf|bdf|otf)$/i, '');
// Handle common patterns
// Split on hyphens and underscores
name = name.replace(/[-_]/g, ' ');
// Add space before capital letters (camelCase/PascalCase)
name = name.replace(/([a-z])([A-Z])/g, '$1 $2');
// Add space before numbers that follow letters
name = name.replace(/([a-zA-Z])(\d)/g, '$1 $2');
// Clean up multiple spaces
name = name.replace(/\s+/g, ' ').trim();
return name;
}
/**
* Fetch font catalog from API (with caching)
* @returns {Promise<Array>} Array of font objects
*/
async function fetchFontCatalog() {
// Return cached data if available
if (fontCatalogCache) {
return fontCatalogCache;
}
// Return existing promise if fetch is in progress
if (fontCatalogPromise) {
return fontCatalogPromise;
}
// Fetch from API
fontCatalogPromise = fetch('/api/v3/fonts/catalog')
.then(response => {
if (!response.ok) {
throw new Error(`Failed to fetch font catalog: ${response.status}`);
}
return response.json();
})
.then(data => {
// Handle different response structures
let fonts = [];
if (data.data && data.data.fonts) {
// New format: { data: { fonts: [...] } }
fonts = data.data.fonts;
} else if (data.data && data.data.catalog) {
// Alternative format: { data: { catalog: {...} } }
const catalog = data.data.catalog;
fonts = Object.entries(catalog).map(([family, info]) => ({
filename: info.filename || family,
family: family,
display_name: info.display_name || generateDisplayName(info.filename || family),
path: info.path,
type: info.type || 'unknown'
}));
} else if (Array.isArray(data)) {
// Direct array format
fonts = data;
}
// Sort fonts alphabetically by display name
fonts.sort((a, b) => {
const nameA = (a.display_name || a.filename || '').toLowerCase();
const nameB = (b.display_name || b.filename || '').toLowerCase();
return nameA.localeCompare(nameB);
});
fontCatalogCache = fonts;
fontCatalogPromise = null;
return fonts;
})
.catch(error => {
console.error('[FontSelectorWidget] Error fetching font catalog:', error);
fontCatalogPromise = null;
return [];
});
return fontCatalogPromise;
}
/**
* Clear the font catalog cache (call when fonts are uploaded/deleted)
*/
function clearFontCache() {
fontCatalogCache = null;
fontCatalogPromise = null;
}
// Expose cache clearing function globally
window.clearFontSelectorCache = clearFontCache;
// Guard against missing global registry
if (!window.LEDMatrixWidgets || typeof window.LEDMatrixWidgets.register !== 'function') {
console.error('[FontSelectorWidget] LEDMatrixWidgets registry not available');
return;
}
window.LEDMatrixWidgets.register('font-selector', {
name: 'Font Selector Widget',
version: '1.0.0',
render: async function(container, config, value, options) {
const fieldId = sanitizeId(options.fieldId || container.id || 'font-select');
const xOptions = config['x-options'] || config['x_options'] || {};
const placeholder = xOptions.placeholder || 'Select a font...';
const filterTypes = xOptions.filterTypes || null; // e.g., ['ttf', 'bdf']
const showPreview = xOptions.showPreview === true;
const disabled = xOptions.disabled === true;
const required = xOptions.required === true;
const currentValue = value !== null && value !== undefined ? String(value) : '';
// Show loading state
container.innerHTML = `
<div id="${fieldId}_widget" class="font-selector-widget" data-field-id="${fieldId}">
<select id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
disabled
class="form-select w-full rounded-md border-gray-300 shadow-sm bg-gray-100 text-black">
<option value="">Loading fonts...</option>
</select>
</div>
`;
try {
// Fetch fonts from API
const fonts = await fetchFontCatalog();
// Filter by type if specified
let filteredFonts = fonts;
if (filterTypes && Array.isArray(filterTypes)) {
filteredFonts = fonts.filter(font => {
const fontType = (font.type || '').toLowerCase();
return filterTypes.some(t => t.toLowerCase() === fontType);
});
}
// Build select HTML
let html = `<div id="${fieldId}_widget" class="font-selector-widget" data-field-id="${fieldId}">`;
html += `
<select id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
${disabled ? 'disabled' : ''}
${required ? 'required' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('font-selector').onChange('${fieldId}')"
class="form-select w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black">
`;
// Placeholder option
if (placeholder && !required) {
html += `<option value="" ${!currentValue ? 'selected' : ''}>${escapeHtml(placeholder)}</option>`;
}
// Font options
for (const font of filteredFonts) {
const fontValue = font.filename || font.family;
const displayName = font.display_name || generateDisplayName(fontValue);
const fontType = font.type ? ` (${font.type.toUpperCase()})` : '';
const isSelected = String(fontValue) === currentValue;
html += `<option value="${escapeHtml(String(fontValue))}" ${isSelected ? 'selected' : ''}>${escapeHtml(displayName)}${escapeHtml(fontType)}</option>`;
}
html += '</select>';
// Optional preview area
if (showPreview) {
html += `
<div id="${fieldId}_preview" class="mt-2 p-2 bg-gray-800 rounded text-white text-center" style="min-height: 30px;">
<span style="font-family: monospace;">Preview</span>
</div>
`;
}
// Error message area
html += `<div id="${fieldId}_error" class="text-sm text-red-600 mt-1 hidden"></div>`;
html += '</div>';
container.innerHTML = html;
} catch (error) {
console.error('[FontSelectorWidget] Error rendering:', error);
container.innerHTML = `
<div id="${fieldId}_widget" class="font-selector-widget" data-field-id="${fieldId}">
<select id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
class="form-select w-full rounded-md border-gray-300 shadow-sm bg-white text-black">
<option value="${escapeHtml(currentValue)}" selected>${escapeHtml(currentValue || 'Error loading fonts')}</option>
</select>
<div class="text-sm text-red-600 mt-1">Failed to load font list</div>
</div>
`;
}
},
getValue: function(fieldId) {
const safeId = sanitizeId(fieldId);
const input = document.getElementById(`${safeId}_input`);
return input ? input.value : '';
},
setValue: function(fieldId, value) {
const safeId = sanitizeId(fieldId);
const input = document.getElementById(`${safeId}_input`);
if (input) {
input.value = value !== null && value !== undefined ? String(value) : '';
}
},
handlers: {
onChange: function(fieldId) {
const widget = window.LEDMatrixWidgets.get('font-selector');
triggerChange(fieldId, widget.getValue(fieldId));
}
},
// Expose utility functions
utils: {
clearCache: clearFontCache,
fetchCatalog: fetchFontCatalog,
generateDisplayName: generateDisplayName
}
});
console.log('[FontSelectorWidget] Font selector widget registered');
})();

View File

@@ -4908,6 +4908,7 @@
<script src="{{ url_for('static', filename='v3/js/widgets/number-input.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/textarea.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/select-dropdown.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/font-selector.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/toggle-switch.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/radio-group.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/date-picker.js') }}" defer></script>

View File

@@ -28,14 +28,14 @@
<!-- Font Upload -->
<div class="bg-gray-50 rounded-lg p-4 mb-8">
<h3 class="text-md font-medium text-gray-900 mb-4">Upload Custom Fonts</h3>
<p class="text-sm text-gray-600 mb-4">Upload your own TTF or BDF font files to use in your LED matrix display.</p>
<p class="text-sm text-gray-600 mb-4">Upload your own TTF, OTF, or BDF font files to use in your LED matrix display.</p>
<div class="font-upload-area" id="font-upload-area">
<div class="upload-dropzone" id="upload-dropzone">
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-3"></i>
<p class="text-gray-600">Drag and drop font files here, or click to select</p>
<p class="text-sm text-gray-500">Supports .ttf and .bdf files</p>
<input type="file" id="font-file-input" accept=".ttf,.bdf" multiple style="display: none;">
<p class="text-sm text-gray-500">Supports .ttf, .otf, and .bdf files</p>
<input type="file" id="font-file-input" accept=".ttf,.otf,.bdf" multiple style="display: none;">
</div>
</div>
@@ -49,8 +49,8 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Font Family Name</label>
<input type="text" id="upload-font-family" class="form-control" placeholder="e.g., my_custom_font">
<p class="text-sm text-gray-600 mt-1">Custom name for this font (letters, numbers, underscores only)</p>
<input type="text" id="upload-font-family" class="form-control" placeholder="e.g., my-custom-font">
<p class="text-sm text-gray-600 mt-1">Custom name for this font (letters, numbers, underscores, hyphens)</p>
</div>
<div class="flex items-end">
<button type="button" id="upload-fonts-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2">
@@ -112,9 +112,7 @@
<label class="block text-sm font-medium text-gray-700 mb-1">Font Family</label>
<select id="override-family" class="form-control text-sm">
<option value="">Use default</option>
<option value="press_start">Press Start 2P</option>
<option value="four_by_six">4x6 Font</option>
<option value="matrix_light_6">Matrix Light 6</option>
<!-- Dynamically populated from font catalog -->
</select>
</div>
@@ -152,7 +150,10 @@
<h3 class="text-md font-medium text-gray-900 mb-4">Font Preview</h3>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 gap-6">
<div>
<canvas id="font-preview-canvas" width="400" height="100" class="border border-gray-300 bg-black rounded"></canvas>
<div id="font-preview-container" class="border border-gray-300 bg-black rounded p-4 min-h-[100px] flex items-center justify-center">
<img id="font-preview-image" src="" alt="Font preview" class="max-w-full" style="display: none;">
<span id="font-preview-loading" class="text-gray-400">Select a font to preview</span>
</div>
</div>
<div class="space-y-3">
<div>
@@ -163,9 +164,7 @@
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Font Family</label>
<select id="preview-family" class="form-control text-sm">
<option value="press_start">Press Start 2P</option>
<option value="four_by_six">4x6 Font</option>
<option value="matrix_light_6">Matrix Light 6</option>
<!-- Dynamically populated from font catalog -->
</select>
</div>
<div>
@@ -209,27 +208,43 @@
window.fontTokens = window.fontTokens || {};
window.fontOverrides = window.fontOverrides || {};
window.selectedFontFiles = window.selectedFontFiles || [];
// Create references that can be reassigned
var fontCatalog = window.fontCatalog;
var fontTokens = window.fontTokens;
var fontOverrides = window.fontOverrides;
var selectedFontFiles = window.selectedFontFiles;
// Base URL for API calls (shared scope)
var baseUrl = window.location.origin;
// Retry counter for initialization
var initRetryCount = 0;
var MAX_INIT_RETRIES = 50; // 5 seconds max (50 * 100ms)
function initializeFontsTab() {
// Allow re-initialization on each HTMX content swap
// The window._fontsScriptLoaded guard prevents function redeclaration
const detectedEl = document.getElementById('detected-fonts');
const availableEl = document.getElementById('available-fonts');
if (!detectedEl || !availableEl) {
initRetryCount++;
if (initRetryCount >= MAX_INIT_RETRIES) {
console.error('Fonts tab elements not found after max retries, giving up');
return;
}
console.log('Fonts tab elements not found, retrying...', {
detectedFonts: !!detectedEl,
availableFonts: !!availableEl
availableFonts: !!availableEl,
attempt: initRetryCount
});
setTimeout(initializeFontsTab, 100);
return;
}
// Reset retry counter on successful init
initRetryCount = 0;
// showNotification is provided by the notification widget (notification.js)
// Fallback only if widget hasn't loaded yet
@@ -368,7 +383,6 @@ async function loadFontData() {
try {
// Use absolute URLs to ensure they work when loaded via HTMX
const baseUrl = window.location.origin;
const [catalogRes, tokensRes, overridesRes] = await Promise.all([
fetch(`${baseUrl}/api/v3/fonts/catalog`),
fetch(`${baseUrl}/api/v3/fonts/tokens`),
@@ -488,21 +502,146 @@ function updateAvailableFontsDisplay() {
return;
}
const lines = Object.entries(fontCatalog).map(([name, fontInfo]) => {
const fontEntries = Object.entries(fontCatalog).map(([name, fontInfo]) => {
const fontPath = typeof fontInfo === 'string' ? fontInfo : (fontInfo?.path || '');
// Only prefix with "assets/fonts/" if path is a bare filename (no "/" and doesn't start with "assets/")
// If path is absolute (starts with "/") or already has "assets/" prefix, use as-is
const fullPath = (fontPath.startsWith('/') || fontPath.startsWith('assets/'))
? fontPath
: `assets/fonts/${fontPath}`;
return `${name}: ${fullPath}`;
const filename = typeof fontInfo === 'object' ? (fontInfo.filename || name) : name;
const displayName = typeof fontInfo === 'object' ? (fontInfo.display_name || name) : name;
const fontType = typeof fontInfo === 'object' ? (fontInfo.type || '').toUpperCase() : '';
// Use is_system flag from API (single source of truth)
const isSystem = typeof fontInfo === 'object' ? (fontInfo.is_system === true) : false;
return { name, filename, displayName, fontType, fontPath, isSystem };
}).sort((a, b) => a.displayName.localeCompare(b.displayName));
// Build list using DOM APIs to prevent XSS
container.innerHTML = '';
fontEntries.forEach(font => {
const row = document.createElement('div');
row.className = 'flex items-center justify-between py-1 border-b border-gray-700 last:border-0';
const nameSpan = document.createElement('span');
nameSpan.className = 'truncate flex-1';
nameSpan.textContent = font.displayName;
if (font.fontType) {
const typeSpan = document.createElement('span');
typeSpan.className = 'text-gray-500 ml-1';
typeSpan.textContent = `(${font.fontType})`;
nameSpan.appendChild(typeSpan);
}
row.appendChild(nameSpan);
if (font.isSystem) {
const systemBadge = document.createElement('span');
systemBadge.className = 'text-gray-600 text-xs ml-2';
systemBadge.textContent = '[system]';
row.appendChild(systemBadge);
} else {
const deleteBtn = document.createElement('button');
deleteBtn.className = 'text-red-400 hover:text-red-300 text-xs ml-2';
deleteBtn.title = 'Delete font';
deleteBtn.textContent = '[delete]';
deleteBtn.dataset.fontName = font.name;
deleteBtn.addEventListener('click', function() {
deleteFont(this.dataset.fontName);
});
row.appendChild(deleteBtn);
}
container.appendChild(row);
});
container.textContent = lines.join('\n');
}
async function deleteFont(fontFamily) {
if (!confirm(`Are you sure you want to delete the font "${fontFamily}"? This action cannot be undone.`)) {
return;
}
try {
const response = await fetch(`${baseUrl}/api/v3/fonts/${encodeURIComponent(fontFamily)}`, {
method: 'DELETE'
});
if (!response.ok) {
const text = await response.text();
let message;
try {
const errorData = JSON.parse(text);
message = errorData.message || `Server error: ${response.status}`;
} catch {
message = `Server error: ${response.status}`;
}
showNotification(message, 'error');
return;
}
const data = await response.json();
if (data.status === 'success') {
showNotification(data.message || `Font "${fontFamily}" deleted successfully`, 'success');
// Refresh font data and UI
await loadFontData();
populateFontSelects();
// Clear font-selector widget cache if available
if (typeof window.clearFontSelectorCache === 'function') {
window.clearFontSelectorCache();
}
} else {
showNotification(data.message || `Failed to delete font "${fontFamily}"`, 'error');
}
} catch (error) {
console.error('Error deleting font:', error);
showNotification(`Error deleting font: ${error.message}`, 'error');
}
}
function populateFontSelects() {
// This would populate the select options with actual font data
// For now, using placeholder options
// Populate font family dropdowns from catalog
const overrideSelect = document.getElementById('override-family');
const previewSelect = document.getElementById('preview-family');
if (!overrideSelect || !previewSelect) return;
// Get font entries sorted by display name
const fontEntries = Object.entries(fontCatalog).map(([key, info]) => {
const filename = typeof info === 'object' ? (info.filename || key) : key;
const displayName = typeof info === 'object' ? (info.display_name || key) : key;
const fontType = typeof info === 'object' ? (info.type || 'unknown').toUpperCase() : '';
return { key, filename, displayName, fontType };
}).sort((a, b) => a.displayName.localeCompare(b.displayName));
// Build options using DOM APIs to prevent XSS
// Clear and add default option for override select
overrideSelect.innerHTML = '';
const defaultOption = document.createElement('option');
defaultOption.value = '';
defaultOption.textContent = 'Use default';
overrideSelect.appendChild(defaultOption);
// Clear preview select
previewSelect.innerHTML = '';
// Add font options to both selects
fontEntries.forEach(font => {
const typeLabel = font.fontType ? ` (${font.fontType})` : '';
const overrideOpt = document.createElement('option');
overrideOpt.value = font.filename;
overrideOpt.textContent = font.displayName + typeLabel;
overrideSelect.appendChild(overrideOpt);
const previewOpt = document.createElement('option');
previewOpt.value = font.filename;
previewOpt.textContent = font.displayName + typeLabel;
previewSelect.appendChild(previewOpt);
});
// Select first font in preview if available
if (fontEntries.length > 0) {
previewSelect.value = fontEntries[0].filename;
}
console.log(`Populated font selects with ${fontEntries.length} fonts`);
}
async function addFontOverride() {
@@ -536,6 +675,19 @@ async function addFontOverride() {
})
});
if (!response.ok) {
const text = await response.text();
let message;
try {
const errorData = JSON.parse(text);
message = errorData.message || `Server error: ${response.status}`;
} catch {
message = `Server error: ${response.status}`;
}
showNotification('Error adding font override: ' + message, 'error');
return;
}
const data = await response.json();
if (data.status === 'success') {
showNotification('Font override added successfully', 'success');
@@ -564,6 +716,19 @@ async function deleteFontOverride(elementKey) {
method: 'DELETE'
});
if (!response.ok) {
const text = await response.text();
let message;
try {
const errorData = JSON.parse(text);
message = errorData.message || `Server error: ${response.status}`;
} catch {
message = `Server error: ${response.status}`;
}
showNotification('Error removing font override: ' + message, 'error');
return;
}
const data = await response.json();
if (data.status === 'success') {
showNotification('Font override removed successfully', 'success');
@@ -587,7 +752,9 @@ function displayCurrentOverrides() {
return;
}
container.innerHTML = Object.entries(fontOverrides).map(([elementKey, override]) => {
// Build list using DOM APIs to prevent XSS
container.innerHTML = '';
Object.entries(fontOverrides).forEach(([elementKey, override]) => {
const elementName = getElementDisplayName(elementKey);
const settings = [];
@@ -600,18 +767,37 @@ function displayCurrentOverrides() {
settings.push(`Size: ${override.size_px}px`);
}
return `
<div class="flex items-center justify-between p-3 bg-white rounded border">
<div>
<div class="font-medium text-gray-900">${elementName}</div>
<div class="text-sm text-gray-600">${settings.join(', ')}</div>
</div>
<button onclick="deleteFontOverride('${elementKey}')" class="btn bg-red-600 hover:bg-red-700 text-white px-3 py-1 text-sm">
<i class="fas fa-trash mr-1"></i>Remove
</button>
</div>
`;
}).join('');
const row = document.createElement('div');
row.className = 'flex items-center justify-between p-3 bg-white rounded border';
const infoDiv = document.createElement('div');
const nameDiv = document.createElement('div');
nameDiv.className = 'font-medium text-gray-900';
nameDiv.textContent = elementName;
const settingsDiv = document.createElement('div');
settingsDiv.className = 'text-sm text-gray-600';
settingsDiv.textContent = settings.join(', ');
infoDiv.appendChild(nameDiv);
infoDiv.appendChild(settingsDiv);
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn bg-red-600 hover:bg-red-700 text-white px-3 py-1 text-sm';
const trashIcon = document.createElement('i');
trashIcon.className = 'fas fa-trash mr-1';
deleteBtn.appendChild(trashIcon);
deleteBtn.appendChild(document.createTextNode('Remove'));
deleteBtn.dataset.elementKey = elementKey;
deleteBtn.addEventListener('click', function() {
deleteFontOverride(this.dataset.elementKey);
});
row.appendChild(infoDiv);
row.appendChild(deleteBtn);
container.appendChild(row);
});
}
function getElementDisplayName(elementKey) {
@@ -639,30 +825,75 @@ function getFontDisplayName(fontKey) {
return names[fontKey] || fontKey;
}
function updateFontPreview() {
const canvas = document.getElementById('font-preview-canvas');
const text = document.getElementById('preview-text').value || 'Sample Text';
const family = document.getElementById('preview-family').value;
const size = document.getElementById('preview-size').value;
async function updateFontPreview() {
const previewImage = document.getElementById('font-preview-image');
const loadingText = document.getElementById('font-preview-loading');
const textInput = document.getElementById('preview-text');
const familySelect = document.getElementById('preview-family');
const sizeSelect = document.getElementById('preview-size');
if (!canvas) return;
if (!previewImage || !loadingText) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const text = textInput?.value || 'Sample Text 123';
const family = familySelect?.value || '';
const sizeToken = sizeSelect?.value || 'md';
const sizePx = fontTokens[sizeToken] || 10;
// Set background
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (!family) {
previewImage.style.display = 'none';
loadingText.style.display = 'block';
loadingText.textContent = 'Select a font to preview';
return;
}
// Set font properties
const fontSize = fontTokens[size] || 8;
ctx.fillStyle = '#ffffff';
ctx.font = `${fontSize}px monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Show loading state
loadingText.textContent = 'Loading preview...';
loadingText.style.display = 'block';
previewImage.style.display = 'none';
// Draw text in center
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
try {
const params = new URLSearchParams({
font: family,
text: text,
size: sizePx,
bg: '000000',
fg: 'ffffff'
});
const response = await fetch(`${baseUrl}/api/v3/fonts/preview?${params}`);
if (!response.ok) {
const text = await response.text();
let message;
try {
const errorData = JSON.parse(text);
message = errorData.message || `Server error: ${response.status}`;
} catch {
message = `Server error: ${response.status}`;
}
loadingText.textContent = message;
loadingText.style.display = 'block';
previewImage.style.display = 'none';
return;
}
const data = await response.json();
if (data.status === 'success' && data.data?.image) {
previewImage.src = data.data.image;
previewImage.style.display = 'block';
loadingText.style.display = 'none';
} else {
loadingText.textContent = data.message || 'Failed to load preview';
loadingText.style.display = 'block';
previewImage.style.display = 'none';
}
} catch (error) {
console.error('Error loading font preview:', error);
loadingText.textContent = 'Error loading preview';
loadingText.style.display = 'block';
previewImage.style.display = 'none';
}
}
function initializeFontUpload() {
@@ -671,13 +902,14 @@ function initializeFontUpload() {
function handleFileSelection(event) {
const files = Array.from(event.target.files);
const validExtensions = ['ttf', 'otf', 'bdf'];
const validFiles = files.filter(file => {
const extension = file.name.toLowerCase().split('.').pop();
return extension === 'ttf' || extension === 'bdf';
return validExtensions.includes(extension);
});
if (validFiles.length === 0) {
showNotification('Please select valid .ttf or .bdf font files', 'warning');
showNotification('Please select valid .ttf, .otf, or .bdf font files', 'warning');
return;
}
@@ -696,18 +928,26 @@ function showUploadForm() {
const selectedFilesContainer = document.getElementById('selected-files');
const fontFamilyInput = document.getElementById('upload-font-family');
// Show selected files
selectedFilesContainer.innerHTML = selectedFontFiles.map(file => `
<div class="flex items-center justify-between p-2 bg-gray-100 rounded">
<span class="text-sm">${file.name} (${(file.size / 1024).toFixed(1)} KB)</span>
</div>
`).join('');
// Show selected files using DOM APIs to prevent XSS
selectedFilesContainer.innerHTML = '';
selectedFontFiles.forEach(file => {
const row = document.createElement('div');
row.className = 'flex items-center justify-between p-2 bg-gray-100 rounded';
const span = document.createElement('span');
span.className = 'text-sm';
span.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
row.appendChild(span);
selectedFilesContainer.appendChild(row);
});
// Auto-generate font family name from first file
if (selectedFontFiles.length === 1) {
const filename = selectedFontFiles[0].name;
const nameWithoutExt = filename.substring(0, filename.lastIndexOf('.'));
fontFamilyInput.value = nameWithoutExt.toLowerCase().replace(/[^a-z0-9]/g, '_');
// Preserve hyphens, convert other special chars to underscores
fontFamilyInput.value = nameWithoutExt.toLowerCase().replace(/[^a-z0-9-]/g, '_');
}
uploadForm.style.display = 'block';
@@ -734,9 +974,9 @@ async function uploadSelectedFonts() {
return;
}
// Validate font family name
if (!/^[a-z0-9_]+$/i.test(fontFamily)) {
showNotification('Font family name can only contain letters, numbers, and underscores', 'warning');
// Validate font family name (must match backend validation)
if (!/^[a-z0-9_-]+$/i.test(fontFamily)) {
showNotification('Font family name can only contain letters, numbers, underscores, and hyphens', 'warning');
return;
}
@@ -755,6 +995,19 @@ async function uploadSelectedFonts() {
body: formData
});
if (!response.ok) {
const text = await response.text();
let message;
try {
const errorData = JSON.parse(text);
message = errorData.message || `Server error: ${response.status}`;
} catch {
message = `Server error: ${response.status}`;
}
showNotification(`Error uploading "${file.name}": ${message}`, 'error');
continue;
}
const data = await response.json();
if (data.status === 'success') {
@@ -773,6 +1026,10 @@ async function uploadSelectedFonts() {
populateFontSelects();
cancelFontUpload();
hideUploadProgress();
// Clear font-selector widget cache so new fonts appear in plugin configs
if (typeof window.clearFontSelectorCache === 'function') {
window.clearFontSelectorCache();
}
} catch (error) {
console.error('Error uploading fonts:', error);
@@ -850,7 +1107,12 @@ function updateUploadProgress(percent) {
border: 1px solid #d1d5db;
}
#font-preview-canvas {
#font-preview-container {
max-width: 100%;
min-height: 100px;
}
#font-preview-image {
max-width: 100%;
height: auto;
}

View File

@@ -537,7 +537,7 @@
{% else %}
{% set str_widget = prop.get('x-widget') or prop.get('x_widget') %}
{% set str_value = value if value is not none else (prop.default if prop.default is defined else '') %}
{% if str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input'] %}
{% if str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector'] %}
{# Render widget container #}
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
<script>