├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── bdf.go ├── cmd └── tigrfont │ └── main.go ├── convert.go ├── go.mod ├── go.sum ├── image.go ├── options.go ├── render.go ├── runes.go ├── tigrsheet.png └── ttf.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: '*' 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | 11 | build: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version: ^1.19 20 | id: go 21 | 22 | - name: Check out code 23 | uses: actions/checkout@v3 24 | 25 | - name: Build for intel linux 26 | run: mkdir linux && GOOS=linux GOARCH=amd64 go build -v -o linux/tigrfont ./cmd/tigrfont 27 | 28 | - name: Build for intel macos 29 | run: mkdir macintel && GOOS=darwin GOARCH=amd64 go build -v -o macintel/tigrfont ./cmd/tigrfont 30 | 31 | - name: Build for arm macos 32 | run: mkdir macarm && GOOS=darwin GOARCH=arm64 go build -v -o macarm/tigrfont ./cmd/tigrfont 33 | 34 | - name: Build for windows 35 | run: mkdir windows && GOOS=windows GOARCH=amd64 go build -v -o windows/tigrfont.exe ./cmd/tigrfont 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and create release on tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | 10 | release: 11 | name: Build and release 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Set TAG 16 | run: echo TAG=${GITHUB_REF:10} >> $GITHUB_ENV 17 | 18 | - name: Set ARCHIVE 19 | run: echo ARCHIVE=tigrfont-${TAG}.tgz >> $GITHUB_ENV 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v4 23 | with: 24 | go-version: ^1.19 25 | id: go 26 | 27 | - name: Check out code 28 | uses: actions/checkout@v3 29 | 30 | - name: Build for intel linux 31 | run: mkdir linux && GOOS=linux GOARCH=amd64 go build -v -o linux/tigrfont ./cmd/tigrfont 32 | 33 | - name: Build for intel macos 34 | run: mkdir macintel && GOOS=darwin GOARCH=amd64 go build -v -o macintel/tigrfont ./cmd/tigrfont 35 | 36 | - name: Build for arm macos 37 | run: mkdir macarm && GOOS=darwin GOARCH=arm64 go build -v -o macarm/tigrfont ./cmd/tigrfont 38 | 39 | - name: Build for windows 40 | run: mkdir windows && GOOS=windows GOARCH=amd64 go build -v -o windows/tigrfont.exe ./cmd/tigrfont 41 | 42 | - name: Pack release 43 | id: pack_release 44 | run: tar czf $ARCHIVE linux macintel macarm windows 45 | 46 | - name: Create release 47 | id: create_release 48 | uses: softprops/action-gh-release@v1 49 | with: 50 | token: ${{ secrets.GITHUB_TOKEN }} 51 | files: ${{ env.ARCHIVE }} 52 | tag_name: ${{ github.ref }} 53 | name: ${{ env.TAG }} 54 | body: "" 55 | draft: true 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | testdata 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2021, Erik Agsjö 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tigrfont - bitmap font sheet generator for TIGR 2 | 3 | `tigrfont` is a commandline tool for creating bitmap font sheets 4 | for [TIGR] from TTF/OTF or [BDF] font files. 5 | 6 | TIGR font sheets are simply PNG files with rows of white characters on a transparent background, separated by single-colored borders: 7 | 8 | ![](tigrsheet.png) 9 | 10 | > This is the default font included in TIGR, and has a black drop shadow. The font sheets created by `tigrfont` don't have shadows. 11 | 12 | ## Installation 13 | 14 | Get [pre-built binaries](https://github.com/erkkah/tigrfont/releases) for Linux, Windows or OSX or install using your local golang setup: 15 | 16 | ```console 17 | $ go install github.com/erkkah/tigrfont 18 | ``` 19 | 20 | ## BDF to TIGR 21 | 22 | Creating font sheets from BDF files is straightforward, since they are bitmap fonts already: 23 | ```console 24 | $ tigrfont 5x7.bdf 5x7.png 25 | ``` 26 | 27 | ## TTF to TIGR 28 | 29 | Converting from TTF files often requires a bit more testing and tweaking, depending on the specifics of the font. 30 | 31 | Since TTF fonts are vector fonts, they are rendered to a bitmap before being exported as the final font sheet. 32 | 33 | The rendering uses anti-aliasing, which will cause visible semi-transparent smudges at the low resolutions typically used with TIGR. 34 | 35 | YMMV :car: 36 | 37 | ### Font resolution and size 38 | 39 | The font is rendered at a given dpi, by default 72. 40 | 41 | The font size is specified in points, by default 18. 42 | 43 | Since apparent character height for a given point size varies a lot between fonts, `tigrfont` can measure the height of an 'X' and adjust the effective point size to make the 'X' render with a height of the given point size. 44 | 45 | For example, running 46 | ```console 47 | $ tigrfont -mx -size 20 myfont.ttf myfont.png 48 | ``` 49 | will render a font sheet at a size where a capital 'X' is 20 pixels high, since pixels equal points at 72 DPI. 50 | 51 | > You can also use `-m ` to measure using any character. 52 | 53 | ## Unicode and codepages 54 | 55 | TIGR, and `tigrfont` traditionally support two codepages, ASCII (code points 32 to 127) and CP-1252 (code points 32 to 255). 56 | Font sheets created using CP-1252 (the default) are loaded like this: 57 | 58 | ```C 59 | Tigr* fontImage = tigrLoadImage("font.png"); 60 | TigrFont* font = tigrLoadFont(fontImage, TCP_1252); 61 | ``` 62 | 63 | ### Unicode sheets 64 | 65 | Since version 1.0, `tigrfont` supports sparse unicode-encoded font sheets. 66 | Note that [TIGR] version 3.1 is needed to use these font sheets. 67 | 68 | Instead of simply enumerating code points, as in the ASCII and CP-1252 cases, the set of code points to include is specified using either the `-encoding` or the `-sample` option. 69 | 70 | The `-encoding` argument accepts an [HTML5 encoding name] and tries to extract the set of code points covered by that encoding. This often generates a superset of code points. For example, specifying "gbk" or "gb3212" results in the same large set of code points. 71 | 72 | Using the `-sample` option, you can specify a UTF-8 encoded text file containing the code points you want in the font sheet. Since duplicates are allowed, you can simply specify a sample text file with the code points needed. 73 | 74 | > If you look at the generated unicode font sheets, you might notice that there are semi-transparent sections in the borders around the characters. This is since the alpha channel is used to store code point info. :brain: 75 | 76 | [HTML5 encoding name]: https://encoding.spec.whatwg.org/#names-and-labels 77 | [TIGR]: https://github.com/erkkah/tigr 78 | [BDF]: https://en.wikipedia.org/wiki/Glyph_Bitmap_Distribution_Format 79 | -------------------------------------------------------------------------------- /bdf.go: -------------------------------------------------------------------------------- 1 | package tigrfont 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | 7 | "github.com/zachomedia/go-bdf" 8 | ) 9 | 10 | func tigrFromBDF(bdfBytes []byte, runeSet []rune, mode missingGlyphMode, watermark bool) (*image.NRGBA, int, error) { 11 | font, err := bdf.Parse(bdfBytes) 12 | if err != nil || font.Size == 0 { 13 | return nil, 0, fmt.Errorf("failed to parse BDF") 14 | } 15 | 16 | face := font.NewFace() 17 | image, rendered, err := renderFontSheet(runeSet, face, mode, watermark) 18 | if err != nil { 19 | return nil, 0, fmt.Errorf("failed to render BDF: %w", err) 20 | } 21 | return image, rendered, nil 22 | } 23 | -------------------------------------------------------------------------------- /cmd/tigrfont/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/erkkah/tigrfont" 9 | ) 10 | 11 | func main() { 12 | var options tigrfont.Options 13 | 14 | flag.IntVar(&options.FontSize, "size", 18, "TTF font size in points (equals pixels at 72 DPI)") 15 | flag.BoolVar(&options.MeasureX, "mx", false, "Measure an 'X' to get TTF point size") 16 | flag.StringVar(&options.Measure, "m", "", "Measure specified character to get TTF point size") 17 | flag.IntVar(&options.DPI, "dpi", 72, "Render TTF at DPI") 18 | flag.IntVar((*int)(&options.Codepage), "cp", (int)(tigrfont.CP1252), "Font sheet codepage: 0, 1252") 19 | flag.StringVar(&options.Encoding, "encoding", "", "Create sheet using the characters from this encoding") 20 | flag.StringVar(&options.SampleFile, "sample", "", "Create sheet using the UTF-8 characters in a file") 21 | 22 | flag.Usage = func() { 23 | fmt.Fprintf(flag.CommandLine.Output(), "Usage: tigrfont [options] \n\nOptions:\n") 24 | flag.PrintDefaults() 25 | } 26 | flag.Parse() 27 | 28 | args := flag.Args() 29 | if len(args) != 2 { 30 | flag.Usage() 31 | os.Exit(1) 32 | } 33 | font := args[0] 34 | target := args[1] 35 | 36 | generated, err := tigrfont.Convert(options, font, target) 37 | if err != nil { 38 | fmt.Printf("%v\n", err) 39 | os.Exit(1) 40 | } 41 | 42 | var source string 43 | if len(options.Encoding) > 0 { 44 | options.Codepage = tigrfont.UNICODE 45 | source = fmt.Sprintf(" from encoding %q", options.Encoding) 46 | } else if len(options.SampleFile) > 0 { 47 | options.Codepage = tigrfont.UNICODE 48 | source = fmt.Sprintf(" from sample %q", options.SampleFile) 49 | } 50 | 51 | fmt.Printf("Generated %s font sheet for %v characters%s\n", options.Codepage, generated, source) 52 | } 53 | -------------------------------------------------------------------------------- /convert.go: -------------------------------------------------------------------------------- 1 | package tigrfont 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "image/png" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | type Codepage int 12 | 13 | const ( 14 | ASCII Codepage = 0 15 | CP1252 Codepage = 1252 16 | UNICODE Codepage = 12001 17 | ) 18 | 19 | func (cp Codepage) String() string { 20 | switch cp { 21 | case ASCII: 22 | return "ascii" 23 | case CP1252: 24 | return "windows 1252" 25 | case UNICODE: 26 | return "unicode" 27 | default: 28 | return "invalid" 29 | } 30 | } 31 | 32 | func Convert(options Options, font, target string) (int, error) { 33 | var runeSet []rune 34 | var err error 35 | 36 | if len(options.Encoding) > 0 && len(options.SampleFile) > 0 { 37 | return 0, fmt.Errorf("cannot specify both encoding and sample file") 38 | } 39 | 40 | replaceMode := replaceMissing 41 | watermark := false 42 | 43 | if len(options.Encoding) > 0 { 44 | runeSet, err = runesFromEncoding(options.Encoding) 45 | if err != nil { 46 | return 0, fmt.Errorf("failed to extract characters from encoding %q: %w", options.Encoding, err) 47 | } 48 | replaceMode = removeMissing 49 | watermark = true 50 | options.Codepage = UNICODE 51 | } 52 | 53 | if len(options.SampleFile) > 0 { 54 | runeSet, err = runesFromFile(options.SampleFile) 55 | if err != nil { 56 | return 0, fmt.Errorf("failed to extract characters from sample %q: %w", options.SampleFile, err) 57 | } 58 | replaceMode = removeMissing 59 | watermark = true 60 | options.Codepage = UNICODE 61 | } 62 | 63 | if len(runeSet) == 0 { 64 | const lowChar = 32 65 | var highChar = 127 66 | 67 | switch options.Codepage { 68 | case ASCII: 69 | highChar = 127 70 | case CP1252: 71 | highChar = 255 72 | case UNICODE: 73 | return 0, fmt.Errorf("use encoding or sample file to create unicode sheet") 74 | default: 75 | return 0, fmt.Errorf("invalid TIGR codepage: %v", options.Codepage) 76 | } 77 | 78 | runeSet = runesFromRange(lowChar, highChar) 79 | } 80 | 81 | fontBytes, err := os.ReadFile(font) 82 | if err != nil { 83 | return 0, fmt.Errorf("failed to load font file: %w", err) 84 | } 85 | 86 | var img image.Image 87 | var rendered int 88 | 89 | // Try TTF first 90 | img, rendered, err = tigrFromTTF(options, fontBytes, runeSet, replaceMode, watermark) 91 | 92 | if err != nil { 93 | font = strings.ToLower(font) 94 | if strings.HasSuffix(font, "ttf") || strings.HasSuffix(font, "otf") { 95 | return 0, err 96 | } 97 | 98 | // Assume BDF file 99 | img, rendered, err = tigrFromBDF(fontBytes, runeSet, replaceMode, watermark) 100 | 101 | if err != nil { 102 | return 0, fmt.Errorf("failed to render font: %v", err) 103 | } 104 | } 105 | 106 | if watermark { 107 | img, err = palettize(img.(*image.NRGBA)) 108 | if err != nil { 109 | return 0, err 110 | } 111 | } 112 | 113 | pngFile, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0664) 114 | if err != nil { 115 | return 0, fmt.Errorf("failed to open target %q: %v", target, err) 116 | } 117 | defer pngFile.Close() 118 | 119 | encoder := png.Encoder{ 120 | CompressionLevel: png.BestCompression, 121 | } 122 | 123 | err = encoder.Encode(pngFile, img) 124 | if err != nil { 125 | return 0, fmt.Errorf("failed to encode PNG: %v", err) 126 | } 127 | 128 | return rendered, nil 129 | } 130 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/erkkah/tigrfont 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/zachomedia/go-bdf v0.0.0-20220611021443-a3af701111be 7 | golang.org/x/image v0.9.0 8 | golang.org/x/text v0.11.0 9 | ) 10 | 11 | replace golang.org/x/image v0.9.0 => github.com/erkkah/image v0.0.0-20230715212431-dcf1e75119be 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/erkkah/image v0.0.0-20230715212431-dcf1e75119be h1:ODTa0OPAARLT1RZNlM/iX2/bIoUoN1yTmfc/mG1afe4= 2 | github.com/erkkah/image v0.0.0-20230715212431-dcf1e75119be/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0= 3 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 4 | github.com/zachomedia/go-bdf v0.0.0-20220611021443-a3af701111be h1:qf05vm7CJA3tcnR42pv2a/+pvCPGylJcg10B9CRFPvg= 5 | github.com/zachomedia/go-bdf v0.0.0-20220611021443-a3af701111be/go.mod h1:FWqHpmEj39kZYjkb4y+GkFRwJofD3lP2k8ataoNlo2Y= 6 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 7 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 8 | golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 9 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 10 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 11 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 12 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 13 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 14 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 15 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 16 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 17 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 18 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 19 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 20 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 21 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 22 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 23 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 24 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 25 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 26 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 27 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 28 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 29 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 30 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 31 | golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= 32 | golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 33 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 34 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 35 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 36 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 37 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 38 | -------------------------------------------------------------------------------- /image.go: -------------------------------------------------------------------------------- 1 | package tigrfont 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | ) 7 | 8 | func contentBounds(img *image.NRGBA) image.Rectangle { 9 | minNonTransparentRow := img.Bounds().Max.Y 10 | maxNonTransparentRow := img.Bounds().Min.Y 11 | 12 | rows: 13 | for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ { 14 | cols: 15 | for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ { 16 | color := img.At(x, y) 17 | if color == Border.C { 18 | continue cols 19 | } 20 | _, _, _, a := color.RGBA() 21 | if a != 0 { 22 | // non-transparent 23 | if y < minNonTransparentRow { 24 | minNonTransparentRow = y 25 | } 26 | if y > maxNonTransparentRow { 27 | maxNonTransparentRow = y 28 | } 29 | continue rows 30 | } 31 | } 32 | } 33 | 34 | return image.Rect(img.Bounds().Min.X, minNonTransparentRow, img.Bounds().Max.X, maxNonTransparentRow+1) 35 | } 36 | 37 | func whitePalette() color.Palette { 38 | p := make(color.Palette, 256) 39 | 40 | for a := 0; a < 256; a++ { 41 | p[a] = color.NRGBA{255, 255, 255, uint8(a)} 42 | } 43 | 44 | return p 45 | } 46 | 47 | func palettize(img *image.NRGBA) (image.Image, error) { 48 | palette := whitePalette() 49 | pi := image.NewPaletted(img.Bounds(), palette) 50 | 51 | for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ { 52 | for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ { 53 | alpha := img.Pix[img.PixOffset(x, y)+3] 54 | 55 | pi.Pix[pi.PixOffset(x, y)] = alpha 56 | } 57 | } 58 | 59 | return pi, nil 60 | } 61 | 62 | func clear(img *image.NRGBA) { 63 | rgba := [...]uint8{0xff, 0xff, 0xff, 0x00} 64 | 65 | for i := range img.Pix { 66 | img.Pix[i] = rgba[i%4] 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package tigrfont 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | ) 7 | 8 | type Options struct { 9 | FontSize int 10 | MeasureX bool 11 | Measure string 12 | DPI int 13 | Codepage Codepage 14 | Encoding string 15 | SampleFile string 16 | } 17 | 18 | var BorderColor = color.NRGBA{0x00, 0xAA, 0xCC, 0xff} 19 | var Border = image.NewUniform(BorderColor) 20 | -------------------------------------------------------------------------------- /render.go: -------------------------------------------------------------------------------- 1 | package tigrfont 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "image/draw" 7 | 8 | xfont "golang.org/x/image/font" 9 | "golang.org/x/image/math/fixed" 10 | ) 11 | 12 | type missingGlyphMode int 13 | 14 | const ( 15 | replaceMissing missingGlyphMode = 1 16 | removeMissing missingGlyphMode = 2 17 | ) 18 | 19 | func renderFontSheet(runes []rune, face xfont.Face, mode missingGlyphMode, watermark bool) (*image.NRGBA, int, error) { 20 | metrics := face.Metrics() 21 | 22 | startDot := fixed.P(0, metrics.Ascent.Ceil()+1) 23 | drawer := xfont.Drawer{ 24 | Dst: image.NewNRGBA(image.Rect(0, 0, 1, 1)), 25 | Src: image.White, 26 | Face: face, 27 | Dot: startDot, 28 | } 29 | 30 | // Render once to measure. 31 | destWidthPixels, renderedRows, maxAscent, maxDescent, runesRendered := renderFontChars(runes, drawer, 1, mode, watermark) 32 | 33 | rowHeightPixels := maxAscent + maxDescent 34 | destHeightPixels := rowHeightPixels * renderedRows 35 | startDot.Y = fixed.I(maxAscent) 36 | 37 | if watermark { 38 | // Skip initial watermark 39 | startDot.X = fixed.I(1) 40 | } else { 41 | // bottom border 42 | rowHeightPixels++ 43 | 44 | // top border 45 | destHeightPixels += renderedRows + 1 46 | startDot.Y += fixed.I(1) 47 | } 48 | 49 | // Render once more to the actual sheet size 50 | dest := image.NewNRGBA(image.Rect(0, 0, destWidthPixels, destHeightPixels)) 51 | clear(dest) 52 | 53 | drawer.Dst = dest 54 | drawer.Dot = startDot 55 | renderFontChars(runes, drawer, rowHeightPixels, mode, watermark) 56 | 57 | if watermark { 58 | stampWatermark(drawer.Dst, 0, 0, uint32(runesRendered), uint8(rowHeightPixels)) 59 | } else { 60 | // left 61 | drawVerticalDivider(drawer.Dst, 0, 1, destHeightPixels) 62 | // top 63 | drawHorizontalDivider(drawer.Dst, 0, destWidthPixels, 0) 64 | } 65 | 66 | return dest, runesRendered, nil 67 | } 68 | 69 | func renderFontChars( 70 | allRunes []rune, drawer xfont.Drawer, rowHeightPixels int, mode missingGlyphMode, watermark bool, 71 | ) (totalWidth, rows, maxAscent, maxDescent, runesRendered int) { 72 | 73 | dstMin := drawer.Dst.Bounds().Min 74 | 75 | minGlyphY := fixed.I(10000) 76 | maxGlyphY := fixed.I(-10000) 77 | 78 | maxWidthPixels := fixed.I(1000) 79 | 80 | rowHeight := fixed.I(rowHeightPixels) 81 | yOffset := 1 82 | if watermark { 83 | yOffset = 0 84 | } 85 | 86 | rowIndex := 0 87 | 88 | // Always start on whole pixels 89 | drawer.Dot.X = fixed.I(drawer.Dot.X.Ceil()) 90 | 91 | for _, r := range allRunes { 92 | s := string(r) 93 | bounds, _, exists := drawer.Face.GlyphBounds(r) 94 | if !exists && mode == removeMissing { 95 | continue 96 | } 97 | 98 | if drawer.Dot.X >= maxWidthPixels { 99 | drawer.Dot.X = 0 100 | drawer.Dot.Y += rowHeight 101 | rowIndex++ 102 | } 103 | 104 | xStart := drawer.Dot.X.Ceil() + dstMin.X 105 | 106 | // Skip left border / watermark 107 | drawer.Dot.X += fixed.I(1) 108 | 109 | if bounds.Min.Y < minGlyphY { 110 | minGlyphY = bounds.Min.Y 111 | } 112 | if bounds.Max.Y > maxGlyphY { 113 | maxGlyphY = bounds.Max.Y 114 | } 115 | 116 | // Glyph extends to left of box, must be shifted to the right. 117 | if bounds.Min.X < 0 { 118 | drawer.Dot.X += -bounds.Min.X 119 | } 120 | 121 | charStartX := drawer.Dot.X 122 | 123 | // Draw the glyph. This should add "advance" to Dot.X. 124 | drawer.DrawString(s) 125 | 126 | width := drawer.Dot.X - charStartX 127 | if width <= 0 { 128 | width = fixed.I(1) 129 | } 130 | 131 | xEnd := xStart + width.Ceil() + 1 132 | currentWidth := xEnd + 1 133 | if totalWidth < currentWidth { 134 | totalWidth = currentWidth 135 | } 136 | 137 | drawer.Dot.X = fixed.I(xEnd) 138 | yStart := rowHeightPixels*rowIndex + dstMin.Y + yOffset 139 | yEnd := yStart + rowHeightPixels 140 | 141 | if watermark { 142 | stampWatermark(drawer.Dst, xStart, yStart, uint32(r), uint8(width.Ceil())) 143 | } else { 144 | // top 145 | drawHorizontalDivider(drawer.Dst, xStart, xEnd, yStart-1) 146 | // right 147 | drawVerticalDivider(drawer.Dst, xEnd, yStart, yEnd) 148 | // bottom 149 | drawHorizontalDivider(drawer.Dst, xStart, xEnd, yEnd-1) 150 | } 151 | 152 | runesRendered++ 153 | } 154 | 155 | rows = rowIndex + 1 156 | maxAscent = (-minGlyphY).Ceil() 157 | maxDescent = maxGlyphY.Ceil() 158 | return 159 | } 160 | 161 | func drawVerticalDivider(dest draw.Image, x, y0, y1 int) { 162 | draw.Draw(dest, image.Rect(x, y0, x+1, y1), Border, image.Point{}, draw.Src) 163 | } 164 | 165 | func drawHorizontalDivider(dest draw.Image, x0, x1, y int) { 166 | draw.Draw(dest, image.Rect(x0, y, x1, y+1), Border, image.Point{}, draw.Src) 167 | } 168 | 169 | func stampWatermark(dest draw.Image, x0, y0 int, big uint32, small uint8) { 170 | mark := [7]uint8{ 171 | uint8(0b10101010), 172 | uint8(big & 0xff), 173 | uint8((big >> 8) & 0xff), 174 | uint8((big >> 16) & 0xff), 175 | uint8((big >> 24) & 0xff), 176 | small, 177 | uint8(0b01010101), 178 | } 179 | 180 | for i, m := range mark { 181 | pixel := dest.At(x0, y0+i).(color.NRGBA) 182 | pixel.A = m 183 | dest.Set(x0, y0+i, pixel) 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /runes.go: -------------------------------------------------------------------------------- 1 | package tigrfont 2 | 3 | import ( 4 | "os" 5 | "sort" 6 | "unicode" 7 | "unicode/utf8" 8 | 9 | "golang.org/x/text/encoding/charmap" 10 | "golang.org/x/text/encoding/htmlindex" 11 | ) 12 | 13 | func runesFromEncoding(encodingName string) ([]rune, error) { 14 | // https://encoding.spec.whatwg.org/#names-and-labels 15 | 16 | encoding, err := htmlindex.Get(encodingName) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | decoder := encoding.NewDecoder() 22 | 23 | allRunes := map[rune]bool{} 24 | 25 | decode := func(encoded []byte) (rune, bool) { 26 | decoded, err := decoder.Bytes(encoded) 27 | if err != nil { 28 | return utf8.RuneError, false 29 | } 30 | decodedRune, length := utf8.DecodeRune(decoded) 31 | if length > 0 && decodedRune != utf8.RuneError { 32 | return decodedRune, true 33 | } 34 | return utf8.RuneError, false 35 | } 36 | 37 | buffer := [2]byte{} 38 | 39 | // Try single and double char encodings only 40 | for first := 0; first <= 0xff; first++ { 41 | buffer[0] = byte(first) 42 | decoded, ok := decode(buffer[:1]) 43 | if ok { 44 | allRunes[decoded] = true 45 | } 46 | 47 | for second := 0; second <= 0xff; second++ { 48 | buffer[1] = byte(second) 49 | decoded, ok := decode(buffer[:2]) 50 | if ok { 51 | allRunes[decoded] = true 52 | } 53 | } 54 | } 55 | 56 | runeList := []rune{} 57 | 58 | for r := range allRunes { 59 | runeList = append(runeList, r) 60 | } 61 | 62 | return usableRunes(runeList), nil 63 | } 64 | 65 | func runesFromFile(file string) ([]rune, error) { 66 | bytes, err := os.ReadFile(file) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | allRunes := map[rune]bool{} 72 | sample := string(bytes) 73 | for _, rune := range sample { 74 | allRunes[rune] = true 75 | } 76 | runeList := []rune{} 77 | 78 | for r := range allRunes { 79 | runeList = append(runeList, r) 80 | } 81 | 82 | return usableRunes(runeList), nil 83 | } 84 | 85 | func runesFromRange(lowChar, highChar int) []rune { 86 | cp := charmap.Windows1252 87 | runeList := []rune{} 88 | for char := lowChar; char <= highChar; char++ { 89 | decoded := cp.DecodeByte(byte(char)) 90 | runeList = append(runeList, decoded) 91 | } 92 | return runeList 93 | } 94 | 95 | func usableRunes(runeSet []rune) []rune { 96 | usable := []rune{} 97 | 98 | for _, r := range runeSet { 99 | if unicode.IsGraphic(r) { 100 | usable = append(usable, r) 101 | } 102 | } 103 | 104 | sort.Slice(usable, func(i, j int) bool { 105 | return usable[i] < usable[j] 106 | }) 107 | 108 | return usable 109 | } 110 | -------------------------------------------------------------------------------- /tigrsheet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erkkah/tigrfont/8f98cc90c9bb14f5b9c600785278395edc4b8d57/tigrsheet.png -------------------------------------------------------------------------------- /ttf.go: -------------------------------------------------------------------------------- 1 | package tigrfont 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | 7 | xfont "golang.org/x/image/font" 8 | "golang.org/x/image/font/opentype" 9 | ) 10 | 11 | func tigrFromTTF(options Options, ttfBytes []byte, runeSet []rune, mode missingGlyphMode, watermark bool) (*image.NRGBA, int, error) { 12 | font, err := opentype.Parse(ttfBytes) 13 | if err != nil { 14 | return nil, 0, fmt.Errorf("failed to parse TTF: %w", err) 15 | } 16 | 17 | if options.MeasureX { 18 | options.Measure = "X" 19 | } 20 | 21 | if len(options.Measure) > 0 { 22 | measure := []rune(options.Measure)[0] 23 | options.FontSize, err = getPointSizeFrom(font, options.FontSize, measure) 24 | if err != nil { 25 | return nil, 0, fmt.Errorf("failed to measure char %q: %w", measure, err) 26 | } 27 | } 28 | 29 | face, err := opentype.NewFace(font, &opentype.FaceOptions{ 30 | DPI: float64(options.DPI), 31 | Size: float64(options.FontSize), 32 | Hinting: xfont.HintingFull, 33 | }) 34 | if err != nil { 35 | return nil, 0, fmt.Errorf("failed to create font face: %w", err) 36 | } 37 | 38 | image, rendered, err := renderFontSheet(runeSet, face, mode, watermark) 39 | if err != nil { 40 | return nil, 0, fmt.Errorf("failed to render TTF: %w", err) 41 | } 42 | 43 | return image, rendered, nil 44 | } 45 | 46 | func getPointSizeFrom(font *opentype.Font, fontSize int, char rune) (int, error) { 47 | face, err := opentype.NewFace( 48 | font, 49 | &opentype.FaceOptions{ 50 | DPI: 72.0, Size: float64(fontSize), Hinting: xfont.HintingFull, 51 | }) 52 | if err != nil { 53 | return 0, err 54 | } 55 | 56 | img, rendered, err := renderFontSheet([]rune{char}, face, removeMissing, false) 57 | if rendered == 0 { 58 | return 0, fmt.Errorf("cannot measure non-existant char %q", string(char)) 59 | } 60 | 61 | if err != nil { 62 | return 0, err 63 | } 64 | 65 | bounds := contentBounds(img) 66 | actual := float64(bounds.Dy()) 67 | expected := float64(fontSize) 68 | factor := expected / actual 69 | return int(expected * factor), nil 70 | } 71 | --------------------------------------------------------------------------------