diff --git a/src/Gjallar/Program.cs b/src/Gjallar/Program.cs index a0ff1ae..2cdd964 100644 --- a/src/Gjallar/Program.cs +++ b/src/Gjallar/Program.cs @@ -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; - } } diff --git a/src/Gjallar/assets/fonts/GjallarKana8x16.psf b/src/Gjallar/assets/fonts/GjallarKana8x16.psf deleted file mode 100644 index e3f3df7..0000000 Binary files a/src/Gjallar/assets/fonts/GjallarKana8x16.psf and /dev/null differ diff --git a/src/Gjallar/assets/fonts/ShinonomeGothic12.psf b/src/Gjallar/assets/fonts/ShinonomeGothic12.psf new file mode 100644 index 0000000..d1df610 Binary files /dev/null and b/src/Gjallar/assets/fonts/ShinonomeGothic12.psf differ diff --git a/src/Gjallar/assets/fonts/ShinonomeGothic14.psf b/src/Gjallar/assets/fonts/ShinonomeGothic14.psf new file mode 100644 index 0000000..81d4e9e Binary files /dev/null and b/src/Gjallar/assets/fonts/ShinonomeGothic14.psf differ diff --git a/src/Gjallar/assets/fonts/ShinonomeGothic16.psf b/src/Gjallar/assets/fonts/ShinonomeGothic16.psf new file mode 100644 index 0000000..926548f Binary files /dev/null and b/src/Gjallar/assets/fonts/ShinonomeGothic16.psf differ diff --git a/tools/generate_psf2_from_ttf.py b/tools/generate_psf2_from_ttf.py deleted file mode 100644 index 7ce0d15..0000000 --- a/tools/generate_psf2_from_ttf.py +++ /dev/null @@ -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( - " 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() diff --git a/tools/generate_psf_from_shinonome.py b/tools/generate_psf_from_shinonome.py new file mode 100644 index 0000000..9cbe5e0 --- /dev/null +++ b/tools/generate_psf_from_shinonome.py @@ -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( + " 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()