Replace Gjallar synthesized fonts with Shinonome bitmaps
This commit is contained in:
parent
29dc7794fc
commit
b4734ddd0d
7 changed files with 178 additions and 193 deletions
|
|
@ -658,7 +658,12 @@ internal sealed class GjallarRenderer : IDisposable
|
|||
fonts = new
|
||||
{
|
||||
requestedPath = config.FontPath,
|
||||
packagedKanaPath = Path.Combine(AppContext.BaseDirectory, "assets", "fonts", "GjallarKana8x16.psf"),
|
||||
packagedFonts = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "assets", "fonts", "ShinonomeGothic12.psf"),
|
||||
Path.Combine(AppContext.BaseDirectory, "assets", "fonts", "ShinonomeGothic14.psf"),
|
||||
Path.Combine(AppContext.BaseDirectory, "assets", "fonts", "ShinonomeGothic16.psf"),
|
||||
},
|
||||
defaultFont = new { width = fonts.Default.Width, height = fonts.Default.Height, kana = fonts.Default.SupportsKana },
|
||||
edgeFont = new { width = fonts.Edge.Width, height = fonts.Edge.Height, kana = fonts.Edge.SupportsKana },
|
||||
loaded = fonts.Describe(),
|
||||
|
|
@ -2347,12 +2352,15 @@ internal sealed class FontAtlas
|
|||
|
||||
public static FontAtlas Load(string primary)
|
||||
{
|
||||
var packagedKanaPath = Path.Combine(AppContext.BaseDirectory, "assets", "fonts", "GjallarKana8x16.psf");
|
||||
var preferredCandidates = new[]
|
||||
var packagedCandidates = new[]
|
||||
{
|
||||
primary,
|
||||
packagedKanaPath,
|
||||
Path.Combine(AppContext.BaseDirectory, "assets", "fonts", "ShinonomeGothic12.psf"),
|
||||
Path.Combine(AppContext.BaseDirectory, "assets", "fonts", "ShinonomeGothic14.psf"),
|
||||
Path.Combine(AppContext.BaseDirectory, "assets", "fonts", "ShinonomeGothic16.psf"),
|
||||
};
|
||||
var preferredCandidates = string.IsNullOrWhiteSpace(primary)
|
||||
? packagedCandidates
|
||||
: new[] { primary }.Concat(packagedCandidates).ToArray();
|
||||
var fallbackCandidates = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "assets", "fonts", "Unifont-APL8x16.psf.gz"),
|
||||
|
|
@ -2396,13 +2404,7 @@ internal sealed class FontAtlas
|
|||
throw new InvalidOperationException("No PSF console fonts found; pass --font.");
|
||||
}
|
||||
|
||||
var source = loaded.OrderByDescending(font => font.SupportsKana).ThenBy(font => font.Width * font.Height).First();
|
||||
var generated = new[]
|
||||
{
|
||||
source.ShrinkTo(8, 12),
|
||||
source,
|
||||
};
|
||||
return new FontAtlas(generated
|
||||
return new FontAtlas(loaded
|
||||
.GroupBy(font => (font.Width, font.Height))
|
||||
.Select(group => group.OrderByDescending(font => font.SupportsKana).First())
|
||||
.ToArray());
|
||||
|
|
@ -2415,7 +2417,7 @@ internal sealed class FontAtlas
|
|||
var candidates = needsKana && fonts.Any(font => font.SupportsKana)
|
||||
? fonts.Where(font => font.SupportsKana)
|
||||
: fonts;
|
||||
var weightedPressure = Math.Clamp(weight / 6.0f, 0.0f, 2.5f);
|
||||
var weightedPressure = Math.Clamp(weight / 8.0f, 0.0f, 1.5f);
|
||||
foreach (var font in candidates.OrderByDescending(font => font.Width * font.Height))
|
||||
{
|
||||
var columns = Math.Max(1, width / Math.Max(1, font.Width));
|
||||
|
|
@ -2434,7 +2436,7 @@ internal sealed class FontAtlas
|
|||
|
||||
public PsfFont HeaderFor(int height, string text = "")
|
||||
{
|
||||
var target = height < 40 ? Math.Max(4, height / 2) : Math.Max(10, Math.Min(24, height / 5));
|
||||
var target = height < 40 ? Math.Max(8, height / 2) : Math.Max(12, Math.Min(32, height / 4));
|
||||
var candidates = ContainsKana(text) && fonts.Any(font => font.SupportsKana)
|
||||
? fonts.Where(font => font.SupportsKana)
|
||||
: fonts;
|
||||
|
|
@ -2508,17 +2510,6 @@ internal sealed class PsfFont
|
|||
Supports('\u30A2') &&
|
||||
Supports('\u30F3');
|
||||
|
||||
public PsfFont ShrinkTo(int width, int height)
|
||||
{
|
||||
var shrunk = new byte[glyphs.Length][];
|
||||
for (var glyphIndex = 0; glyphIndex < glyphs.Length; glyphIndex++)
|
||||
{
|
||||
shrunk[glyphIndex] = ShrinkGlyph(glyphs[glyphIndex], width, height);
|
||||
}
|
||||
|
||||
return new PsfFont(width, height, shrunk, unicodeMap);
|
||||
}
|
||||
|
||||
public static PsfFont Load(string path)
|
||||
{
|
||||
var bytes = ReadBytes(path);
|
||||
|
|
@ -2685,50 +2676,4 @@ internal sealed class PsfFont
|
|||
return true;
|
||||
}
|
||||
|
||||
private byte[] ShrinkGlyph(byte[] source, int width, int height)
|
||||
{
|
||||
var bytesPerRow = (width + 7) / 8;
|
||||
var target = new byte[height * bytesPerRow];
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
var sy0 = y * Height / height;
|
||||
var sy1 = Math.Max(sy0 + 1, (y + 1) * Height / height);
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
var sx0 = x * Width / width;
|
||||
var sx1 = Math.Max(sx0 + 1, (x + 1) * Width / width);
|
||||
var lit = 0;
|
||||
var total = 0;
|
||||
for (var sy = sy0; sy < sy1; sy++)
|
||||
{
|
||||
for (var sx = sx0; sx < sx1; sx++)
|
||||
{
|
||||
total++;
|
||||
if (IsSet(source, sx, sy))
|
||||
{
|
||||
lit++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lit * 2 >= Math.Max(1, total))
|
||||
{
|
||||
target[y * bytesPerRow + x / 8] |= (byte)(0x80 >> (x % 8));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
private bool IsSet(byte[] glyph, int x, int y)
|
||||
{
|
||||
if ((uint)x >= (uint)Width || (uint)y >= (uint)Height)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var offset = y * BytesPerRow + x / 8;
|
||||
return offset < glyph.Length && (glyph[offset] & (0x80 >> (x % 8))) != 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
BIN
src/Gjallar/assets/fonts/ShinonomeGothic12.psf
Normal file
BIN
src/Gjallar/assets/fonts/ShinonomeGothic12.psf
Normal file
Binary file not shown.
BIN
src/Gjallar/assets/fonts/ShinonomeGothic14.psf
Normal file
BIN
src/Gjallar/assets/fonts/ShinonomeGothic14.psf
Normal file
Binary file not shown.
BIN
src/Gjallar/assets/fonts/ShinonomeGothic16.psf
Normal file
BIN
src/Gjallar/assets/fonts/ShinonomeGothic16.psf
Normal file
Binary file not shown.
|
|
@ -1,122 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
PSF2_MAGIC = 0x864AB572
|
||||
UNICODE_TABLE_FLAG = 0x01
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Generate a fixed-cell PSF2 font from a TTF/OTF font.")
|
||||
parser.add_argument("--font", required=True, help="Source TTF/OTF path.")
|
||||
parser.add_argument("--out", required=True, help="Output PSF2 path.")
|
||||
parser.add_argument("--width", type=int, default=8, help="Cell width in pixels.")
|
||||
parser.add_argument("--height", type=int, default=16, help="Cell height in pixels.")
|
||||
parser.add_argument("--padding-x", type=int, default=1, help="Horizontal cell padding in pixels.")
|
||||
parser.add_argument("--padding-y", type=int, default=1, help="Vertical cell padding in pixels.")
|
||||
parser.add_argument("--source-size", type=int, default=96, help="Large source font size used before downscaling into the target cell.")
|
||||
args = parser.parse_args()
|
||||
|
||||
font_path = Path(args.font)
|
||||
out_path = Path(args.out)
|
||||
width = args.width
|
||||
height = args.height
|
||||
|
||||
codepoints = [0x0000]
|
||||
codepoints.extend(range(0x0020, 0x007F))
|
||||
codepoints.extend(range(0x00A0, 0x0100))
|
||||
codepoints.extend(range(0x2000, 0x2070))
|
||||
codepoints.extend(range(0x3000, 0x3040))
|
||||
codepoints.extend(range(0x3040, 0x30A0))
|
||||
codepoints.extend(range(0x30A0, 0x3100))
|
||||
codepoints.extend(range(0xFF00, 0xFFEF + 1))
|
||||
|
||||
font = ImageFont.truetype(str(font_path), args.source_size)
|
||||
glyphs = [
|
||||
render_glyph(font, cp, width, height, args.padding_x, args.padding_y)
|
||||
for cp in codepoints
|
||||
]
|
||||
|
||||
bytes_per_row = (width + 7) // 8
|
||||
char_size = bytes_per_row * height
|
||||
header = struct.pack(
|
||||
"<IIIIIIII",
|
||||
PSF2_MAGIC,
|
||||
0,
|
||||
32,
|
||||
UNICODE_TABLE_FLAG,
|
||||
len(glyphs),
|
||||
char_size,
|
||||
height,
|
||||
width,
|
||||
)
|
||||
unicode_table = b"".join(
|
||||
chr(cp).encode("utf-8", "strict") + b"\xFF"
|
||||
for cp in codepoints
|
||||
)
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out_path.open("wb") as handle:
|
||||
handle.write(header)
|
||||
for glyph in glyphs:
|
||||
handle.write(glyph)
|
||||
handle.write(unicode_table)
|
||||
|
||||
print(
|
||||
f"wrote {out_path} from {font_path.name} at source {args.source_size}px "
|
||||
f"with {len(glyphs)} glyphs ({width}x{height})"
|
||||
)
|
||||
|
||||
def render_glyph(
|
||||
font: ImageFont.FreeTypeFont,
|
||||
codepoint: int,
|
||||
width: int,
|
||||
height: int,
|
||||
padding_x: int,
|
||||
padding_y: int,
|
||||
) -> bytes:
|
||||
ch = chr(codepoint)
|
||||
work = Image.new("L", (font.size * 2, font.size * 2), 0)
|
||||
draw = ImageDraw.Draw(work)
|
||||
bbox = font.getbbox(ch)
|
||||
if bbox is not None:
|
||||
left, top, right, bottom = bbox
|
||||
x = max(0, (work.width - (right - left)) // 2 - left)
|
||||
y = max(0, (work.height - (bottom - top)) // 2 - top)
|
||||
draw.text((x, y), ch, fill=255, font=font)
|
||||
|
||||
glyph_box = work.getbbox()
|
||||
if glyph_box is None:
|
||||
fitted = Image.new("L", (width, height), 0)
|
||||
else:
|
||||
cropped = work.crop(glyph_box)
|
||||
inner_width = max(1, width - padding_x * 2)
|
||||
inner_height = max(1, height - padding_y * 2)
|
||||
scale = min(inner_width / cropped.width, inner_height / cropped.height)
|
||||
scaled_width = max(1, min(inner_width, round(cropped.width * scale)))
|
||||
scaled_height = max(1, min(inner_height, round(cropped.height * scale)))
|
||||
resampled = cropped.resize((scaled_width, scaled_height), Image.Resampling.LANCZOS)
|
||||
fitted = Image.new("L", (width, height), 0)
|
||||
paste_x = max(0, (width - scaled_width) // 2)
|
||||
paste_y = max(0, (height - scaled_height) // 2)
|
||||
fitted.paste(resampled, (paste_x, paste_y))
|
||||
|
||||
bytes_per_row = (width + 7) // 8
|
||||
packed = bytearray(bytes_per_row * height)
|
||||
pixels = fitted.load()
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
if pixels[x, y] < 96:
|
||||
continue
|
||||
offset = y * bytes_per_row + x // 8
|
||||
packed[offset] |= 0x80 >> (x % 8)
|
||||
return bytes(packed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
162
tools/generate_psf_from_shinonome.py
Normal file
162
tools/generate_psf_from_shinonome.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PSF2_MAGIC = 0x864AB572
|
||||
UNICODE_TABLE_FLAG = 0x01
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Build a fixed-cell PSF2 font from authored Shinonome bitmap sources.")
|
||||
parser.add_argument("--latin", required=True, help="Path to Shinonome latin1 font_src.bit.")
|
||||
parser.add_argument("--kanji", required=True, help="Path to Shinonome kanjic font_src.bit.")
|
||||
parser.add_argument("--out", required=True, help="Output PSF2 path.")
|
||||
parser.add_argument("--cell-width", type=int, required=True, help="Fixed cell width in pixels.")
|
||||
parser.add_argument("--cell-height", type=int, required=True, help="Fixed cell height in pixels.")
|
||||
args = parser.parse_args()
|
||||
|
||||
latin_font = parse_bit_font(Path(args.latin))
|
||||
kanji_font = parse_bit_font(Path(args.kanji))
|
||||
|
||||
glyph_map: dict[int, Glyph] = {}
|
||||
for codepoint, glyph in latin_font.glyphs.items():
|
||||
glyph_map[codepoint] = pad_glyph(glyph, args.cell_width, args.cell_height)
|
||||
for codepoint, glyph in kanji_font.glyphs.items():
|
||||
glyph_map[codepoint] = pad_glyph(glyph, args.cell_width, args.cell_height)
|
||||
|
||||
codepoints = sorted(glyph_map)
|
||||
bytes_per_row = (args.cell_width + 7) // 8
|
||||
char_size = bytes_per_row * args.cell_height
|
||||
header = struct.pack(
|
||||
"<IIIIIIII",
|
||||
PSF2_MAGIC,
|
||||
0,
|
||||
32,
|
||||
UNICODE_TABLE_FLAG,
|
||||
len(codepoints),
|
||||
char_size,
|
||||
args.cell_height,
|
||||
args.cell_width,
|
||||
)
|
||||
unicode_table = b"".join(chr(codepoint).encode("utf-8") + b"\xFF" for codepoint in codepoints)
|
||||
|
||||
out_path = Path(args.out)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out_path.open("wb") as handle:
|
||||
handle.write(header)
|
||||
for codepoint in codepoints:
|
||||
handle.write(pack_glyph(glyph_map[codepoint], args.cell_width, args.cell_height))
|
||||
handle.write(unicode_table)
|
||||
|
||||
print(
|
||||
f"wrote {out_path} with {len(codepoints)} glyphs "
|
||||
f"from {Path(args.latin).name} + {Path(args.kanji).name} "
|
||||
f"into {args.cell_width}x{args.cell_height} cells"
|
||||
)
|
||||
|
||||
|
||||
class Glyph:
|
||||
def __init__(self, width: int, height: int, rows: list[int]) -> None:
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.rows = rows
|
||||
|
||||
|
||||
class BitFont:
|
||||
def __init__(self, width: int, height: int, glyphs: dict[int, Glyph]) -> None:
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.glyphs = glyphs
|
||||
|
||||
|
||||
def parse_bit_font(path: Path) -> BitFont:
|
||||
width = 0
|
||||
height = 0
|
||||
encoding: int | None = None
|
||||
bitmap_lines: list[str] = []
|
||||
in_bitmap = False
|
||||
glyphs: dict[int, Glyph] = {}
|
||||
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.rstrip("\n")
|
||||
if line.startswith("FONTBOUNDINGBOX "):
|
||||
_, w, h, *_ = line.split()
|
||||
width = int(w)
|
||||
height = int(h)
|
||||
elif line.startswith("ENCODING "):
|
||||
encoding = int(line.split()[1])
|
||||
elif line == "BITMAP":
|
||||
in_bitmap = True
|
||||
bitmap_lines = []
|
||||
elif line.startswith("ENDCHAR"):
|
||||
if encoding is None:
|
||||
raise ValueError(f"missing ENCODING before ENDCHAR in {path}")
|
||||
glyphs.update(decode_glyph(encoding, width, height, bitmap_lines))
|
||||
encoding = None
|
||||
in_bitmap = False
|
||||
bitmap_lines = []
|
||||
elif in_bitmap:
|
||||
bitmap_lines.append(line)
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError(f"missing FONTBOUNDINGBOX in {path}")
|
||||
return BitFont(width, height, glyphs)
|
||||
|
||||
|
||||
def decode_glyph(encoding: int, width: int, height: int, bitmap_lines: list[str]) -> dict[int, Glyph]:
|
||||
glyph = Glyph(width, height, [parse_row(line, width) for line in bitmap_lines[:height]])
|
||||
codepoint = decode_encoding(encoding)
|
||||
return {} if codepoint is None else {codepoint: glyph}
|
||||
|
||||
|
||||
def decode_encoding(encoding: int) -> int | None:
|
||||
if 0 <= encoding <= 0xFF:
|
||||
return encoding
|
||||
|
||||
row = (encoding >> 8) & 0xFF
|
||||
cell = encoding & 0xFF
|
||||
try:
|
||||
text = bytes([row + 0x80, cell + 0x80]).decode("euc_jp")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
return ord(text) if len(text) == 1 else None
|
||||
|
||||
|
||||
def parse_row(line: str, width: int) -> int:
|
||||
bits = 0
|
||||
for index, ch in enumerate(line[:width]):
|
||||
if ch not in {".", " "}:
|
||||
bits |= 1 << (width - index - 1)
|
||||
return bits
|
||||
|
||||
|
||||
def pad_glyph(glyph: Glyph, cell_width: int, cell_height: int) -> Glyph:
|
||||
if glyph.width > cell_width or glyph.height > cell_height:
|
||||
raise ValueError(
|
||||
f"glyph {glyph.width}x{glyph.height} does not fit inside target cell {cell_width}x{cell_height}"
|
||||
)
|
||||
|
||||
offset_x = max(0, (cell_width - glyph.width) // 2)
|
||||
offset_y = max(0, cell_height - glyph.height)
|
||||
padded = [0 for _ in range(cell_height)]
|
||||
for row_index, row_bits in enumerate(glyph.rows):
|
||||
padded[offset_y + row_index] = row_bits << (cell_width - glyph.width - offset_x)
|
||||
return Glyph(cell_width, cell_height, padded)
|
||||
|
||||
|
||||
def pack_glyph(glyph: Glyph, width: int, height: int) -> bytes:
|
||||
bytes_per_row = (width + 7) // 8
|
||||
packed = bytearray(bytes_per_row * height)
|
||||
for y in range(height):
|
||||
row_bits = glyph.rows[y]
|
||||
for x in range(width):
|
||||
if row_bits & (1 << (width - x - 1)):
|
||||
packed[y * bytes_per_row + x // 8] |= 0x80 >> (x % 8)
|
||||
return bytes(packed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in a new issue