Replace Gjallar synthesized fonts with Shinonome bitmaps

This commit is contained in:
Meta Vens 2026-06-15 00:25:51 +01:00
commit b4734ddd0d
7 changed files with 178 additions and 193 deletions

View file

@ -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.

Binary file not shown.

Binary file not shown.

View file

@ -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()

View 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()