diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 744fb44..c9e0ec6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,6 +79,8 @@ jobs: permissions: contents: write steps: + - uses: actions/checkout@v4 + - name: Download artifacts uses: actions/download-artifact@v4 with: @@ -89,6 +91,35 @@ jobs: mkdir -p release find dist -maxdepth 3 -type f -print -exec cp {} release/ \; + - name: Package release archives + run: | + set -euo pipefail + + version="${GITHUB_REF_NAME}" + mkdir -p packaged + + for file in release/*; do + base="$(basename "${file}")" + stem="${base%.exe}" + stage_dir="$(mktemp -d)" + + cp "${file}" "${stage_dir}/" + cp README.md LICENSE THIRD_PARTY_NOTICES.md "${stage_dir}/" + + if [[ "${base}" == *.exe ]]; then + asset="packaged/${stem}-${version}.zip" + ( + cd "${stage_dir}" + zip -q -r "${GITHUB_WORKSPACE}/${asset}" . + ) + else + asset="packaged/${stem}-${version}.tar.gz" + tar -C "${stage_dir}" -czf "${asset}" . + fi + + rm -rf "${stage_dir}" + done + - name: Publish GitHub release uses: softprops/action-gh-release@v2 with: @@ -96,6 +127,6 @@ jobs: name: ${{ github.ref_name }} generate_release_notes: true fail_on_unmatched_files: true - files: release/* + files: packaged/* env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index bf018ed..0b33d36 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .vscode/* out_font/* +*.xnb *.fnt /ra2fnt /ra2fnt.exe diff --git a/README.md b/README.md index d8455ea..207207f 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,12 @@ Create `.fnt` from PNG set: ./ra2fnt create -in out_font -out rebuilt.fnt ``` +Create an experimental LZ4-compressed `SpriteFont XNB v5` font file compatible with [`xna-cncnet-client`](https://github.com/CnCNet/xna-cncnet-client) from PNG set: + +```bash +./ra2fnt create -in out_font -out SpriteFont.xnb --format spritefont-xnb +``` + Create without glyph deduplication: ```bash @@ -107,7 +113,7 @@ Zero-width glyphs are listed only in `metadata.json` (`symbol_width`). ## Create behavior -`create` reconstructs a `.fnt` from PNG files in the input directory: +`create` reconstructs a font file from PNG files in the input directory: - PNG files are discovered recursively (subdirectory names are ignored by parser). - Files must be named as fixed-length hex codepoints (for example `0x0041.png`, `0x30A1.png`). @@ -120,6 +126,9 @@ Zero-width glyphs are listed only in `metadata.json` (`symbol_width`). - `ideograph_width` is taken from `metadata.json`. - `scale` is taken from `metadata.json`; when `scale > 1`, PNG dimensions are downscaled by this factor during `create` (back to normal font size). - Identical glyphs are deduplicated, so multiple codepoints can reference the same symbol index. +- Output format is selected by `--format`: + - `fnt` (default): writes Westwood Unicode BitFont `.fnt` + - `spritefont-xnb`: writes an experimental LZ4-compressed `SpriteFont XNB v5` `.xnb` font file compatible with [`xna-cncnet-client`](https://github.com/CnCNet/xna-cncnet-client) - Use `--no-dedup` to disable deduplication. - Unicode table is rebuilt from filenames (`0xXXXX` -> symbol index in sorted codepoint order). @@ -141,3 +150,11 @@ Because unicode mapping order/tail bytes are rebuilt, the resulting `.fnt` is no - At least one non-zero-width PNG is required to infer `symbol_height`. - Zero-width glyphs are represented only in `metadata.json` and have no PNG files. - Unicode table order is rebuilt from sorted codepoints. +- `spritefont-xnb` always writes LZ4-compressed `SpriteFont XNB v5`. +- `spritefont-xnb` is an experimental feature. +- When `?` is present, `spritefont-xnb` writes it as `defaultChar` fallback. + +## License + +- Project license: `MIT` (see `LICENSE`) +- Third-party notices: `THIRD_PARTY_NOTICES.md` diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..381a4e5 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,41 @@ +# Third-Party Notices + +This project is licensed under the MIT License. It also includes or depends on third-party components under their own licenses. + +## `github.com/pierrec/lz4/v4` + +- Project: `github.com/pierrec/lz4/v4` +- License: `BSD-3-Clause` +- Source: `https://github.com/pierrec/lz4` + +License text: + +```text +Copyright (c) 2015, Pierre Curto +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of xxHash nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` diff --git a/go.mod b/go.mod index 6bb2bc5..b9e58ac 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module ra2fnt go 1.22 + +require github.com/pierrec/lz4/v4 v4.1.26 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dafceee --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= diff --git a/src/cmd/ra2fnt/main.go b/src/cmd/ra2fnt/main.go index b86e696..2ced9dc 100644 --- a/src/cmd/ra2fnt/main.go +++ b/src/cmd/ra2fnt/main.go @@ -9,6 +9,7 @@ import ( "strings" "ra2fnt/src/internal/fnt" + "ra2fnt/src/internal/fontout" "ra2fnt/src/internal/pngset" ) @@ -75,6 +76,7 @@ func (bar *progressBar) Update(stage string, done, total int) { func (bar *progressBar) Finish() { if bar.printed { fmt.Fprintln(os.Stderr) + bar.printed = false } } @@ -168,6 +170,7 @@ func runExport(args []string) error { }); err != nil { return err } + progress.Finish() mappedCodepoints := 0 for _, symbolIndex := range font.UnicodeTable { @@ -176,8 +179,9 @@ func runExport(args []string) error { } } - fmt.Printf( - " exported %d codepoints to %s (source symbols: %d)\n", + fmt.Fprintf( + os.Stderr, + "exported %d codepoints to %s (source symbols: %d)\n", mappedCodepoints, *outDir, font.SymbolsCount, @@ -224,7 +228,8 @@ func ensureExportOutDir(outDir string, input io.Reader, output io.Writer, force func runCreate(args []string) error { fs := flag.NewFlagSet("create", flag.ContinueOnError) inDir := fs.String("in", "", "input directory created by export") - outPath := fs.String("out", "", "output .fnt file") + outPath := fs.String("out", "", "output font file") + format := fs.String("format", fontout.FormatFNT, "create output format: fnt, spritefont-xnb") noDedup := fs.Bool("no-dedup", false, "disable glyph deduplication") fs.SetOutput(os.Stderr) @@ -235,6 +240,10 @@ func runCreate(args []string) error { fs.Usage() return fmt.Errorf("-in and -out are required") } + outputFormat, err := fontout.NormalizeFormat(*format) + if err != nil { + return err + } options := pngset.ImportOptions{} options.DisableDedup = *noDedup @@ -247,15 +256,18 @@ func runCreate(args []string) error { if err != nil { return err } - if err := fnt.WriteFile(*outPath, font); err != nil { + if err := fontout.WriteFile(*outPath, font, outputFormat); err != nil { return err } + progress.Finish() - fmt.Printf( - "created %d symbols from %d codepoints in %s (deduplicated: %d)\n", + fmt.Fprintf( + os.Stderr, + "created %d symbols from %d codepoints in %s (%s, deduplicated: %d)\n", report.UniqueSymbols, report.Codepoints, *outPath, + outputFormat, report.DeduplicatedSymbols, ) return nil @@ -282,7 +294,8 @@ func runValidate(args []string) error { return err } - fmt.Printf( + fmt.Fprintf( + os.Stderr, "validation passed: codepoints=%d, png=%d, zero_width=%d, symbols=%d, deduplicated=%d\n", report.Codepoints, report.PNGFiles, @@ -296,7 +309,7 @@ func runValidate(args []string) error { func usage() { fmt.Fprintf(os.Stderr, "Usage:\n") fmt.Fprintf(os.Stderr, " %s export -in game.fnt -out out_dir [--scale N] [--force]\n", os.Args[0]) - fmt.Fprintf(os.Stderr, " %s create -in out_dir -out rebuilt.fnt\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s create -in out_dir -out output_file [--format fnt|spritefont-xnb]\n", os.Args[0]) fmt.Fprintf(os.Stderr, " %s validate -in out_dir\n", os.Args[0]) fmt.Fprintf(os.Stderr, " %s version\n", os.Args[0]) fmt.Fprintf(os.Stderr, " %s --version\n", os.Args[0]) diff --git a/src/cmd/ra2fnt/main_test.go b/src/cmd/ra2fnt/main_test.go index ccbf027..fd45077 100644 --- a/src/cmd/ra2fnt/main_test.go +++ b/src/cmd/ra2fnt/main_test.go @@ -2,13 +2,18 @@ package main import ( "bytes" + "encoding/binary" "image/png" + "io" "os" "path/filepath" "strings" "testing" + "github.com/pierrec/lz4/v4" + "ra2fnt/src/internal/fnt" + "ra2fnt/src/internal/fontout" ) func TestEnsureExportOutDirNotExists(t *testing.T) { @@ -220,6 +225,62 @@ func TestRunValidateAcceptsNoDedupFlag(t *testing.T) { } } +func TestRunCreateSpriteFontXNB(t *testing.T) { + root := t.TempDir() + inPath := filepath.Join(root, "in.fnt") + outDir := filepath.Join(root, "out") + outPath := filepath.Join(root, "font.xnb") + if err := writeSampleFont(inPath); err != nil { + t.Fatalf("write sample font: %v", err) + } + + if err := runExport([]string{"-in", inPath, "-out", outDir}); err != nil { + t.Fatalf("runExport: %v", err) + } + if err := runCreate([]string{"-in", outDir, "-out", outPath, "-format", fontout.FormatSpriteFontXNB}); err != nil { + t.Fatalf("runCreate spritefont-xnb: %v", err) + } + + raw, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("read created xnb: %v", err) + } + if got, want := string(raw[:3]), "XNB"; got != want { + t.Fatalf("xnb magic mismatch: got=%q want=%q", got, want) + } + if got, want := raw[3], byte('w'); got != want { + t.Fatalf("xnb platform mismatch: got=%q want=%q", got, want) + } + if got, want := raw[4], byte(5); got != want { + t.Fatalf("xnb version mismatch: got=%d want=%d", got, want) + } + if got, want := raw[5], byte(0x40); got != want { + t.Fatalf("xnb flags mismatch: got=%d want=%d", got, want) + } + if got, want := int(binary.LittleEndian.Uint32(raw[10:14])), len(decompressLZ4Block(t, raw[14:], int(binary.LittleEndian.Uint32(raw[10:14])))); got != want { + t.Fatalf("xnb decompressed size mismatch: got=%d want=%d", got, want) + } +} + +func TestRunCreateRejectsUnsupportedFormat(t *testing.T) { + root := t.TempDir() + inPath := filepath.Join(root, "in.fnt") + outDir := filepath.Join(root, "out") + outPath := filepath.Join(root, "font.bin") + if err := writeSampleFont(inPath); err != nil { + t.Fatalf("write sample font: %v", err) + } + + if err := runExport([]string{"-in", inPath, "-out", outDir}); err != nil { + t.Fatalf("runExport: %v", err) + } + + err := runCreate([]string{"-in", outDir, "-out", outPath, "-format", "unknown"}) + if err == nil || !strings.Contains(err.Error(), "unsupported create format") { + t.Fatalf("expected unsupported format error, got: %v", err) + } +} + func writeSampleFont(path string) error { font := &fnt.Font{ IdeographWidth: 8, @@ -247,3 +308,47 @@ func TestVersionString(t *testing.T) { t.Fatalf("version string mismatch: got=%q want=%q", got, want) } } + +func TestProgressBarFinishIsIdempotent(t *testing.T) { + readPipe, writePipe, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + defer readPipe.Close() + + oldStderr := os.Stderr + os.Stderr = writePipe + defer func() { + os.Stderr = oldStderr + }() + + bar := newProgressBar("create") + bar.printed = true + bar.Finish() + bar.Finish() + + if err := writePipe.Close(); err != nil { + t.Fatalf("close write pipe: %v", err) + } + + output, err := io.ReadAll(readPipe) + if err != nil { + t.Fatalf("read stderr: %v", err) + } + if got, want := string(output), "\n"; got != want { + t.Fatalf("unexpected finish output: got=%q want=%q", got, want) + } +} + +func decompressLZ4Block(t *testing.T, src []byte, size int) []byte { + t.Helper() + dst := make([]byte, size) + n, err := lz4.UncompressBlock(src, dst) + if err != nil { + t.Fatalf("lz4.UncompressBlock: %v", err) + } + if got, want := n, size; got != want { + t.Fatalf("decompressed size mismatch: got=%d want=%d", got, want) + } + return dst +} diff --git a/src/internal/fontout/fontout.go b/src/internal/fontout/fontout.go new file mode 100644 index 0000000..5047f5c --- /dev/null +++ b/src/internal/fontout/fontout.go @@ -0,0 +1,55 @@ +package fontout + +import ( + "fmt" + "strings" + + "ra2fnt/src/internal/fnt" + "ra2fnt/src/internal/spritefontxnb" +) + +const ( + FormatFNT = "fnt" + FormatSpriteFontXNB = "spritefont-xnb" +) + +var supportedFormats = []string{ + FormatFNT, + FormatSpriteFontXNB, +} + +func SupportedFormats() []string { + out := make([]string, len(supportedFormats)) + copy(out, supportedFormats) + return out +} + +func NormalizeFormat(value string) (string, error) { + format := strings.TrimSpace(strings.ToLower(value)) + if format == "" { + format = FormatFNT + } + + switch format { + case FormatFNT, FormatSpriteFontXNB: + return format, nil + default: + return "", fmt.Errorf("unsupported create format %q (supported: %s)", value, strings.Join(supportedFormats, ", ")) + } +} + +func WriteFile(path string, font *fnt.Font, format string) error { + resolvedFormat, err := NormalizeFormat(format) + if err != nil { + return err + } + + switch resolvedFormat { + case FormatFNT: + return fnt.WriteFile(path, font) + case FormatSpriteFontXNB: + return spritefontxnb.WriteFile(path, font) + default: + return fmt.Errorf("unsupported create format %q", resolvedFormat) + } +} diff --git a/src/internal/spritefontxnb/spritefontxnb.go b/src/internal/spritefontxnb/spritefontxnb.go new file mode 100644 index 0000000..6fa5719 --- /dev/null +++ b/src/internal/spritefontxnb/spritefontxnb.go @@ -0,0 +1,703 @@ +package spritefontxnb + +import ( + "bytes" + "encoding/binary" + "fmt" + "image" + "math" + "os" + "sort" + "unicode/utf8" + + "github.com/pierrec/lz4/v4" + + "ra2fnt/src/internal/fnt" +) + +const ( + xnbMagic = "XNB" + xnbPlatformWindows = 'w' + xnbVersion5 = 5 + xnbFlagsCompressed = 0x40 + xnbHeaderSize = 10 + xnbCompressedHeaderSize = xnbHeaderSize + 4 + surfaceFormatDXT3 int32 = 5 + + defaultCharacter rune = '?' + + // Match the spacing used by the client-side reference SpriteFont files. + spriteFontGlyphGap float32 = 1 + + // Reference SpriteFont atlases in the client use 2048px width. + maxAtlasWidth = 2048 + // Keep a small gutter between glyphs inside the atlas texture. + atlasPadding = 1 + // Start with at least four rows worth of width before packing more tightly. + minAtlasWidthFactor = 4 + // Reference-like placeholder image for invisible glyphs with non-zero advance. + placeholderGlyphSize = 1 + + dxtBlockSize = 4 + dxtBlockBytes = 16 + dxtColorWhite = 0xFFFF + dxtColorBlack = 0x0000 + + lz4CompressionLevel = lz4.Level9 +) + +const ( + typeReaderSpriteFont = "Microsoft.Xna.Framework.Content.SpriteFontReader, Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553" + typeReaderTexture2D = "Microsoft.Xna.Framework.Content.Texture2DReader, Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553" + typeReaderListRect = "Microsoft.Xna.Framework.Content.ListReader`1[[Microsoft.Xna.Framework.Rectangle, Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553]]" + typeReaderRect = "Microsoft.Xna.Framework.Content.RectangleReader" + typeReaderListChar = "Microsoft.Xna.Framework.Content.ListReader`1[[System.Char, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]" + typeReaderChar = "Microsoft.Xna.Framework.Content.CharReader" + typeReaderListVec3 = "Microsoft.Xna.Framework.Content.ListReader`1[[Microsoft.Xna.Framework.Vector3, Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553]]" + typeReaderVec3 = "Microsoft.Xna.Framework.Content.Vector3Reader" +) + +const ( + rootReaderIndex = 1 + textureReaderIndex = 2 + rectListReaderIndex = 3 + charListReaderIndex = 5 + vec3ListReaderIndex = 7 +) + +var typeReaders = []string{ + typeReaderSpriteFont, + typeReaderTexture2D, + typeReaderListRect, + typeReaderRect, + typeReaderListChar, + typeReaderChar, + typeReaderListVec3, + typeReaderVec3, +} + +type glyph struct { + codepoint uint16 + img *image.Alpha + glyphRect rect + cropRect rect + kerning vec3 +} + +type atlasLayout struct { + glyphs []glyph + width int + height int +} + +type rect struct { + X int32 + Y int32 + Width int32 + Height int32 +} + +type vec3 struct { + X float32 + Y float32 + Z float32 +} + +type writer struct { + buf bytes.Buffer +} + +func WriteFile(path string, font *fnt.Font) error { + raw, err := MarshalBinary(font) + if err != nil { + return err + } + if err := os.WriteFile(path, raw, 0o644); err != nil { + return fmt.Errorf("write %q: %w", path, err) + } + return nil +} + +func MarshalBinary(font *fnt.Font) ([]byte, error) { + if err := validateFont(font); err != nil { + return nil, err + } + + glyphs, defaultChar, hasDefaultChar, err := buildGlyphs(font) + if err != nil { + return nil, err + } + + layout, err := layoutGlyphs(glyphs, int(font.SymbolHeight)) + if err != nil { + return nil, err + } + + atlas := renderAtlas(layout) + + payload, err := marshalContent(layout.glyphs, atlas, int32(font.FontHeight), defaultChar, hasDefaultChar) + if err != nil { + return nil, err + } + + return marshalCompressedXNB(payload) +} + +func validateFont(font *fnt.Font) error { + if font == nil { + return fmt.Errorf("font is nil") + } + if font.SymbolHeight == 0 { + return fmt.Errorf("symbol_height must be > 0") + } + if font.FontHeight == 0 { + return fmt.Errorf("font_height must be > 0") + } + if font.SymbolStride == 0 { + return fmt.Errorf("symbol_stride must be > 0") + } + if len(font.Symbols) != int(font.SymbolsCount) { + return fmt.Errorf("symbols count mismatch: header=%d actual=%d", font.SymbolsCount, len(font.Symbols)) + } + + rawSymbolSizeU64 := uint64(font.SymbolStride) * uint64(font.SymbolHeight) + if rawSymbolSizeU64 > math.MaxInt { + return fmt.Errorf("symbol data size is too large: %d bytes", rawSymbolSizeU64) + } + rawSymbolSize := int(rawSymbolSizeU64) + expectedSymbolDataSize := uint32(rawSymbolSize + 1) + if font.SymbolDataSize != expectedSymbolDataSize { + return fmt.Errorf( + "symbol_data_size mismatch: got %d want %d", + font.SymbolDataSize, + expectedSymbolDataSize, + ) + } + + for i, symbol := range font.Symbols { + if len(symbol.Data) != rawSymbolSize { + return fmt.Errorf( + "symbol %d has invalid data size: got %d want %d", + i, + len(symbol.Data), + rawSymbolSize, + ) + } + } + + return nil +} + +func marshalCompressedXNB(payload []byte) ([]byte, error) { + compressedPayload, err := compressContent(payload) + if err != nil { + return nil, err + } + + xnb := make([]byte, 0, xnbCompressedHeaderSize+len(compressedPayload)) + xnb = append(xnb, xnbMagic...) + xnb = append(xnb, byte(xnbPlatformWindows)) + xnb = append(xnb, byte(xnbVersion5)) + xnb = append(xnb, byte(xnbFlagsCompressed)) + + var raw [4]byte + binary.LittleEndian.PutUint32(raw[:], uint32(xnbCompressedHeaderSize+len(compressedPayload))) + xnb = append(xnb, raw[:]...) + binary.LittleEndian.PutUint32(raw[:], uint32(len(payload))) + xnb = append(xnb, raw[:]...) + xnb = append(xnb, compressedPayload...) + + return xnb, nil +} + +func compressContent(payload []byte) ([]byte, error) { + if len(payload) == 0 { + return nil, fmt.Errorf("content payload is empty") + } + + dst := make([]byte, lz4.CompressBlockBound(len(payload))) + n, err := lz4.CompressBlockHC(payload, dst, lz4CompressionLevel, nil, nil) + if err != nil { + return nil, fmt.Errorf("compress SpriteFont XNB payload with LZ4: %w", err) + } + if n <= 0 { + return nil, fmt.Errorf("compress SpriteFont XNB payload with LZ4: compression returned %d bytes", n) + } + return dst[:n], nil +} + +func buildGlyphs(font *fnt.Font) ([]glyph, rune, bool, error) { + codepoints := make([]uint16, 0) + for codepoint, symbolIndex := range font.UnicodeTable { + if symbolIndex != 0 { + codepoints = append(codepoints, uint16(codepoint)) + } + } + sort.Slice(codepoints, func(i, j int) bool { + return codepoints[i] < codepoints[j] + }) + if len(codepoints) == 0 { + return nil, 0, false, fmt.Errorf("font has no mapped codepoints") + } + + fullWidth := int(font.SymbolStride) * 8 + glyphs := make([]glyph, 0, len(codepoints)) + hasDefaultChar := false + for _, codepoint := range codepoints { + if codepoint >= 0xD800 && codepoint <= 0xDFFF { + return nil, 0, false, fmt.Errorf("spritefont-xnb does not support surrogate codepoint U+%04X", codepoint) + } + + symbolIndex := int(font.UnicodeTable[codepoint]) - 1 + if symbolIndex < 0 || symbolIndex >= len(font.Symbols) { + return nil, 0, false, fmt.Errorf( + "unicode table maps U+%04X to invalid symbol index %d (symbols=%d)", + codepoint, + symbolIndex, + len(font.Symbols), + ) + } + + symbol := font.Symbols[symbolIndex] + width := int(symbol.Width) + if width > fullWidth { + return nil, 0, false, fmt.Errorf("symbol %d width=%d exceeds stride width=%d", symbolIndex, width, fullWidth) + } + + glyph, err := symbolToGlyph(codepoint, symbol.Data, int(font.SymbolStride), int(font.SymbolHeight), width) + if err != nil { + return nil, 0, false, fmt.Errorf("build SpriteFont glyph for U+%04X: %w", codepoint, err) + } + glyphs = append(glyphs, glyph) + hasDefaultChar = hasDefaultChar || rune(codepoint) == defaultCharacter + } + + return glyphs, defaultCharacter, hasDefaultChar, nil +} + +func symbolToGlyph(codepoint uint16, data []byte, stride, height, width int) (glyph, error) { + if stride <= 0 || height <= 0 { + return glyph{}, fmt.Errorf("invalid symbol dimensions: stride=%d height=%d", stride, height) + } + if width < 0 || width > stride*8 { + return glyph{}, fmt.Errorf("invalid symbol width=%d for stride=%d", width, stride) + } + if len(data) != stride*height { + return glyph{}, fmt.Errorf("invalid symbol data size: got %d, expected %d", len(data), stride*height) + } + + if width <= 0 { + return glyph{ + codepoint: codepoint, + cropRect: rect{ + Height: int32(height), + }, + }, nil + } + + bounds, hasPixels := symbolVisibleBounds(data, stride, height, width) + if !hasPixels { + return newInvisibleGlyphPlaceholder(codepoint, width, height), nil + } + + return newVisibleGlyph(codepoint, data, stride, height, bounds), nil +} + +// MonoGame reference SpriteFonts use a 1x1 placeholder texture region for glyphs that +// have advance width but no visible pixels, while keeping the logical width in crop/kerning. +func newInvisibleGlyphPlaceholder(codepoint uint16, width, height int) glyph { + return glyph{ + codepoint: codepoint, + img: image.NewAlpha(image.Rect(0, 0, placeholderGlyphSize, placeholderGlyphSize)), + cropRect: rect{ + X: int32(maxInt(width-placeholderGlyphSize, 0)), + Y: int32(height), + Width: int32(width), + Height: int32(height), + }, + kerning: vec3{ + X: 0, + Y: placeholderGlyphSize, + Z: float32(maxInt(width-placeholderGlyphSize, 0)), + }, + } +} + +func newVisibleGlyph(codepoint uint16, data []byte, stride, height int, bounds image.Rectangle) glyph { + img := rasterizeGlyph(data, stride, bounds) + visibleWidth := bounds.Dx() + + return glyph{ + codepoint: codepoint, + img: img, + cropRect: rect{ + X: 0, + Y: int32(bounds.Min.Y), + Width: int32(visibleWidth), + Height: int32(height), + }, + kerning: vec3{ + X: float32(bounds.Min.X), + Y: float32(visibleWidth), + Z: spriteFontGlyphGap, + }, + } +} + +func rasterizeGlyph(data []byte, stride int, bounds image.Rectangle) *image.Alpha { + img := image.NewAlpha(image.Rect(0, 0, bounds.Dx(), bounds.Dy())) + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + row := data[y*stride : (y+1)*stride] + dstOffset := (y - bounds.Min.Y) * img.Stride + for x := bounds.Min.X; x < bounds.Max.X; x++ { + if row[x/8]&(1<<(7-uint(x%8))) != 0 { + img.Pix[dstOffset+(x-bounds.Min.X)] = 0xFF + } + } + } + return img +} + +func symbolVisibleBounds(data []byte, stride, height, width int) (image.Rectangle, bool) { + minX := width + minY := height + maxX := -1 + maxY := -1 + + for y := 0; y < height; y++ { + row := data[y*stride : (y+1)*stride] + for x := 0; x < width; x++ { + if row[x/8]&(1<<(7-uint(x%8))) == 0 { + continue + } + if x < minX { + minX = x + } + if x > maxX { + maxX = x + } + if y < minY { + minY = y + } + if y > maxY { + maxY = y + } + } + } + if maxX < 0 || maxY < 0 { + return image.Rectangle{}, false + } + return image.Rect(minX, minY, maxX+1, maxY+1), true +} + +func layoutGlyphs(glyphs []glyph, symbolHeight int) (atlasLayout, error) { + if len(glyphs) == 0 { + return atlasLayout{}, fmt.Errorf("no glyphs to pack") + } + + laidOut := append([]glyph(nil), glyphs...) + atlasWidth := chooseAtlasWidth(laidOut, symbolHeight) + x := 0 + y := 0 + rowHeight := 0 + atlasHeight := 0 + placedGlyphs := 0 + + for i := range laidOut { + if laidOut[i].img == nil { + continue + } + w := laidOut[i].img.Bounds().Dx() + h := laidOut[i].img.Bounds().Dy() + if w <= 0 || h <= 0 { + continue + } + if h > rowHeight { + rowHeight = h + } + if x > 0 && x+w > atlasWidth { + x = 0 + y += rowHeight + atlasPadding + rowHeight = h + } + + laidOut[i].glyphRect = rect{ + X: int32(x), + Y: int32(y), + Width: int32(w), + Height: int32(h), + } + + x += w + atlasPadding + placedGlyphs++ + if candidate := y + h; candidate > atlasHeight { + atlasHeight = candidate + } + } + + if placedGlyphs == 0 { + atlasWidth = dxtBlockSize + atlasHeight = dxtBlockSize + } + + return atlasLayout{ + glyphs: laidOut, + width: maxInt(atlasWidth, dxtBlockSize), + height: maxInt(nextPowerOfTwo(maxInt(atlasHeight, 1)), dxtBlockSize), + }, nil +} + +func renderAtlas(layout atlasLayout) *image.Alpha { + atlas := image.NewAlpha(image.Rect(0, 0, layout.width, layout.height)) + for _, glyph := range layout.glyphs { + if glyph.img == nil { + continue + } + minX := int(glyph.glyphRect.X) + minY := int(glyph.glyphRect.Y) + bounds := glyph.img.Bounds() + for y := 0; y < bounds.Dy(); y++ { + dstOffset := (minY+y)*atlas.Stride + minX + srcOffset := y * glyph.img.Stride + copy(atlas.Pix[dstOffset:dstOffset+bounds.Dx()], glyph.img.Pix[srcOffset:srcOffset+bounds.Dx()]) + } + } + return atlas +} + +func chooseAtlasWidth(glyphs []glyph, symbolHeight int) int { + maxWidth := dxtBlockSize + totalArea := 0 + hasImages := false + for _, glyph := range glyphs { + if glyph.img == nil { + continue + } + w := glyph.img.Bounds().Dx() + h := glyph.img.Bounds().Dy() + if w > maxWidth { + maxWidth = w + } + totalArea += (w + atlasPadding) * (h + atlasPadding) + hasImages = true + } + if !hasImages { + return dxtBlockSize + } + + target := int(math.Ceil(math.Sqrt(float64(totalArea)))) + if target < maxWidth { + target = maxWidth + } + minTarget := symbolHeight * minAtlasWidthFactor + if target < minTarget { + target = minTarget + } + if target > maxAtlasWidth { + target = maxAtlasWidth + } + return nextPowerOfTwo(target) +} + +func nextPowerOfTwo(value int) int { + if value <= 1 { + return 1 + } + result := 1 + for result < value { + result <<= 1 + } + return result +} + +func marshalContent(glyphs []glyph, atlas *image.Alpha, lineSpacing int32, defaultChar rune, hasDefaultChar bool) ([]byte, error) { + var w writer + + w.write7BitEncodedInt(len(typeReaders)) + for _, readerType := range typeReaders { + w.writeString(readerType) + w.writeInt32(0) + } + + w.write7BitEncodedInt(0) // shared resources + + w.write7BitEncodedInt(rootReaderIndex) + if err := w.writeTexture2D(atlas); err != nil { + return nil, err + } + w.writeGlyphRectList(rectListReaderIndex, glyphs) + w.writeGlyphCropList(rectListReaderIndex, glyphs) + w.writeGlyphCharList(charListReaderIndex, glyphs) + w.writeInt32(lineSpacing) + w.writeFloat32(0) + w.writeGlyphKerningList(vec3ListReaderIndex, glyphs) + w.writeOptionalChar(hasDefaultChar, defaultChar) + + return w.buf.Bytes(), nil +} + +func (w *writer) writeTexture2D(img *image.Alpha) error { + raw, err := compressDXT3(img) + if err != nil { + return err + } + + w.write7BitEncodedInt(textureReaderIndex) + w.writeInt32(surfaceFormatDXT3) + w.writeInt32(int32(img.Bounds().Dx())) + w.writeInt32(int32(img.Bounds().Dy())) + w.writeInt32(1) + w.writeInt32(int32(len(raw))) + w.writeBytes(raw) + return nil +} + +func compressDXT3(img *image.Alpha) ([]byte, error) { + bounds := img.Bounds() + if bounds.Dx()%dxtBlockSize != 0 || bounds.Dy()%dxtBlockSize != 0 { + return nil, fmt.Errorf( + "DXT3 image dimensions must be multiples of %d, got %dx%d", + dxtBlockSize, + bounds.Dx(), + bounds.Dy(), + ) + } + + blockCountX := bounds.Dx() / dxtBlockSize + blockCountY := bounds.Dy() / dxtBlockSize + raw := make([]byte, 0, blockCountX*blockCountY*dxtBlockBytes) + + for by := bounds.Min.Y; by < bounds.Max.Y; by += dxtBlockSize { + for bx := bounds.Min.X; bx < bounds.Max.X; bx += dxtBlockSize { + raw = append(raw, encodeDXT3Block(img, bx, by)...) + } + } + return raw, nil +} + +func encodeDXT3Block(img *image.Alpha, startX, startY int) []byte { + var block [dxtBlockBytes]byte + var colorIndices uint32 + + for y := 0; y < dxtBlockSize; y++ { + var row uint16 + offset := (startY+y)*img.Stride + startX + for x := 0; x < dxtBlockSize; x++ { + alpha := img.Pix[offset+x] + nibble := uint16((uint32(alpha) * 15) / 255) + row |= nibble << (x * 4) + if alpha == 0 { + colorIndices |= 1 << ((y*dxtBlockSize + x) * 2) + } + } + binary.LittleEndian.PutUint16(block[y*2:], row) + } + + binary.LittleEndian.PutUint16(block[8:], dxtColorWhite) + binary.LittleEndian.PutUint16(block[10:], dxtColorBlack) + binary.LittleEndian.PutUint32(block[12:], colorIndices) + return block[:] +} + +func (w *writer) writeGlyphRectList(readerIndex int, glyphs []glyph) { + w.write7BitEncodedInt(readerIndex) + w.writeInt32(int32(len(glyphs))) + for _, glyph := range glyphs { + w.writeRect(glyph.glyphRect) + } +} + +func (w *writer) writeGlyphCropList(readerIndex int, glyphs []glyph) { + w.write7BitEncodedInt(readerIndex) + w.writeInt32(int32(len(glyphs))) + for _, glyph := range glyphs { + w.writeRect(glyph.cropRect) + } +} + +func (w *writer) writeGlyphCharList(readerIndex int, glyphs []glyph) { + w.write7BitEncodedInt(readerIndex) + w.writeInt32(int32(len(glyphs))) + for _, glyph := range glyphs { + w.writeChar(rune(glyph.codepoint)) + } +} + +func (w *writer) writeGlyphKerningList(readerIndex int, glyphs []glyph) { + w.write7BitEncodedInt(readerIndex) + w.writeInt32(int32(len(glyphs))) + for _, glyph := range glyphs { + w.writeVec3(glyph.kerning) + } +} + +func (w *writer) writeRect(value rect) { + w.writeInt32(value.X) + w.writeInt32(value.Y) + w.writeInt32(value.Width) + w.writeInt32(value.Height) +} + +func (w *writer) writeVec3(value vec3) { + w.writeFloat32(value.X) + w.writeFloat32(value.Y) + w.writeFloat32(value.Z) +} + +func (w *writer) writeBytes(value []byte) { + _, _ = w.buf.Write(value) +} + +func (w *writer) writeOptionalChar(hasValue bool, value rune) { + if hasValue { + w.writeByte(1) + w.writeChar(value) + return + } + w.writeByte(0) +} + +func (w *writer) writeByte(value byte) { + w.buf.WriteByte(value) +} + +func (w *writer) writeUInt32(value uint32) { + var raw [4]byte + binary.LittleEndian.PutUint32(raw[:], value) + w.writeBytes(raw[:]) +} + +func (w *writer) writeInt32(value int32) { + w.writeUInt32(uint32(value)) +} + +func (w *writer) writeFloat32(value float32) { + w.writeUInt32(math.Float32bits(value)) +} + +func (w *writer) writeString(value string) { + raw := []byte(value) + w.write7BitEncodedInt(len(raw)) + w.writeBytes(raw) +} + +func (w *writer) writeChar(value rune) { + var raw [utf8.UTFMax]byte + size := utf8.EncodeRune(raw[:], value) + w.writeBytes(raw[:size]) +} + +func (w *writer) write7BitEncodedInt(value int) { + for value >= 0x80 { + w.writeByte(byte(value) | 0x80) + value >>= 7 + } + w.writeByte(byte(value)) +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/src/internal/spritefontxnb/spritefontxnb_test.go b/src/internal/spritefontxnb/spritefontxnb_test.go new file mode 100644 index 0000000..2c3053f --- /dev/null +++ b/src/internal/spritefontxnb/spritefontxnb_test.go @@ -0,0 +1,578 @@ +package spritefontxnb + +import ( + "bytes" + "encoding/binary" + "image" + "math" + "testing" + "unicode/utf8" + + "github.com/pierrec/lz4/v4" + + "ra2fnt/src/internal/fnt" +) + +func TestBuildGlyphsMatchesReferenceModel(t *testing.T) { + font := sampleReferenceLikeFont() + + glyphs, defaultChar, hasDefaultChar, err := buildGlyphs(font) + if err != nil { + t.Fatalf("buildGlyphs: %v", err) + } + + if got, want := len(glyphs), 4; got != want { + t.Fatalf("glyph count mismatch: got=%d want=%d", got, want) + } + if !hasDefaultChar { + t.Fatalf("expected default character to be present") + } + if got, want := defaultChar, rune('?'); got != want { + t.Fatalf("default character mismatch: got=%q want=%q", got, want) + } + + space := glyphs[0] + if got, want := rune(space.codepoint), rune(' '); got != want { + t.Fatalf("space codepoint mismatch: got=%q want=%q", got, want) + } + if space.img == nil { + t.Fatalf("space placeholder glyph image is nil") + } + if got, want := space.img.Bounds().Dx(), 1; got != want { + t.Fatalf("space placeholder width mismatch: got=%d want=%d", got, want) + } + if got, want := space.img.Bounds().Dy(), 1; got != want { + t.Fatalf("space placeholder height mismatch: got=%d want=%d", got, want) + } + if got, want := space.cropRect, (rect{X: 2, Y: 4, Width: 3, Height: 4}); got != want { + t.Fatalf("space crop rect mismatch: got=%+v want=%+v", got, want) + } + if got, want := space.kerning, (vec3{X: 0, Y: 1, Z: 2}); got != want { + t.Fatalf("space kerning mismatch: got=%+v want=%+v", got, want) + } + + question := glyphs[1] + if got, want := rune(question.codepoint), rune('?'); got != want { + t.Fatalf("question codepoint mismatch: got=%q want=%q", got, want) + } + if question.img == nil { + t.Fatalf("question glyph image is nil") + } + if got, want := question.img.Bounds().Dx(), 2; got != want { + t.Fatalf("question tight width mismatch: got=%d want=%d", got, want) + } + if got, want := question.img.Bounds().Dy(), 2; got != want { + t.Fatalf("question tight height mismatch: got=%d want=%d", got, want) + } + if got, want := question.cropRect, (rect{X: 0, Y: 0, Width: 2, Height: 4}); got != want { + t.Fatalf("question crop rect mismatch: got=%+v want=%+v", got, want) + } + if got, want := question.kerning, (vec3{X: 0, Y: 2, Z: 1}); got != want { + t.Fatalf("question kerning mismatch: got=%+v want=%+v", got, want) + } + + letterA := glyphs[2] + if got, want := rune(letterA.codepoint), rune('A'); got != want { + t.Fatalf("A codepoint mismatch: got=%q want=%q", got, want) + } + if letterA.img == nil { + t.Fatalf("A glyph image is nil") + } + if got, want := letterA.img.Bounds().Dx(), 2; got != want { + t.Fatalf("A tight width mismatch: got=%d want=%d", got, want) + } + if got, want := letterA.img.Bounds().Dy(), 2; got != want { + t.Fatalf("A tight height mismatch: got=%d want=%d", got, want) + } + if got, want := letterA.cropRect, (rect{X: 0, Y: 1, Width: 2, Height: 4}); got != want { + t.Fatalf("A crop rect mismatch: got=%+v want=%+v", got, want) + } + if got, want := letterA.kerning, (vec3{X: 1, Y: 2, Z: 1}); got != want { + t.Fatalf("A kerning mismatch: got=%+v want=%+v", got, want) + } + + zeroWidth := glyphs[3] + if got, want := rune(zeroWidth.codepoint), rune('B'); got != want { + t.Fatalf("B codepoint mismatch: got=%q want=%q", got, want) + } + if zeroWidth.img != nil { + t.Fatalf("zero-width glyph should not have atlas image") + } + if got, want := zeroWidth.cropRect, (rect{X: 0, Y: 0, Width: 0, Height: 4}); got != want { + t.Fatalf("zero-width crop rect mismatch: got=%+v want=%+v", got, want) + } + if got, want := zeroWidth.kerning, (vec3{}); got != want { + t.Fatalf("zero-width kerning mismatch: got=%+v want=%+v", got, want) + } +} + +func TestMarshalBinaryWritesReferenceLikeSpriteFontXNB(t *testing.T) { + font := sampleReferenceLikeFont() + + raw, err := MarshalBinary(font) + if err != nil { + t.Fatalf("MarshalBinary: %v", err) + } + + if got, want := string(raw[:3]), xnbMagic; got != want { + t.Fatalf("magic mismatch: got=%q want=%q", got, want) + } + if got, want := raw[3], byte(xnbPlatformWindows); got != want { + t.Fatalf("platform mismatch: got=%q want=%q", got, want) + } + if got, want := raw[4], byte(xnbVersion5); got != want { + t.Fatalf("version mismatch: got=%d want=%d", got, want) + } + if got, want := raw[5], byte(xnbFlagsCompressed); got != want { + t.Fatalf("flags mismatch: got=%d want=%d", got, want) + } + if got, want := binary.LittleEndian.Uint32(raw[6:10]), uint32(len(raw)); got != want { + t.Fatalf("file size mismatch: got=%d want=%d", got, want) + } + decompressedSize := int(binary.LittleEndian.Uint32(raw[10:14])) + if decompressedSize <= 0 { + t.Fatalf("invalid decompressed payload size: %d", decompressedSize) + } + + decompressedPayload := make([]byte, decompressedSize) + n, err := lz4.UncompressBlock(raw[14:], decompressedPayload) + if err != nil { + t.Fatalf("lz4.UncompressBlock: %v", err) + } + if got, want := n, decompressedSize; got != want { + t.Fatalf("decompressed payload size mismatch: got=%d want=%d", got, want) + } + + parser := newTestParser(t, decompressedPayload) + typeReaderCount := parser.read7BitEncodedInt() + if got, want := typeReaderCount, 8; got != want { + t.Fatalf("type reader count mismatch: got=%d want=%d", got, want) + } + for i := 0; i < typeReaderCount; i++ { + _ = parser.readString() + if got := parser.readInt32(); got != 0 { + t.Fatalf("type reader %d version mismatch: got=%d want=0", i, got) + } + } + + if got := parser.read7BitEncodedInt(); got != 0 { + t.Fatalf("shared resource count mismatch: got=%d want=0", got) + } + if got := parser.read7BitEncodedInt(); got != 1 { + t.Fatalf("root object type reader mismatch: got=%d want=1", got) + } + if got := parser.read7BitEncodedInt(); got != 2 { + t.Fatalf("texture object type reader mismatch: got=%d want=2", got) + } + if got, want := int32(parser.readInt32()), surfaceFormatDXT3; got != want { + t.Fatalf("surface format mismatch: got=%d want=%d", got, want) + } + + atlasWidth := parser.readInt32() + atlasHeight := parser.readInt32() + if atlasWidth < 2 || atlasHeight < 2 { + t.Fatalf("atlas dimensions too small: %dx%d", atlasWidth, atlasHeight) + } + if !isPowerOfTwo(atlasWidth) || !isPowerOfTwo(atlasHeight) { + t.Fatalf("atlas dimensions must be power-of-two: %dx%d", atlasWidth, atlasHeight) + } + if got := parser.readInt32(); got != 1 { + t.Fatalf("mip level count mismatch: got=%d want=1", got) + } + + levelDataSize := parser.readInt32() + if got, want := levelDataSize, atlasWidth*atlasHeight; got != want { + t.Fatalf("texture byte size mismatch: got=%d want=%d", got, want) + } + parser.skip(levelDataSize) + + if got := parser.read7BitEncodedInt(); got != 3 { + t.Fatalf("glyph rect list reader mismatch: got=%d want=3", got) + } + glyphRects := parser.readRects() + if got, want := len(glyphRects), 4; got != want { + t.Fatalf("glyph rect count mismatch: got=%d want=%d", got, want) + } + if got, want := glyphRects[0].Width, int32(1); got != want { + t.Fatalf("space glyph rect width mismatch: got=%d want=%d", got, want) + } + if got, want := glyphRects[1].Height, int32(2); got != want { + t.Fatalf("question glyph rect height mismatch: got=%d want=%d", got, want) + } + if got, want := glyphRects[2].Width, int32(2); got != want { + t.Fatalf("A glyph rect width mismatch: got=%d want=%d", got, want) + } + if got, want := glyphRects[3].Width, int32(0); got != want { + t.Fatalf("zero-width glyph rect width mismatch: got=%d want=%d", got, want) + } + + if got := parser.read7BitEncodedInt(); got != 3 { + t.Fatalf("crop rect list reader mismatch: got=%d want=3", got) + } + cropRects := parser.readRects() + if got, want := cropRects[0], (rect{X: 2, Y: 4, Width: 3, Height: 4}); got != want { + t.Fatalf("space crop rect mismatch: got=%+v want=%+v", got, want) + } + if got, want := cropRects[2], (rect{X: 0, Y: 1, Width: 2, Height: 4}); got != want { + t.Fatalf("A crop rect mismatch: got=%+v want=%+v", got, want) + } + if got, want := cropRects[3].Width, int32(0); got != want { + t.Fatalf("zero-width crop rect width mismatch: got=%d want=%d", got, want) + } + + if got := parser.read7BitEncodedInt(); got != 5 { + t.Fatalf("char list reader mismatch: got=%d want=5", got) + } + chars := parser.readChars() + if got, want := string(chars), " ?AB"; got != want { + t.Fatalf("char list mismatch: got=%q want=%q", got, want) + } + + if got, want := parser.readInt32(), int(font.FontHeight); got != want { + t.Fatalf("line spacing mismatch: got=%d want=%d", got, want) + } + if got := parser.readFloat32(); got != 0 { + t.Fatalf("spacing mismatch: got=%f want=0", got) + } + + if got := parser.read7BitEncodedInt(); got != 7 { + t.Fatalf("kerning list reader mismatch: got=%d want=7", got) + } + kernings := parser.readVec3s() + if got, want := len(kernings), 4; got != want { + t.Fatalf("kerning count mismatch: got=%d want=%d", got, want) + } + if got, want := kernings[0], (vec3{X: 0, Y: 1, Z: 2}); got != want { + t.Fatalf("space kerning mismatch: got=%+v want=%+v", got, want) + } + if got, want := kernings[2], (vec3{X: 1, Y: 2, Z: 1}); got != want { + t.Fatalf("A kerning mismatch: got=%+v want=%+v", got, want) + } + if got, want := kernings[3], (vec3{}); got != want { + t.Fatalf("zero-width kerning mismatch: got=%+v want=%+v", got, want) + } + + hasDefaultChar, defaultChar := parser.readOptionalChar() + if !hasDefaultChar { + t.Fatalf("expected default character to be present") + } + if got, want := defaultChar, rune('?'); got != want { + t.Fatalf("default character mismatch: got=%q want=%q", got, want) + } + + if parser.remaining() != 0 { + t.Fatalf("unexpected trailing payload bytes: %d", parser.remaining()) + } +} + +func TestMarshalBinaryOmitsDefaultCharWhenQuestionIsMissing(t *testing.T) { + font := &fnt.Font{ + IdeographWidth: 8, + SymbolStride: 1, + SymbolHeight: 2, + FontHeight: 3, + SymbolsCount: 1, + SymbolDataSize: 3, + Symbols: []fnt.Symbol{ + {Width: 1, Data: []byte{0b1000_0000, 0x00}}, + }, + } + font.UnicodeTable['A'] = 1 + + raw, err := MarshalBinary(font) + if err != nil { + t.Fatalf("MarshalBinary: %v", err) + } + + decompressedPayload := decompressXNBPayload(t, raw) + parser := newTestParser(t, decompressedPayload) + typeReaderCount := parser.read7BitEncodedInt() + for i := 0; i < typeReaderCount; i++ { + _ = parser.readString() + _ = parser.readInt32() + } + _ = parser.read7BitEncodedInt() + _ = parser.read7BitEncodedInt() + _ = parser.read7BitEncodedInt() + _ = parser.readInt32() + atlasWidth := parser.readInt32() + atlasHeight := parser.readInt32() + _ = parser.readInt32() + parser.skip(parser.readInt32()) + _ = atlasWidth + _ = atlasHeight + _ = parser.read7BitEncodedInt() + _ = parser.readRects() + _ = parser.read7BitEncodedInt() + _ = parser.readRects() + _ = parser.read7BitEncodedInt() + _ = parser.readChars() + _ = parser.readInt32() + _ = parser.readFloat32() + _ = parser.read7BitEncodedInt() + _ = parser.readVec3s() + + hasDefaultChar, _ := parser.readOptionalChar() + if hasDefaultChar { + t.Fatalf("expected default character to be absent") + } +} + +func TestMarshalBinaryRejectsSurrogateCodepoints(t *testing.T) { + font := &fnt.Font{ + IdeographWidth: 8, + SymbolStride: 1, + SymbolHeight: 1, + FontHeight: 1, + SymbolsCount: 1, + SymbolDataSize: 2, + Symbols: []fnt.Symbol{ + {Width: 1, Data: []byte{0b1000_0000}}, + }, + } + font.UnicodeTable[0xD800] = 1 + + _, err := MarshalBinary(font) + if err == nil { + t.Fatalf("expected surrogate validation error") + } +} + +func TestCompressDXT3EncodesExplicitAlphaAndWhiteColor(t *testing.T) { + img := image.NewAlpha(image.Rect(0, 0, 4, 4)) + img.Pix[0] = 0xFF + img.Pix[1] = 0x80 + img.Pix[4] = 0xFF + + raw, err := compressDXT3(img) + if err != nil { + t.Fatalf("compressDXT3: %v", err) + } + if got, want := len(raw), 16; got != want { + t.Fatalf("compressed size mismatch: got=%d want=%d", got, want) + } + + expected := []byte{ + 0x7F, 0x00, + 0x0F, 0x00, + 0x00, 0x00, + 0x00, 0x00, + 0xFF, 0xFF, + 0x00, 0x00, + 0x50, 0x54, 0x55, 0x55, + } + if got, want := raw, expected; !bytes.Equal(got, want) { + t.Fatalf("compressed block mismatch:\n got=%v\nwant=%v", got, want) + } +} + +func TestCompressDXT3RejectsNonBlockAlignedImage(t *testing.T) { + img := image.NewAlpha(image.Rect(0, 0, 5, 4)) + + _, err := compressDXT3(img) + if err == nil { + t.Fatalf("expected block alignment error") + } +} + +func TestLayoutGlyphsDoesNotMutateInput(t *testing.T) { + font := sampleReferenceLikeFont() + glyphs, _, _, err := buildGlyphs(font) + if err != nil { + t.Fatalf("buildGlyphs: %v", err) + } + + original := append([]glyph(nil), glyphs...) + layout, err := layoutGlyphs(glyphs, int(font.SymbolHeight)) + if err != nil { + t.Fatalf("layoutGlyphs: %v", err) + } + + if len(layout.glyphs) != len(glyphs) { + t.Fatalf("layout glyph count mismatch: got=%d want=%d", len(layout.glyphs), len(glyphs)) + } + for i := range glyphs { + if got, want := glyphs[i].glyphRect, original[i].glyphRect; got != want { + t.Fatalf("input glyph mutated at %d: got=%+v want=%+v", i, got, want) + } + } +} + +func sampleReferenceLikeFont() *fnt.Font { + font := &fnt.Font{ + IdeographWidth: 8, + SymbolStride: 1, + SymbolHeight: 4, + FontHeight: 5, + SymbolsCount: 4, + SymbolDataSize: 5, + Symbols: []fnt.Symbol{ + {Width: 3, Data: []byte{0x00, 0x00, 0x00, 0x00}}, + {Width: 2, Data: []byte{0b1100_0000, 0b0100_0000, 0x00, 0x00}}, + {Width: 4, Data: []byte{0x00, 0b0110_0000, 0b0010_0000, 0x00}}, + {Width: 0, Data: []byte{0x00, 0x00, 0x00, 0x00}}, + }, + } + font.UnicodeTable[' '] = 1 + font.UnicodeTable['?'] = 2 + font.UnicodeTable['A'] = 3 + font.UnicodeTable['B'] = 4 + return font +} + +func decompressXNBPayload(t *testing.T, raw []byte) []byte { + t.Helper() + if len(raw) < xnbCompressedHeaderSize { + t.Fatalf("compressed XNB too short: %d", len(raw)) + } + decompressedSize := int(binary.LittleEndian.Uint32(raw[10:14])) + if decompressedSize <= 0 { + t.Fatalf("invalid decompressed payload size: %d", decompressedSize) + } + decompressedPayload := make([]byte, decompressedSize) + n, err := lz4.UncompressBlock(raw[14:], decompressedPayload) + if err != nil { + t.Fatalf("lz4.UncompressBlock: %v", err) + } + if got, want := n, decompressedSize; got != want { + t.Fatalf("decompressed payload size mismatch: got=%d want=%d", got, want) + } + return decompressedPayload +} + +type testParser struct { + t *testing.T + raw []byte + pos int +} + +func newTestParser(t *testing.T, raw []byte) *testParser { + t.Helper() + return &testParser{t: t, raw: raw} +} + +func (p *testParser) remaining() int { + return len(p.raw) - p.pos +} + +func (p *testParser) readByte() byte { + p.t.Helper() + if p.pos >= len(p.raw) { + p.t.Fatalf("unexpected end of payload") + } + value := p.raw[p.pos] + p.pos++ + return value +} + +func (p *testParser) read7BitEncodedInt() int { + value := 0 + shift := 0 + for { + b := p.readByte() + value |= int(b&0x7F) << shift + if b&0x80 == 0 { + return value + } + shift += 7 + } +} + +func (p *testParser) readString() string { + size := p.read7BitEncodedInt() + p.t.Helper() + if p.pos+size > len(p.raw) { + p.t.Fatalf("string exceeds payload size: pos=%d size=%d len=%d", p.pos, size, len(p.raw)) + } + value := string(p.raw[p.pos : p.pos+size]) + p.pos += size + return value +} + +func (p *testParser) readInt32() int { + p.t.Helper() + if p.pos+4 > len(p.raw) { + p.t.Fatalf("int32 exceeds payload size: pos=%d len=%d", p.pos, len(p.raw)) + } + value := int(binary.LittleEndian.Uint32(p.raw[p.pos : p.pos+4])) + p.pos += 4 + return value +} + +func (p *testParser) readFloat32() float32 { + p.t.Helper() + if p.pos+4 > len(p.raw) { + p.t.Fatalf("float32 exceeds payload size: pos=%d len=%d", p.pos, len(p.raw)) + } + value := math.Float32frombits(binary.LittleEndian.Uint32(p.raw[p.pos : p.pos+4])) + p.pos += 4 + return value +} + +func (p *testParser) readChar() rune { + p.t.Helper() + if p.pos >= len(p.raw) { + p.t.Fatalf("char exceeds payload size") + } + value, size := utf8.DecodeRune(p.raw[p.pos:]) + if value == utf8.RuneError && size == 1 { + p.t.Fatalf("invalid utf-8 char at pos=%d", p.pos) + } + p.pos += size + return value +} + +func (p *testParser) readOptionalChar() (bool, rune) { + if p.readByte() == 0 { + return false, 0 + } + return true, p.readChar() +} + +func (p *testParser) skip(size int) { + p.t.Helper() + if size < 0 || p.pos+size > len(p.raw) { + p.t.Fatalf("skip exceeds payload size: pos=%d size=%d len=%d", p.pos, size, len(p.raw)) + } + p.pos += size +} + +func (p *testParser) readRects() []rect { + count := p.readInt32() + values := make([]rect, count) + for i := 0; i < count; i++ { + values[i] = rect{ + X: int32(p.readInt32()), + Y: int32(p.readInt32()), + Width: int32(p.readInt32()), + Height: int32(p.readInt32()), + } + } + return values +} + +func (p *testParser) readChars() []rune { + count := p.readInt32() + values := make([]rune, count) + for i := 0; i < count; i++ { + values[i] = p.readChar() + } + return values +} + +func (p *testParser) readVec3s() []vec3 { + count := p.readInt32() + values := make([]vec3, count) + for i := 0; i < count; i++ { + values[i] = vec3{ + X: p.readFloat32(), + Y: p.readFloat32(), + Z: p.readFloat32(), + } + } + return values +} + +func isPowerOfTwo(value int) bool { + return value > 0 && value&(value-1) == 0 +}