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  }