Skip to content

Instantly share code, notes, and snippets.

@GeneralD
Created July 22, 2025 15:15
Show Gist options
  • Select an option

  • Save GeneralD/e8f29722a2c84b677a5530edbd313e68 to your computer and use it in GitHub Desktop.

Select an option

Save GeneralD/e8f29722a2c84b677a5530edbd313e68 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
"""
Text Style Converter - Convert plain text to Unicode styled text
Similar to PlainStyle (https://www.shapoco.net/plainstyle/)
"""
import sys
import argparse
from typing import Dict, Callable, Optional, List, Tuple
from dataclasses import dataclass
@dataclass(frozen=True)
class Colors:
"""ANSI color codes for terminal output."""
RED: str = '\033[0;31m'
GREEN: str = '\033[0;32m'
YELLOW: str = '\033[1;33m'
BLUE: str = '\033[0;34m'
PURPLE: str = '\033[0;35m'
CYAN: str = '\033[0;36m'
NC: str = '\033[0m'
@dataclass(frozen=True)
class StyleConfig:
"""Configuration for a Unicode style."""
name: str
bases: Dict[str, int]
special_chars: Optional[Dict] = None
aliases: Optional[List[str]] = None
# Unicode style configurations
STYLE_CONFIGS = [
StyleConfig('bold', {'upper': 0x1d400, 'lower': 0x1d41a, 'digit': 0x1d7ce}),
StyleConfig('italic', {'upper': 0x1d434, 'lower': 0x1d44e},
{'h': 0x210E}),
StyleConfig('bolditalic', {'upper': 0x1d468, 'lower': 0x1d482},
aliases=['bold-italic']),
StyleConfig('script', {'upper': 0x1d49c, 'lower': 0x1d4b6}, {
'B': 0x212C, 'E': 0x2130, 'F': 0x2131, 'H': 0x210B,
'I': 0x2110, 'L': 0x2112, 'M': 0x2133, 'R': 0x211B,
'e': 0x212F, 'g': 0x210A, 'o': 0x2134
}),
StyleConfig('boldscript', {'upper': 0x1d4d0, 'lower': 0x1d4ea},
aliases=['bold-script']),
StyleConfig('sans', {'upper': 0x1d5a0, 'lower': 0x1d5ba, 'digit': 0x1d7e2},
aliases=['sans-serif']),
StyleConfig('sansbold', {'upper': 0x1d5d4, 'lower': 0x1d5ee, 'digit': 0x1d7ec},
aliases=['sans-bold']),
StyleConfig('sansitalic', {'upper': 0x1d608, 'lower': 0x1d622},
aliases=['sans-italic']),
StyleConfig('sansbolditalic', {'upper': 0x1d63c, 'lower': 0x1d656},
aliases=['sans-bold-italic']),
StyleConfig('monospace', {'upper': 0x1d670, 'lower': 0x1d68a, 'digit': 0x1d7f6},
aliases=['mono']),
StyleConfig('fraktur', {'upper': 0x1d504, 'lower': 0x1d51e}, {
'C': 0x212D, 'H': 0x210C, 'I': 0x2111, 'R': 0x211C, 'Z': 0x2128
}, ['gothic']),
StyleConfig('double', {'upper': 0x1d538, 'lower': 0x1d552, 'digit': 0x1d7d8}, {
'C': 0x2102, 'H': 0x210D, 'N': 0x2115, 'P': 0x2119,
'Q': 0x211A, 'R': 0x211D, 'Z': 0x2124
}, ['doublestruck']),
StyleConfig('circled', {'upper': 0x24b6, 'lower': 0x24d0, 'digit': 0x2460},
aliases=['circle']),
StyleConfig('blackcircled', {'upper': 0x1f150, 'digit': 0x2776},
aliases=['black-circled']),
StyleConfig('parenthesized', {'upper': 0x1f110, 'lower': 0x249c, 'digit': 0x2474},
aliases=['parens']),
StyleConfig('squared', {'upper': 0x1f130}, aliases=['square']),
StyleConfig('blacksquared', {'upper': 0x1f170}, aliases=['black-squared']),
StyleConfig('fullwidth', {'base': 0xff01}, {
' ': 0x3000, '`': 0x2018, '"': 0x201D
}, ['wide']),
StyleConfig('superscript', {}, {
'0': 0x2070, '1': 0x00b9, '2': 0x00b2, '3': 0x00b3, '4': 0x2074,
'5': 0x2075, '6': 0x2076, '7': 0x2077, '8': 0x2078, '9': 0x2079,
'+': 0x207a, '-': 0x207b, '=': 0x207c, '(': 0x207d, ')': 0x207e,
'a': 0x1d43, 'b': 0x1d47, 'c': 0x1d9c, 'd': 0x1d48, 'e': 0x1d49,
'f': 0x1da0, 'g': 0x1d4d, 'h': 0x02b0, 'i': 0x2071, 'j': 0x02b2,
'k': 0x1d4f, 'l': 0x02e1, 'm': 0x1d50, 'n': 0x207f, 'o': 0x1d52,
'p': 0x1d56, 'r': 0x02b3, 's': 0x02e2, 't': 0x1d57, 'u': 0x1d58,
'v': 0x1d5b, 'w': 0x02b7, 'x': 0x02e3, 'y': 0x02b8, 'z': 0x1dbb
}, ['super']),
StyleConfig('subscript', {}, {
'0': 0x2080, '1': 0x2081, '2': 0x2082, '3': 0x2083, '4': 0x2084,
'5': 0x2085, '6': 0x2086, '7': 0x2087, '8': 0x2088, '9': 0x2089,
'+': 0x208a, '-': 0x208b, '=': 0x208c, '(': 0x208d, ')': 0x208e,
'a': 0x2090, 'e': 0x2091, 'o': 0x2092, 'x': 0x2093, 'h': 0x2095,
'k': 0x2096, 'l': 0x2097, 'm': 0x2098, 'n': 0x2099, 'p': 0x209a,
's': 0x209b, 't': 0x209c
}, ['sub'])
]
# Case conversion styles
CASE_STYLES = {
'upper': str.upper, 'uppercase': str.upper, 'u': str.upper,
'lower': str.lower, 'lowercase': str.lower, 'l': str.lower,
'title': str.title, 'titlecase': str.title, 't': str.title,
}
def convert_char_with_offset(char: str, offset: int) -> str:
"""Convert character using Unicode offset."""
return chr(ord(char) + offset)
def convert_digit_special(char: str, digit_base: int, style_name: str) -> str:
"""Handle special digit conversion cases."""
if style_name == 'circled' and char == '0':
return chr(0x24ea)
elif style_name == 'blackcircled' and char == '0':
return chr(0x24ff)
elif digit_base in [0x2460, 0x2776, 0x2474] and char != '0':
return chr(digit_base + ord(char) - ord('1'))
else:
return chr(digit_base + ord(char) - ord('0'))
def convert_with_config(text: str, config: StyleConfig) -> str:
"""Convert text using style configuration."""
result = []
for char in text:
converted = False
# Check special characters first
if config.special_chars and char in config.special_chars:
result.append(chr(config.special_chars[char]))
converted = True
elif config.name == 'fullwidth' and '!' <= char <= '~':
result.append(chr(config.bases['base'] + ord(char) - ord('!')))
converted = True
elif 'upper' in config.bases and 'A' <= char <= 'Z':
result.append(convert_char_with_offset(char, config.bases['upper'] - ord('A')))
converted = True
elif 'lower' in config.bases and 'a' <= char <= 'z':
result.append(convert_char_with_offset(char, config.bases['lower'] - ord('a')))
converted = True
elif 'digit' in config.bases and '0' <= char <= '9':
result.append(convert_digit_special(char, config.bases['digit'], config.name))
converted = True
if not converted:
result.append(char)
return ''.join(result)
def build_style_registry() -> Dict[str, Callable[[str], str]]:
"""Build registry of all style conversion functions."""
registry = {}
# Add Unicode styles
for config in STYLE_CONFIGS:
converter = lambda text, cfg=config: convert_with_config(text, cfg)
registry[config.name] = converter
# Add aliases
if config.aliases:
for alias in config.aliases:
registry[alias] = converter
# Add case conversion styles
registry.update(CASE_STYLES)
return registry
def convert_text(style: str, text: str) -> str:
"""Convert text to specified style."""
registry = build_style_registry()
style_key = style.lower()
if style_key not in registry:
raise ValueError(f"Unknown style '{style}'")
return registry[style_key](text)
def get_style_examples() -> Dict[str, str]:
"""Generate examples for all styles."""
examples = {
'bold': 'Bold text', 'italic': 'Italic text', 'bolditalic': 'Bold italic',
'script': 'Script text', 'boldscript': 'Bold script', 'monospace': 'Monospace text',
'fraktur': 'Fraktur text', 'double': 'Double-struck', 'sans': 'Sans serif',
'sansbold': 'Sans bold', 'sansitalic': 'Sans italic', 'sansbolditalic': 'Sans bold italic',
'circled': 'Circled text', 'blackcircled': 'BLACK CIRCLED', 'parenthesized': 'Parenthesized',
'squared': 'SQUARED', 'blacksquared': 'BLACK SQUARED', 'fullwidth': 'Fullwidth text',
'superscript': 'superscript text', 'subscript': 'subscript text',
'upper': 'uppercase text', 'lower': 'LOWERCASE TEXT', 'title': 'title case text'
}
return {style: convert_text(style, text) for style, text in examples.items()}
def create_style_section(styles: Dict[str, str], style_list: List[str],
start_num: int = 1, labels: Optional[List[str]] = None) -> str:
"""Create a formatted style section."""
colors = Colors()
if labels:
return "\n".join([
f"{colors.GREEN}{label}.{colors.NC} {style:<15} - {styles[style]}"
for label, style in zip(labels, style_list)
])
else:
return "\n".join([
f"{colors.GREEN}{i}.{colors.NC} {style:<15} - {styles[style]}"
for i, style in enumerate(style_list, start_num)
])
def show_styles():
"""Display all available text styles with examples."""
colors = Colors()
styles = get_style_examples()
template = """{cyan}Basic text styles:{nc}
{basic_section}
{cyan}Sans-serif styles:{nc}
{sans_section}
{cyan}Enclosed styles:{nc}
{enclosed_section}
{cyan}Special styles:{nc}
{special_section}
{cyan}Case conversion:{nc}
{case_section}"""
print(template.format(
cyan=colors.CYAN, nc=colors.NC,
basic_section=create_style_section(styles,
['bold', 'italic', 'bolditalic', 'script', 'boldscript',
'monospace', 'fraktur', 'double']),
sans_section=create_style_section(styles,
['sans', 'sansbold', 'sansitalic', 'sansbolditalic'], 9),
enclosed_section=create_style_section(styles,
['circled', 'blackcircled', 'parenthesized', 'squared',
'blacksquared', 'fullwidth'], 13),
special_section=create_style_section(styles,
['superscript', 'subscript'], labels=['sup', 'sub']),
case_section=create_style_section(styles,
['upper', 'lower', 'title'], labels=['u', 'l', 't'])
))
def show_usage():
"""Display usage information."""
colors = Colors()
template = """{yellow}Usage:{nc}
textstyle [style] [text]
textstyle [style] # Read from stdin/pipe
{yellow}Examples:{nc}
textstyle bold "Hello World"
textstyle bold Hello World Multiple Words
textstyle script "Beautiful Text"
echo "Hello World" | textstyle bold
cat file.txt | textstyle italic
"""
print(template.format(yellow=colors.YELLOW, nc=colors.NC))
show_styles()
def get_input_text(text_args: Optional[str]) -> Optional[str]:
"""Get input text from arguments or stdin."""
if text_args:
return text_args
elif not sys.stdin.isatty():
return sys.stdin.read().strip()
return None
def run_cli(style: Optional[str], text: Optional[str]) -> int:
"""Main CLI logic."""
colors = Colors()
# Show usage if no arguments and not piped
if not style and not text and sys.stdin.isatty():
show_usage()
return 0
input_text = get_input_text(text)
# Validate inputs
if not style:
print(f"{colors.RED}Error: Please provide a style{colors.NC}", file=sys.stderr)
show_usage()
return 1
if not input_text:
print(f"{colors.RED}Error: Please provide text to convert{colors.NC}", file=sys.stderr)
show_usage()
return 1
# Convert and output
try:
result = convert_text(style, input_text)
print(result)
return 0
except ValueError as e:
print(f"{colors.RED}Error: {e}{colors.NC}", file=sys.stderr)
show_usage()
return 1
def parse_arguments() -> argparse.Namespace:
"""Parse command line arguments."""
parser = argparse.ArgumentParser(description="Convert plain text to Unicode styled text")
parser.add_argument('style', nargs='?', help='Text style to apply')
parser.add_argument('text', nargs='*', help='Text to convert (multiple words joined with spaces)')
parser.add_argument('-l', '--list-styles', action='store_true', help='List all available styles')
return parser.parse_args()
def main() -> int:
"""Main entry point."""
args = parse_arguments()
if args.list_styles:
show_styles()
return 0
# Join text arguments
text = ' '.join(args.text) if args.text else None
return run_cli(args.style, text)
if __name__ == "__main__":
sys.exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment