Created
March 1, 2026 05:28
-
-
Save idbrii/886083f04fec1039293d89c2aa756b36 to your computer and use it in GitHub Desktop.
Preview Fonts - Preview text with installed or otf/ttf fonts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ViewFont -- Preview characters in any system or custom font. | |
| CC0 - https://creativecommons.org/public-domain/cc0/ | |
| Code mostly generated by Claude Opus 4.6, so I claim no copyright. | |
| Similar in concept to [List Font | |
| Families](https://stackoverflow.com/questions/39614027/list-available-font-families-in-tkinter#53717785) | |
| but instead of displaying fonts with their name as a preview, shows custom | |
| text. Also supports loading custom fonts. | |
| """ | |
| import ctypes | |
| import os | |
| import tkinter as tk | |
| from tkinter import ttk, filedialog, font as tkfont | |
| try: | |
| from fontTools.ttLib import TTFont | |
| except ImportError: | |
| TTFont = False | |
| FR_PRIVATE = 0x10 | |
| class Tooltip: | |
| """Simple hover tooltip for a widget.""" | |
| def __init__(self, widget, text): | |
| self.widget = widget | |
| self.text = text | |
| self.tip = None | |
| widget.bind("<Enter>", self._show) | |
| widget.bind("<Leave>", self._hide) | |
| def _show(self, event=None): | |
| x = self.widget.winfo_rootx() + self.widget.winfo_width() // 2 | |
| y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4 | |
| self.tip = tk.Toplevel(self.widget) | |
| self.tip.wm_overrideredirect(True) | |
| self.tip.wm_geometry(f"+{x}+{y}") | |
| label = tk.Label( | |
| self.tip, | |
| text=self.text, | |
| background="#ffffe0", | |
| relief=tk.SOLID, | |
| borderwidth=1, | |
| padx=4, | |
| pady=2, | |
| ) | |
| label.pack() | |
| def _hide(self, event=None): | |
| if self.tip: | |
| self.tip.destroy() | |
| self.tip = None | |
| def load_font_file(path): | |
| """Load a .ttf/.otf file into the process and return its family name.""" | |
| path = os.path.abspath(path) | |
| added = ctypes.windll.gdi32.AddFontResourceExW(path, FR_PRIVATE, 0) | |
| if added == 0: | |
| raise RuntimeError(f"Failed to load font: {path}") | |
| tt = TTFont(path) | |
| family = tt["name"].getDebugName(1) | |
| tt.close() | |
| return family | |
| class ViewFontApp: | |
| def __init__(self, root): | |
| self.root = root | |
| self.root.title("ViewFont") | |
| self.root.geometry("800x600") | |
| self.root.minsize(500, 400) | |
| self.loaded_files = [] | |
| self._all_fonts = sorted(set(tkfont.families()), key=str.lower) | |
| self._build_ui() | |
| self._update_preview() | |
| def _build_ui(self): | |
| # Top bar: text input + size | |
| top = ttk.Frame(self.root, padding=8) | |
| top.pack(fill=tk.X) | |
| ttk.Label(top, text="Text:").pack(side=tk.LEFT) | |
| self.text_var = tk.StringVar(value="\u5203") | |
| self.text_var.trace_add("write", lambda *_: self._update_preview()) | |
| text_entry = ttk.Entry(top, textvariable=self.text_var, width=30) | |
| text_entry.pack(side=tk.LEFT, padx=(4, 16)) | |
| ttk.Label(top, text="Size:").pack(side=tk.LEFT) | |
| self.size_var = tk.IntVar(value=72) | |
| self.size_var.trace_add("write", lambda *_: self._update_preview()) | |
| size_spin = ttk.Spinbox( | |
| top, from_=8, to=200, textvariable=self.size_var, width=5 | |
| ) | |
| size_spin.pack(side=tk.LEFT, padx=(4, 0)) | |
| # Font selection bar | |
| font_bar = ttk.Frame(self.root, padding=(8, 0, 8, 8)) | |
| font_bar.pack(fill=tk.X) | |
| ttk.Label(font_bar, text="Font:").pack(side=tk.LEFT) | |
| self.font_var = tk.StringVar() | |
| self.font_combo = ttk.Combobox( | |
| font_bar, textvariable=self.font_var, values=self._all_fonts, width=40 | |
| ) | |
| self.font_combo.pack(side=tk.LEFT, padx=(4, 4)) | |
| self.font_combo.bind("<<ComboboxSelected>>", lambda *_: self._update_preview()) | |
| self.font_combo.bind("<KeyRelease>", self._filter_fonts) | |
| if self._all_fonts: | |
| self.font_var.set(self._all_fonts[0]) | |
| btn_next = ttk.Button(font_bar, text="\u25b6", width=3, command=self._next_font) | |
| btn_next.pack(side=tk.LEFT, padx=(0, 8)) | |
| Tooltip(btn_next, "Next font") | |
| btn_load = ttk.Button(font_bar, text="Load File...", command=self._load_file) | |
| btn_load.pack(side=tk.LEFT) | |
| if TTFont: | |
| Tooltip(btn_load, "Load a font file (.ttf/.otf) that isn't installed.") | |
| else: | |
| btn_load.configure(state=tk.DISABLED) | |
| Tooltip(btn_load, "Requires pip install fonttools") | |
| self.show_name_var = tk.BooleanVar(value=True) | |
| self.show_name_var.trace_add("write", lambda *_: self._update_preview()) | |
| ttk.Checkbutton( | |
| font_bar, text="Show font name", variable=self.show_name_var | |
| ).pack(side=tk.LEFT, padx=(8, 0)) | |
| # Preview area | |
| preview_frame = ttk.LabelFrame(self.root, text="Preview", padding=8) | |
| preview_frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=(0, 4)) | |
| self.preview_text = tk.Text( | |
| preview_frame, | |
| wrap=tk.WORD, | |
| bg="white", | |
| relief=tk.SUNKEN, | |
| cursor="arrow", | |
| state=tk.DISABLED, | |
| ) | |
| self.preview_text.pack(fill=tk.BOTH, expand=True) | |
| # Status bar | |
| self.status_var = tk.StringVar(value="Ready") | |
| status_bar = ttk.Label( | |
| self.root, textvariable=self.status_var, relief=tk.SUNKEN, padding=(8, 2) | |
| ) | |
| status_bar.pack(fill=tk.X, side=tk.BOTTOM) | |
| def _filter_fonts(self, event=None): | |
| """Filter the font dropdown as the user types.""" | |
| typed = self.font_var.get().lower() | |
| if typed: | |
| filtered = [f for f in self._all_fonts if typed in f.lower()] | |
| else: | |
| filtered = self._all_fonts | |
| self.font_combo["values"] = filtered | |
| def _next_font(self): | |
| """Cycle to the next font in the list.""" | |
| current = self.font_var.get() | |
| fonts = list(self.font_combo["values"]) | |
| if not fonts: | |
| fonts = self._all_fonts | |
| try: | |
| idx = fonts.index(current) | |
| idx = (idx + 1) % len(fonts) | |
| except ValueError: | |
| idx = 0 | |
| self.font_var.set(fonts[idx]) | |
| self._update_preview() | |
| def _update_preview(self): | |
| """Redraw the preview with current text, font, and size.""" | |
| try: | |
| size = self.size_var.get() | |
| except (tk.TclError, ValueError): | |
| size = 72 | |
| family = self.font_var.get() | |
| text = self.text_var.get() or "\u5203" | |
| self.preview_text.configure(state=tk.NORMAL) | |
| self.preview_text.delete("1.0", tk.END) | |
| self.preview_text.tag_configure( | |
| "name", font=(family, max(size // 4, 10)), justify=tk.CENTER | |
| ) | |
| self.preview_text.tag_configure("body", font=(family, size), justify=tk.CENTER) | |
| if self.show_name_var.get(): | |
| self.preview_text.insert(tk.END, family + "\n", "name") | |
| self.preview_text.insert(tk.END, text, "body") | |
| self.preview_text.configure(state=tk.DISABLED) | |
| def _load_file(self): | |
| """Open a font file and add it to the dropdown.""" | |
| path = filedialog.askopenfilename( | |
| title="Select Font File", | |
| filetypes=[ | |
| ("Font files", "*.ttf *.otf *.ttc *.otc"), | |
| ("All files", "*.*"), | |
| ], | |
| ) | |
| if not path: | |
| return | |
| try: | |
| family = load_font_file(path) | |
| # Refresh tkinter font list to include the new font. | |
| self._all_fonts = sorted(set(tkfont.families()), key=str.lower) | |
| self.font_combo["values"] = self._all_fonts | |
| self.font_var.set(family) | |
| self._update_preview() | |
| self.loaded_files.append(os.path.basename(path)) | |
| self.status_var.set(f"Loaded: {', '.join(self.loaded_files)}") | |
| except Exception as e: | |
| self.status_var.set(f"Error: {e}") | |
| def main(): | |
| root = tk.Tk() | |
| ViewFontApp(root) | |
| root.mainloop() | |
| if __name__ == "__main__": | |
| main() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Code could probably be simpler and more elegant, but works better than most free font preview tools I found.
It took 24 Premium requests (API time spent: 7m 50s). I wonder how much processing that required.
Looks like this: