textual-paint/src/textual_paint/repack_font.py

403 lines
15 KiB
Python
Raw Normal View History

2023-05-13 01:18:17 +03:00
#!/usr/bin/env python3
import os
from PIL import Image
block_char_lookup = {
0x0: ' ',
0x1: '',
0x2: '',
0x3: '',
0x4: '',
0x5: '',
0x6: '',
0x7: '',
0x8: '',
0x9: '',
0xA: '',
0xB: '',
0xC: '',
0xD: '',
0xE: '',
0xF: '',
}
2023-05-13 07:05:51 +03:00
def spacePad(num: int) -> str:
return ' ' * num
def blankLines(num: int, width: int) -> str:
lines = [spacePad(width) for _ in range(num)]
return '\n'.join(lines)
class FIGletFontWriter:
2023-05-13 10:18:20 +03:00
"""Used to write FIGlet fonts.
createFigFileData() returns a string that can be written to a .flf file.
It can automatically fix some common problems with FIGlet fonts, such as
incorrect character widths/heights, and missing lowercase characters.
"""
2023-05-13 07:05:51 +03:00
charOrder: list[int] = [ii for ii in range(32, 127)] + [196, 214, 220, 228, 246, 252, 223]
2023-05-13 10:18:20 +03:00
R"""Character codes that are required to be in any FIGlet font.
Printable portion of the ASCII character set:
32 (blank/space) 64 @ 96 `
33 ! 65 A 97 a
34 " 66 B 98 b
35 # 67 C 99 c
36 $ 68 D 100 d
37 % 69 E 101 e
38 & 70 F 102 f
39 ' 71 G 103 g
40 ( 72 H 104 h
41 ) 73 I 105 i
42 * 74 J 106 j
43 + 75 K 107 k
44 , 76 L 108 l
45 - 77 M 109 m
46 . 78 N 110 n
47 / 79 O 111 o
48 0 80 P 112 p
49 1 81 Q 113 q
50 2 82 R 114 r
51 3 83 S 115 s
52 4 84 T 116 t
53 5 85 U 117 u
54 6 86 V 118 v
55 7 87 W 119 w
56 8 88 X 120 x
57 9 89 Y 121 y
58 : 90 Z 122 z
59 ; 91 [ 123 {
60 < 92 \ 124 |
61 = 93 ] 125 }
62 > 94 ^ 126 ~
63 ? 95 _
Additional required Deutsch FIGcharacters, in order:
196 Ä (umlauted "A" -- two dots over letter "A")
214 Ö (umlauted "O" -- two dots over letter "O")
220 Ü (umlauted "U" -- two dots over letter "U")
228 ä (umlauted "a" -- two dots over letter "a")
246 ö (umlauted "o" -- two dots over letter "o")
252 ü (umlauted "u" -- two dots over letter "u")
223 ß ("ess-zed" -- see FIGcharacter illustration below)
___
/ _ \
| |/ /
Ess-zed >>---> | |\ \
| ||_/
|_|
Additional characters must use code tagged characters, which are not yet supported.
"""
2023-05-13 07:05:51 +03:00
def __init__(
self,
fontName: str,
figChars: dict[int, str],
height: int,
baseline: int,
maxLength: int,
commentLines: list[str],
rightToLeft: bool = False,
horizontalLayout: str = 'Universal Smushing',
verticalLayout: str = 'Universal Smushing',
codeTagCount: int = 0,
hardBlank: str = "$",
endMark: str = "@",
2023-05-13 09:55:15 +03:00
caseInsensitive: bool = False
2023-05-13 07:05:51 +03:00
):
self.fontName = fontName
2023-05-13 10:18:20 +03:00
"""Name of the font."""
2023-05-13 07:05:51 +03:00
self.figChars: dict[int, str] = figChars
2023-05-13 10:18:20 +03:00
"""Dictionary that maps character codes to FIGcharacter strings."""
2023-05-13 07:05:51 +03:00
self.height = height
2023-05-13 10:18:20 +03:00
"""Height of a FIGcharacter, in characters."""
2023-05-13 07:05:51 +03:00
self.baseline = baseline
2023-05-13 10:18:20 +03:00
"""Distance from the top of the character to the baseline. If not specified, defaults to height."""
2023-05-13 07:05:51 +03:00
self.maxLength = maxLength
2023-05-13 10:18:20 +03:00
"""Maximum length of a line INCLUDING two endMark characters."""
2023-05-13 07:05:51 +03:00
self.commentLines: list[str] = commentLines
2023-05-13 10:18:20 +03:00
"""List of comment lines to be included in the header. It's recommended to include at least the name of the font and the name of the author."""
2023-05-13 07:05:51 +03:00
self.rightToLeft = rightToLeft
2023-05-13 10:18:20 +03:00
"""Indicates RTL writing direction (or LTR if False)."""
2023-05-13 07:05:51 +03:00
self.codeTagCount = codeTagCount
2023-05-13 10:18:20 +03:00
"""Number of extra characters included in the font (in addition to the required 102 untagged characters). Outputting tagged characters is not yet supported."""
2023-05-13 07:05:51 +03:00
self.hardBlank = hardBlank
2023-05-13 10:18:20 +03:00
"""Invisible character used to prevent smushing."""
2023-05-13 07:05:51 +03:00
self.endMark = endMark
2023-05-13 10:18:20 +03:00
"""Denotes the end of a line. Two of these characters in a row denotes the end of a FIGcharacter."""
2023-05-13 07:05:51 +03:00
self.horizontalLayout = horizontalLayout
2023-05-13 10:18:20 +03:00
"""One of 'Full', 'Fitted', 'Universal Smushing', or 'Controlled Smushing'"""
2023-05-13 07:05:51 +03:00
self.verticalLayout = verticalLayout
2023-05-13 10:18:20 +03:00
"""One of 'Full', 'Fitted', 'Universal Smushing', or 'Controlled Smushing'"""
2023-05-13 07:05:51 +03:00
self.hRule = [False] * 7
2023-05-13 10:18:20 +03:00
"""Horizontal Smushing Rules, 1-6 (0 is not used, so that indices correspond with the names of the parameters). horizontalLayout must be 'Controlled Smushing' for these to take effect."""
2023-05-13 10:17:50 +03:00
self.vRule = [False] * 6
2023-05-13 10:18:20 +03:00
"""Vertical Smushing Rules, 1-5 (0 is not used, so that indices correspond with the names of the parameters). verticalLayout must be 'Controlled Smushing' for these to take effect."""
2023-05-13 09:55:15 +03:00
self.caseInsensitive = caseInsensitive
2023-05-13 10:18:20 +03:00
"""Makes lowercase same as uppercase. Note that this is one-way overwrite. It doesn't check if a character already exists, and it won't fill in uppercase using lowercase."""
2023-05-13 07:05:51 +03:00
def getOldLayoutValue(self) -> int:
val = 0
if self.horizontalLayout == 'Full':
return -1
elif self.horizontalLayout == 'Fitted':
return 0
elif self.horizontalLayout == 'Universal Smushing':
return 0
else:
val += 1 if self.hRule[1] else 0
val += 2 if self.hRule[2] else 0
val += 4 if self.hRule[3] else 0
val += 8 if self.hRule[4] else 0
val += 16 if self.hRule[5] else 0
val += 32 if self.hRule[6] else 0
return val
def getFullLayoutValue(self) -> int:
val = 0
# horizontal rules
if self.horizontalLayout == 'Full':
val += 0
elif self.horizontalLayout == 'Fitted':
val += 64
elif self.horizontalLayout == 'Universal Smushing':
val += 128
else:
val += 128
val += 1 if self.hRule[1] else 0
val += 2 if self.hRule[2] else 0
val += 4 if self.hRule[3] else 0
val += 8 if self.hRule[4] else 0
val += 16 if self.hRule[5] else 0
val += 32 if self.hRule[6] else 0
# vertical rules
if self.verticalLayout == 'Full':
val += 0
elif self.verticalLayout == 'Fitted':
val += 8192
elif self.verticalLayout == 'Universal Smushing':
val += 16384
else:
val += 16384
val += 256 if self.vRule[1] else 0
val += 512 if self.vRule[2] else 0
val += 1024 if self.vRule[3] else 0
val += 2048 if self.vRule[4] else 0
val += 4096 if self.vRule[5] else 0
return val
def generateFigFontHeader(self) -> str:
header: list[str] = []
baseline = self.baseline
if not baseline:
baseline = self.height
baseline = int(baseline)
if baseline <= 0 or baseline > self.height:
baseline = self.height
header.append('flf2a' + self.hardBlank)
header.append(str(self.height))
header.append(str(baseline))
header.append(str(self.maxLength))
header.append(str(self.getOldLayoutValue()))
header.append(str(len(self.commentLines)))
header.append("1" if self.rightToLeft else "0")
header.append(str(self.getFullLayoutValue()))
header.append(str(self.codeTagCount))
return ' '.join(header)
def fixFigChars(self):
height = 0
charWidth: dict[int, int] = {}
maxWidth = 0
# Fix case insensitivity
if self.caseInsensitive is True:
for ii in range(97, 123):
self.figChars[ii] = self.figChars[ii - 32]
# Calculate max height and ensure consistent width for each character
for idx in self.figChars:
figChar = self.figChars[idx].replace('\r\n', '\n').split('\n')
height = max(height, len(figChar))
charWidth[idx] = 0
for line in figChar:
charWidth[idx] = max(charWidth[idx], len(line))
for i in range(len(figChar)):
if len(figChar[i]) < charWidth[idx]:
figChar[i] += spacePad(charWidth[idx] - len(figChar[i]))
maxWidth = max(maxWidth, charWidth[idx])
self.figChars[idx] = '\n'.join(figChar)
# Fix any height issues
for idx in self.figChars:
figChar = self.figChars[idx].replace('\r\n', '\n').split('\n')
if len(figChar) < height:
self.figChars[idx] = '\n'.join(figChar) + '\n' + blankLines(height - len(figChar), charWidth[idx])
self.height = height
self.maxLength = maxWidth + 2
def createFigFileData(self) -> str:
2023-05-13 10:18:20 +03:00
"""Generates the FIGlet file data for the current font."""
2023-05-13 07:05:51 +03:00
output = ''
self.fixFigChars()
output = self.generateFigFontHeader() + '\n'
output += "\n".join(self.commentLines) + '\n'
for char in self.charOrder:
figChar = self.figChars.get(char)
if figChar is None:
raise Exception(f"Character {char} missing from figChars")
output += (self.endMark + '\n').join(figChar.split('\n'))
output += self.endMark + self.endMark + '\n'
return output
2023-05-13 01:18:17 +03:00
def extract_textures(image_path: str):
2023-05-13 10:18:20 +03:00
"""Removes the border around glyphs in an image, saves a new image without the border, and converts the image into FIGlet format font files."""
2023-05-13 01:18:17 +03:00
# Open the image
image = Image.open(image_path)
# Calculate the texture size and border width
width, height = image.size
texture_width = 4
texture_height = 4
border_width = 1
# Calculate the number of textures in each dimension
num_textures_x = (width - border_width) // (texture_width + border_width)
num_textures_y = (height - border_width) // (texture_height + border_width)
# Create a new image to store the extracted textures
extracted_image = Image.new('RGB', (num_textures_x * texture_width, num_textures_y * texture_height))
2023-05-13 07:05:51 +03:00
half_size_meta_glyphs = {}
full_size_meta_glyphs = {}
2023-05-13 01:18:17 +03:00
# Extract textures
for row in range(num_textures_y):
for col in range(num_textures_x):
# Calculate the coordinates for the current texture
left = col * (texture_width + border_width) + border_width
upper = row * (texture_height + border_width) + border_width
right = left + texture_width
lower = upper + texture_height
# Crop the texture from the original image
texture = image.crop((left, upper, right, lower))
# Calculate the paste coordinates on the extracted image
paste_x = col * texture_width
paste_y = row * texture_height
# Paste the texture onto the extracted image
extracted_image.paste(texture, (paste_x, paste_y))
2023-05-13 07:05:51 +03:00
# Calculate the ordinal of the character
ordinal = row * num_textures_x + col
ordinal -= 2
# Extract as half-size FIGlet font
extracted_text_half = ""
for y in range(0, texture_height, 2):
for x in range(0, texture_width, 2):
# Get the four pixels that make up a character
fg_palette_index = 1
aa = texture.getpixel((x, y)) == fg_palette_index
ab = texture.getpixel((x, y + 1)) == fg_palette_index
ba = texture.getpixel((x + 1, y)) == fg_palette_index
bb = texture.getpixel((x + 1, y + 1)) == fg_palette_index
# Convert the pixel to a character
# char = block_char_lookup[(aa << 3) | (ab << 2) | (ba << 1) | bb]
char = block_char_lookup[(bb << 3) | (ab << 2) | (ba << 1) | aa]
# Add the character to the extracted text
extracted_text_half += char
# Add a newline after each row
extracted_text_half += '\n'
2023-05-13 07:05:51 +03:00
half_size_meta_glyphs[ordinal] = extracted_text_half
# Extract as full-size FIGlet font
extracted_text_full = ""
for y in range(texture_height):
for x in range(texture_width):
# Get the pixel
fg_palette_index = 1
pixel = texture.getpixel((x, y)) == fg_palette_index
# Convert the pixel to a character
char = '' if pixel else ' '
# Add the character to the extracted text
extracted_text_full += char
# Add a newline after each row
extracted_text_full += '\n'
2023-05-13 07:05:51 +03:00
full_size_meta_glyphs[ordinal] = extracted_text_full
half_size_font = FIGletFontWriter(
fontName="NanoTiny 2x2",
figChars=half_size_meta_glyphs,
height=2,
baseline=2,
maxLength=2,
commentLines=[
"NanoTiny 2x2",
"by Isaiah Odhner",
],
horizontalLayout="Full",
verticalLayout="Full",
)
full_size_font = FIGletFontWriter(
fontName="NanoTiny 4x4",
figChars=full_size_meta_glyphs,
height=4,
baseline=4,
maxLength=4,
commentLines=[
"NanoTiny 4x4",
"by Isaiah Odhner",
],
horizontalLayout="Full",
verticalLayout="Full",
)
2023-05-13 07:05:51 +03:00
return extracted_image, half_size_font.createFigFileData(), full_size_font.createFigFileData()
2023-05-13 01:18:17 +03:00
base_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
samples_folder = os.path.join(base_folder, 'samples')
2023-05-13 01:18:17 +03:00
image_path = os.path.join(samples_folder, 'NanoTiny_v14.png')
output_path = os.path.join(samples_folder, 'NanoTiny_v14_no_border.png')
2023-05-13 07:05:51 +03:00
half_size_flf_output_path = os.path.join(base_folder, 'NanoTiny_v14_2x2.flf')
full_size_flf_output_path = os.path.join(base_folder, 'NanoTiny_v14_4x4.flf')
2023-05-13 01:18:17 +03:00
extracted_image, extracted_text_half, extracted_text_full = extract_textures(image_path)
2023-05-13 01:18:17 +03:00
extracted_image.save(output_path)
print(f'Wrote extracted textures to {output_path}')
2023-05-13 07:05:51 +03:00
with open(full_size_flf_output_path, 'w') as f:
f.write(extracted_text_full)
2023-05-13 07:05:51 +03:00
print(f'Wrote FIGlet font {full_size_flf_output_path}')
with open(half_size_flf_output_path, 'w') as f:
f.write(extracted_text_half)
2023-05-13 07:05:51 +03:00
print(f'Wrote FIGlet font {half_size_flf_output_path}')