github.com/kintar/etxt@v0.0.9/font_files.go (about) 1 package etxt 2 3 import "os" 4 import "io" 5 import "io/fs" 6 import "errors" 7 import "strings" 8 import "path/filepath" 9 import "compress/gzip" 10 import "bytes" 11 import "embed" 12 13 import "golang.org/x/image/font/sfnt" 14 15 // Parses a font and returns it along its name and any possible 16 // error. Supported formats are .ttf, .otf, .ttf.gz and .otf.gz. 17 // 18 // This is a low level function; you may prefer to use a [FontLibrary] 19 // instead. 20 func ParseFontFrom(path string) (*Font, string, error) { 21 // check font path validity 22 knownFontExt, gzipped := acceptFontPath(path) 23 if !knownFontExt { 24 return nil, "", errors.New("invalid font path '" + path + "'") 25 } 26 27 // open font file 28 file, err := os.Open(path) 29 if err != nil { 30 return nil, "", err 31 } 32 return parseFontFileAndClose(file, gzipped) 33 } 34 35 // Same as [ParseFontFrom](), but for embedded filesystems. 36 // 37 // This is a low level function; you may prefer to use a [FontLibrary] 38 // instead. 39 func ParseEmbedFontFrom(path string, embedFileSys embed.FS) (*Font, string, error) { 40 // check font path validity 41 knownFontExt, gzipped := acceptFontPath(path) 42 if !knownFontExt { 43 return nil, "", errors.New("invalid font path '" + path + "'") 44 } 45 46 // open font file 47 file, err := embedFileSys.Open(path) 48 if err != nil { 49 return nil, "", err 50 } 51 return parseFontFileAndClose(file, gzipped) 52 } 53 54 func parseFontFileAndClose(file io.ReadCloser, gzipped bool) (*Font, string, error) { 55 fileCloser := onceCloser{file, false} 56 defer fileCloser.Close() 57 58 // detect gzipping 59 var reader io.ReadCloser 60 var readerCloser *onceCloser 61 if gzipped { 62 gzipReader, err := gzip.NewReader(file) 63 if err != nil { 64 return nil, "", err 65 } 66 reader = gzipReader 67 readerCloser = &onceCloser{gzipReader, false} 68 defer readerCloser.Close() 69 } else { 70 reader = file 71 readerCloser = &fileCloser 72 } 73 74 // read font bytes 75 fontBytes, err := io.ReadAll(reader) 76 if err != nil { 77 return nil, "", err 78 } 79 err = readerCloser.Close() 80 if err != nil { 81 return nil, "", err 82 } 83 err = fileCloser.Close() 84 if err != nil { 85 return nil, "", err 86 } 87 88 // create font from bytes and get name 89 return parseRawFontBytes(fontBytes) 90 } 91 92 // Similar to [sfnt.Parse](), but also including the font name 93 // in the returned values and accepting gzipped font bytes. The 94 // bytes must not be modified while the font is in use. 95 // 96 // This is a low level function; you may prefer to use a [FontLibrary] 97 // instead. 98 // 99 // [sfnt.Parse]: https://pkg.go.dev/golang.org/x/image/font/sfnt#Parse. 100 func ParseFontBytes(fontBytes []byte) (*Font, string, error) { 101 if len(fontBytes) >= 2 && fontBytes[0] == 0x1F && fontBytes[1] == 0x8B { 102 gzipReader, err := gzip.NewReader(bytes.NewReader(fontBytes)) 103 if err != nil { 104 return nil, "", err 105 } 106 fontBytes, err = io.ReadAll(gzipReader) 107 if err != nil { 108 return nil, "", err 109 } 110 err = gzipReader.Close() 111 if err != nil { 112 return nil, "", err 113 } 114 } 115 116 return parseRawFontBytes(fontBytes) 117 } 118 119 func parseRawFontBytes(fontBytes []byte) (*Font, string, error) { 120 newFont, err := sfnt.Parse(fontBytes) 121 if err != nil { 122 return nil, "", err 123 } 124 fontName, err := FontName(newFont) 125 return newFont, fontName, err 126 } 127 128 // Applies [GzipFontFile]() to each font of the given directory. 129 func GzipDirFonts(fontsDir string, outputDir string) error { 130 absDirPath, err := filepath.Abs(fontsDir) 131 if err != nil { 132 return err 133 } 134 absOutDir, err := filepath.Abs(outputDir) 135 if err != nil { 136 return err 137 } 138 139 return filepath.WalkDir(absDirPath, 140 func(path string, info fs.DirEntry, err error) error { 141 if err != nil { 142 return err 143 } 144 if info.IsDir() { 145 if path == absDirPath { 146 return nil 147 } 148 return fs.SkipDir 149 } 150 151 knownFontExt, gzipped := acceptFontPath(path) 152 if knownFontExt && !gzipped { 153 err := GzipFontFile(path, absOutDir) 154 if err != nil { 155 return err 156 } 157 } 158 return nil 159 }) 160 } 161 162 // Compresses the given font by gzipping it and storing the result on 163 // outDir with the same name as the original but an extra .gz extension. 164 // 165 // The font size reduction can vary a lot depending on the font and format, 166 // but it's typically above 33%, with many .ttf font sizes being halved. 167 // 168 // If you are wondering why gzip is used instead of supporting .woff formats: 169 // gzip has stdlib support, can be applied transparently and compression 170 // rates are very similar to what brotli achieves for .woff files. 171 // 172 // When working on games, sometimes you might prefer to compress directly 173 // with a single command: 174 // 175 // gzip --keep --best your_font.ttf 176 func GzipFontFile(fontPath string, outDir string) error { 177 // make output dir if it doesn't exist yet 178 info, err := os.Stat(outDir) 179 if err != nil && !errors.Is(err, fs.ErrNotExist) { 180 return err 181 } 182 if err != nil { // must be fs.ErrNotExist 183 err = os.Mkdir(outDir, 0700) 184 if err != nil { 185 return err 186 } 187 } else if !info.IsDir() { 188 return errors.New("'" + outDir + "' is not a directory") 189 } 190 191 if !strings.HasSuffix(outDir, string(os.PathSeparator)) { 192 outDir += string(os.PathSeparator) 193 } 194 195 // open font file 196 fontFile, err := os.Open(fontPath) 197 if err != nil { 198 return err 199 } 200 fontFileCloser := onceCloser{fontFile, false} 201 defer fontFileCloser.Close() 202 203 // create new compressed font file 204 gzipFile, err := os.Create(outDir + filepath.Base(fontPath) + ".gz") 205 if err != nil { 206 return err 207 } 208 gzipFileCloser := onceCloser{gzipFile, false} 209 defer gzipFileCloser.Close() 210 211 // write new compressed file 212 gzipWriter := gzip.NewWriter(gzipFile) // DefaultCompression is perfectly ok 213 gzipWriterCloser := onceCloser{gzipWriter, false} 214 defer gzipWriterCloser.Close() 215 _, err = io.Copy(gzipWriter, fontFile) 216 if err != nil { 217 return err 218 } 219 220 // close everything that can be closed 221 err = gzipWriterCloser.Close() 222 if err != nil { 223 return err 224 } 225 err = gzipFileCloser.Close() 226 if err != nil { 227 return err 228 } 229 err = fontFileCloser.Close() 230 if err != nil { 231 return err 232 } 233 return nil 234 } 235 236 // --- helpers --- 237 238 // onceCloser makes it easier to both defer closes (to cover for early error 239 // returns) and check close errors manually when done with other operations, 240 // without having to suffer from "file already closed" and similar issues. 241 type onceCloser struct { 242 closer io.Closer 243 alreadyClosed bool 244 } 245 246 func (self *onceCloser) Close() error { 247 if self.alreadyClosed { 248 return nil 249 } 250 self.alreadyClosed = true 251 return self.closer.Close() 252 } 253 254 // The first bool returns whether to accept the font path or not. 255 // The second indicates if the font is gzipped or not. 256 func acceptFontPath(path string) (bool, bool) { 257 gzipped := false 258 if strings.HasSuffix(path, ".gz") { 259 gzipped = true 260 path = path[0 : len(path)-3] 261 } 262 263 validExt := (strings.HasSuffix(path, ".ttf") || strings.HasSuffix(path, ".otf")) 264 return validExt, gzipped 265 }