├── .gitattributes ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── tests.yml │ └── release.yml ├── paths_darwin.go ├── paths_unix.go ├── paths_windows.go ├── go.mod ├── LICENSE ├── install_darwin.go ├── example.txt ├── install_unix.go ├── Makefile ├── .golangci.yml ├── .travis.yml ├── main.go ├── install_windows.go ├── go.sum ├── font.go ├── README.md └── install.go /.gitattributes: -------------------------------------------------------------------------------- 1 | *.go text eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | font-install* 2 | *.sw[po] 3 | release/ 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /paths_darwin.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "path" 5 | 6 | xdg "github.com/casimir/xdg-go" 7 | ) 8 | 9 | // FontsDir denotes the path to the user's fonts directory on OSX. 10 | var FontsDir = path.Join(xdg.DataHome(), "Fonts") 11 | -------------------------------------------------------------------------------- /paths_unix.go: -------------------------------------------------------------------------------- 1 | //go:build linux || solaris || openbsd || freebsd 2 | // +build linux solaris openbsd freebsd 3 | 4 | package main 5 | 6 | import ( 7 | "os" 8 | "path" 9 | ) 10 | 11 | // FontsDir denotes the path to the user's fonts directory on Unix-like systems. 12 | var FontsDir = path.Join(os.Getenv("HOME"), "/.local/share/fonts") 13 | -------------------------------------------------------------------------------- /paths_windows.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path" 6 | ) 7 | 8 | // FontsDir denotes the path to the user's fonts directory on Linux. 9 | // Windows doesn't have the concept of a permanent, per-user collection 10 | // of fonts, meaning that all fonts are stored in the system-level fonts 11 | // directory, which is %WINDIR%\Fonts by default. 12 | var FontsDir = path.Join(os.Getenv("WINDIR"), "Fonts") 13 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Crosse/font-install 2 | 3 | require ( 4 | github.com/ConradIrwin/font v0.0.0-20190603172541-e12dbea4cf12 5 | github.com/Crosse/gosimplelogger v0.2.0 6 | github.com/casimir/xdg-go v0.0.0-20160329195404-372ccc2180da 7 | golang.org/x/sys v0.6.0 8 | ) 9 | 10 | require ( 11 | dmitri.shuralyov.com/font/woff2 v0.0.0-20180220214647-957792cbbdab // indirect 12 | github.com/dsnet/compress v0.0.1 // indirect 13 | github.com/shurcooL/gofontwoff v0.0.0-20181114050219-180f79e6909d // indirect 14 | golang.org/x/text v0.3.8 // indirect 15 | ) 16 | 17 | go 1.18 18 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - main 7 | pull_request: 8 | jobs: 9 | golangci: 10 | strategy: 11 | matrix: 12 | go-version: [1.18.x] 13 | os: [ubuntu-latest, macos-latest, windows-latest] 14 | fail-fast: false 15 | name: lint 16 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - uses: actions/setup-go@v3 19 | with: 20 | go-version: 1.18 21 | - uses: actions/checkout@v3 22 | - name: golangci-lint 23 | uses: golangci/golangci-lint-action@v3 24 | with: 25 | version: latest 26 | args: --timeout 3m 27 | only-new-issues: true 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Releases 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | release: 10 | runs-on: macos-latest 11 | permissions: 12 | contents: write 13 | steps: 14 | - name: checkout 15 | uses: actions/checkout@v3 16 | 17 | - name: "setup go" 18 | uses: actions/setup-go@v3 19 | with: 20 | cache: true 21 | go-version-file: 'go.mod' 22 | 23 | - name: build 24 | run: make zip 25 | 26 | - name: "create release" 27 | uses: ncipollo/release-action@v1 28 | with: 29 | artifactErrorsFailBuild: true 30 | artifacts: "release/*.zip" 31 | draft: true 32 | generateReleaseNotes: true 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Seth Wright 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee 4 | is hereby granted, provided that the above copyright notice and this permission notice appear in all 5 | copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS 8 | SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE 9 | AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 10 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE 11 | OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS 12 | SOFTWARE. 13 | -------------------------------------------------------------------------------- /install_darwin.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | 8 | log "github.com/Crosse/gosimplelogger" 9 | ) 10 | 11 | func platformDependentInstall(fontData *FontData) error { 12 | // On darwin/OSX, the user's fonts directory is ~/Library/Fonts, 13 | // and fonts should be installed directly into that path; 14 | // i.e., not in subfolders. 15 | fullPath := path.Join(FontsDir, path.Base(fontData.FileName)) 16 | log.Debugf("Installing \"%v\" to %v", fontData.Name, fullPath) 17 | 18 | err := os.MkdirAll(path.Dir(fullPath), 0o700) 19 | if err != nil { 20 | return fmt.Errorf("failed to create directory: %w", err) 21 | } 22 | 23 | err = os.WriteFile(fullPath, fontData.Data, 0o644) 24 | if err != nil { 25 | return fmt.Errorf("cannot write file: %w", err) 26 | } 27 | 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /example.txt: -------------------------------------------------------------------------------- 1 | http://www.fontsquirrel.com/fonts/download/Inconsolata 2 | http://www.fontsquirrel.com/fonts/download/dejavu-sans 3 | http://www.fontsquirrel.com/fonts/download/dejavu-sans-mono 4 | http://www.fontsquirrel.com/fonts/download/dejavu-serif 5 | http://www.fontsquirrel.com/fonts/download/droid-sans 6 | http://www.fontsquirrel.com/fonts/download/droid-sans-mono 7 | http://www.fontsquirrel.com/fonts/download/liberation-mono 8 | http://www.fontsquirrel.com/fonts/download/liberation-sans 9 | http://www.fontsquirrel.com/fonts/download/liberation-serif 10 | http://www.fontsquirrel.com/fonts/download/open-sans 11 | http://www.fontsquirrel.com/fonts/download/source-code-pro 12 | http://www.fontsquirrel.com/fonts/download/ubuntu 13 | http://www.fontsquirrel.com/fonts/download/ubuntu-mono 14 | http://fontawesome.io/assets/font-awesome-4.7.0.zip 15 | -------------------------------------------------------------------------------- /install_unix.go: -------------------------------------------------------------------------------- 1 | //go:build linux || solaris || openbsd || freebsd 2 | // +build linux solaris openbsd freebsd 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "path" 10 | "strings" 11 | 12 | log "github.com/Crosse/gosimplelogger" 13 | ) 14 | 15 | func platformDependentInstall(fontData *FontData) error { 16 | // On Linux, fontconfig can understand subdirectories. So, to keep the 17 | // font directory clean, install all font files for a particular font 18 | // family into a subdirectory named after the family (with hyphens instead 19 | // of spaces). 20 | fullPath := path.Join(FontsDir, 21 | strings.ToLower(strings.ReplaceAll(fontData.Family, " ", "-")), 22 | path.Base(fontData.FileName)) 23 | log.Debugf("Installing \"%v\" to %v", fontData.Name, fullPath) 24 | 25 | err := os.MkdirAll(path.Dir(fullPath), 0o700) 26 | if err != nil { 27 | return fmt.Errorf("failed to create directory: %w", err) 28 | } 29 | 30 | err = os.WriteFile(fullPath, fontData.Data, 0o644) 31 | if err != nil { 32 | return fmt.Errorf("cannot write file: %w", err) 33 | } 34 | 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | package = github.com/Crosse/font-install 2 | 3 | default: release 4 | 5 | define build 6 | @env GOOS=$(1) GOARCH=$(2) make release/font-install-$(1)-$(2)$(3) 7 | endef 8 | 9 | release/font-install-%: 10 | GOOS=$(GOOS) GOARCH=$(GOARCH) go build -o "$@" $(package) 11 | 12 | release/font-install-darwin-universal: release/font-install-darwin-amd64 release/font-install-darwin-arm64 13 | $(RM) "$@" 14 | lipo -create -o "$@" $^ 15 | 16 | .PHONY: release 17 | release: 18 | mkdir -p release 19 | $(call build,linux,arm) 20 | $(call build,linux,amd64) 21 | $(call build,linux,arm64) 22 | 23 | $(call build,darwin,amd64) 24 | $(call build,darwin,arm64) 25 | @make release/font-install-darwin-universal 26 | 27 | $(call build,openbsd,arm) 28 | $(call build,openbsd,amd64) 29 | $(call build,openbsd,arm64) 30 | 31 | $(call build,freebsd,arm) 32 | $(call build,freebsd,amd64) 33 | $(call build,freebsd,arm64) 34 | 35 | $(call build,windows,amd64,.exe) 36 | $(call build,windows,arm64,.exe) 37 | 38 | .PHONY: zip 39 | zip: release 40 | find release -type f ! -name '*.zip' -execdir zip -9 "{}.zip" "{}" \; 41 | 42 | .PHONY: clean 43 | clean: 44 | $(RM) -r release 45 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | linters: 3 | enable-all: true 4 | disable: 5 | # deprecated 6 | - golint 7 | - interfacer 8 | - maligned 9 | - scopelint 10 | - ifshort 11 | - deadcode 12 | - varcheck 13 | - structcheck 14 | - nosnakecase 15 | 16 | # less useful 17 | - cyclop 18 | - exhaustivestruct 19 | - exhaustruct 20 | - forbidigo 21 | - funlen 22 | - gochecknoglobals 23 | - gomnd 24 | - ireturn 25 | - nolintlint 26 | - varnamelen 27 | 28 | # really don't think I need to worry about it in a project this small 29 | - goerr113 30 | 31 | linters-settings: 32 | misspell: 33 | ignore-words: 34 | - strat 35 | nlreturn: 36 | block-size: 2 37 | exhaustive: 38 | default-signifies-exhaustive: true 39 | gosec: 40 | config: 41 | G306: "0644" 42 | 43 | issues: 44 | exclude-rules: 45 | # False positive: https://github.com/kunwardeep/paralleltest/issues/8. 46 | - linters: 47 | - paralleltest 48 | text: "does not use range value in test Run" 49 | - linters: 50 | - errcheck 51 | text: "Error return value of (Info|Error)f? is not checked" 52 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.10.2 4 | install: 5 | - curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh 6 | - dep ensure 7 | deploy: 8 | provider: releases 9 | api_key: 10 | secure: VuEm3PPXvRTvWvBt8Fx++8enyKrxRl1yDXmUHRoMAsywH7Kf9ldTQTFO/KjfS/2t5GU+X+rG1JeQ5ooWgCqcNEYv1BR5w2nOHz+7UfzHF+V1Nq3aavRiuV/LvVFMvMvltK/pOvv7JLzcfKsL6+pYfP/nnTTj9LpBzuRBKsgF5hs8YCNm3jebr7F1vQL6CwYLTZqk6oLi7QZ/WmNa/+QyxXXbca3/IdaXyoJMxEeoWqOaC2hz73kE4qhjgNREib3ZLbo092w12YKgxn1NkNAz9WWKipGMzmxLxmk+rK67D8ykppQBJ0+Vt9vd54Gf1Wbt+glR16ghIxiI/Fx33cSp8AhSx5elrq5hK2yaL2IKtllrXQiii0ilMbxbw4H5Jbt/HMEseN+izPzDsIh+e6FMwT4oSiOk13XmoqXzWf46tCTG3AoiSagW/6Dy4U/QRRtMPAxf23V9vKqhm38qOemoNJdsrcIwajLmWHklX6VNt9b9f7ehTl7+TC6P3Ku2MBhENTjxlyYPJwFNXGjdoa4l7ABOrk1A1MQvfXFTMQ3sAWSMrkVLLa3qQ0eV6HPaFoU1WlWbciNH+cctsZBcUnv/rN166ldcpmzdzoplmLjyE1RHgmNs9Fpp/v/ck1w5FyC7Cv2UaDrRYVVeFcpFWU0ulbnDSIeU3kszOKfzQN4J7+A= 11 | file: 12 | - release/font-install-linux-amd64 13 | - release/font-install-linux-386 14 | - release/font-install-linux-arm64 15 | - release/font-install-linux-arm 16 | - release/font-install-darwin-amd64 17 | - release/font-install-windows-amd64.exe 18 | - release/font-install-windows-386.exe 19 | on: 20 | repo: Crosse/font-install 21 | tags: true 22 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "flag" 6 | "os" 7 | "regexp" 8 | "runtime" 9 | 10 | log "github.com/Crosse/gosimplelogger" 11 | ) 12 | 13 | func main() { 14 | var ( 15 | fonts []string 16 | filename = flag.String("fromFile", "", "text file containing fonts to install") 17 | debug = flag.Bool("debug", false, "Enable debug logging") 18 | dryrun = flag.Bool("dry-run", false, "Don't actually download or install anything") 19 | ) 20 | 21 | flag.Parse() 22 | 23 | if *filename == "" && len(flag.Args()) == 0 { 24 | flag.Usage() 25 | os.Exit(1) 26 | } 27 | 28 | if *debug { 29 | log.LogLevel = log.LogDebug 30 | } else { 31 | log.LogLevel = log.LogInfo 32 | } 33 | 34 | if *filename != "" { 35 | fd, err := os.Open(*filename) 36 | if err != nil { 37 | log.Fatal(err) 38 | } 39 | 40 | re := regexp.MustCompile(`^(#.*|\s*)?$`) 41 | 42 | scanner := bufio.NewScanner(fd) 43 | for scanner.Scan() { 44 | line := scanner.Text() 45 | if !re.MatchString(line) { 46 | fonts = append(fonts, line) 47 | } 48 | } 49 | 50 | if err = scanner.Err(); err != nil { 51 | log.Fatal(err) 52 | } 53 | } 54 | 55 | fonts = append(fonts, flag.Args()...) 56 | 57 | for _, v := range fonts { 58 | if *dryrun { 59 | log.Infof("Would install font(s) from %v", v) 60 | continue 61 | } 62 | 63 | log.Debugf("Installing font from %v", v) 64 | 65 | if err := InstallFont(v); err != nil { 66 | log.Error(err) 67 | } 68 | } 69 | 70 | log.Infof("Installed %v fonts", installedFonts) 71 | 72 | if runtime.GOOS == "windows" { 73 | log.Info("You will need to logoff and logon before the installed font(s) will be available.") 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /install_windows.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | 8 | log "github.com/Crosse/gosimplelogger" 9 | "golang.org/x/sys/windows/registry" 10 | ) 11 | 12 | func platformDependentInstall(fontData *FontData) error { 13 | // To install a font on Windows: 14 | // - Copy the file to the fonts directory 15 | // - Create a registry entry for the font 16 | fullPath := path.Join(FontsDir, fontData.FileName) 17 | log.Debugf("Installing \"%v\" to %v", fontData.Name, fullPath) 18 | 19 | err := os.WriteFile(fullPath, fontData.Data, 0o644) 20 | if err != nil { 21 | return fmt.Errorf("cannot write file: %w", err) 22 | } 23 | 24 | // Second, write metadata about the font to the registry. 25 | k, err := registry.OpenKey( 26 | registry.LOCAL_MACHINE, 27 | `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts`, 28 | registry.WRITE) 29 | if err != nil { 30 | // If this fails, remove the font file as well. 31 | log.Error(err) 32 | 33 | if nexterr := os.Remove(fullPath); nexterr != nil { 34 | return fmt.Errorf("error removing font: %w", nexterr) 35 | } 36 | 37 | return fmt.Errorf("error opening registry: %w", err) 38 | } 39 | defer k.Close() 40 | 41 | // Apparently it's "ok" to mark an OpenType font as "TrueType", 42 | // and since this tool only supports True- and OpenType fonts, 43 | // this should be Okay(tm). 44 | // Besides, Windows does it, so why can't I? 45 | valueName := fmt.Sprintf("%v (TrueType)", fontData.FileName) 46 | if err = k.SetStringValue(fontData.Name, valueName); err != nil { 47 | // If this fails, remove the font file as well. 48 | log.Error(err) 49 | 50 | if nexterr := os.Remove(fullPath); nexterr != nil { 51 | return fmt.Errorf("error removing font: %w", nexterr) 52 | } 53 | 54 | return fmt.Errorf("error writing to registry: %w", err) 55 | } 56 | 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dmitri.shuralyov.com/font/woff2 v0.0.0-20180220214647-957792cbbdab h1:Ew70NL+wL6v9looOiJJthlqA41VzoJS+q9AyjHJe6/g= 2 | dmitri.shuralyov.com/font/woff2 v0.0.0-20180220214647-957792cbbdab/go.mod h1:FvHgTMJanm43G7B3MVSjS/jim5ytVqAJNAOpRhnuHJc= 3 | github.com/ConradIrwin/font v0.0.0-20190603172541-e12dbea4cf12 h1:p6Fbemw7MLL8GECFdqBnGqrY6tfeNEECS1Gckyt6JJg= 4 | github.com/ConradIrwin/font v0.0.0-20190603172541-e12dbea4cf12/go.mod h1:RfqDRypmK6cOLNPzccgIJ999A/yzpd0iGh6xazqpWrY= 5 | github.com/Crosse/gosimplelogger v0.2.0 h1:ETC8ar0tehvg3unr4NO5+ArBFZkG16WflLObDUXuyKY= 6 | github.com/Crosse/gosimplelogger v0.2.0/go.mod h1:whwzH0thK39LhKDbhxl6Uq/7ykKuwNkySB2v6vm6tf0= 7 | github.com/casimir/xdg-go v0.0.0-20160329195404-372ccc2180da h1:hjpZV7G49m1bly++F+Gho1Sbf2+eBW/eTLJWuRkH9Uc= 8 | github.com/casimir/xdg-go v0.0.0-20160329195404-372ccc2180da/go.mod h1:dywSSi3sMtJn2IjiYfJciP9tjVVeIVRa7AE7N5WLUBo= 9 | github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q= 10 | github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= 11 | github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= 12 | github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= 13 | github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= 14 | github.com/shurcooL/gofontwoff v0.0.0-20181114050219-180f79e6909d h1:lvCTyBbr36+tqMccdGMwuEU+hjux/zL6xSmf5S9ITaA= 15 | github.com/shurcooL/gofontwoff v0.0.0-20181114050219-180f79e6909d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw= 16 | github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= 17 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 18 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 19 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 20 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 21 | -------------------------------------------------------------------------------- /font.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "path" 7 | "strings" 8 | 9 | "github.com/ConradIrwin/font/sfnt" 10 | log "github.com/Crosse/gosimplelogger" 11 | ) 12 | 13 | // FontData describes a font file and the various metadata associated with it. 14 | type FontData struct { 15 | Name string 16 | Family string 17 | FileName string 18 | Metadata map[sfnt.NameID]string 19 | Data []byte 20 | } 21 | 22 | // fontExtensions is a list of file extensions that denote fonts. 23 | // Only files ending with these extensions will be installed. 24 | var fontExtensions = map[string]bool{ 25 | ".otf": true, 26 | ".ttf": true, 27 | } 28 | 29 | // NewFontData creates a new FontData struct. 30 | // fileName is the font's file name, and data is a byte slice containing the font file data. 31 | // It returns a FontData struct describing the font, or an error. 32 | func NewFontData(fileName string, data []byte) (*FontData, error) { 33 | if _, ok := fontExtensions[strings.ToLower(path.Ext(fileName))]; !ok { 34 | return nil, fmt.Errorf("not a font: %v", fileName) 35 | } 36 | 37 | fontData := &FontData{ 38 | FileName: fileName, 39 | Metadata: make(map[sfnt.NameID]string), 40 | Data: data, 41 | } 42 | 43 | font, err := sfnt.Parse(bytes.NewReader(fontData.Data)) 44 | if err != nil { 45 | return nil, fmt.Errorf("cannot parse font: %w", err) 46 | } 47 | 48 | if !font.HasTable(sfnt.TagName) { 49 | return nil, fmt.Errorf("font has no name table: %s", fileName) 50 | } 51 | 52 | nameTable, err := font.NameTable() 53 | if err != nil { 54 | return nil, fmt.Errorf("cannot get font table for %s: %w", fileName, err) 55 | } 56 | 57 | for _, nameEntry := range nameTable.List() { 58 | fontData.Metadata[nameEntry.NameID] = nameEntry.String() 59 | } 60 | 61 | fontData.Name = fontData.Metadata[sfnt.NameFull] 62 | fontData.Family = fontData.Metadata[sfnt.NamePreferredFamily] 63 | 64 | if fontData.Family == "" { 65 | if v, ok := fontData.Metadata[sfnt.NameFontFamily]; ok { 66 | fontData.Family = v 67 | } else { 68 | log.Errorf("Font %v has no font family!", fontData.Name) 69 | } 70 | } 71 | 72 | if fontData.Name == "" { 73 | log.Errorf("Font %v has no name! Using file name instead.", fileName) 74 | fontData.Name = fileName 75 | } 76 | 77 | return fontData, nil 78 | } 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # font-install 2 | 3 | `font-install` is a cross-platform utility to install fonts on a system. 4 | 5 | It can install fonts on Linux, macOS, OpenBSD, FreeBSD, or Windows systems. 6 | Given a ZIP file, it will even install all font files within the archive. 7 | If you feed `font-install` an HTTP/HTTPS URL, it will first download the 8 | file, then install the font (or extract and install them if the file is a 9 | ZIP file). `font-install` currently only handles OpenType and TrueType font 10 | files. 11 | 12 | `font-install` is not intended to handle webfonts; it installs fonts so that 13 | other applications can use (such as your display manager, office suite, 14 | etc.). It currently installs fonts into the system's user-specific fonts 15 | library location. 16 | 17 | ## Background 18 | 19 | I have a list of fonts that I always want installed on my computer, no 20 | matter which operating system that computer runs. On Linux, this evolved 21 | into a simple bash script, which also worked well for OSX (after fudging the 22 | install path). Both of these operating systems simply look for font files 23 | in a specific location (on Linux, `${HOME}/.local/share/fonts`; for OSX it 24 | is `${HOME}/Library/Fonts`). However, I also wanted to be able to install 25 | these same fonts just as easily on Windows...which is quite not as 26 | easy. (It's still pretty easy, though.) 27 | 28 | Enter `font-install`: a tool that will download, extract, and install fonts 29 | the exact same way no matter which OS I'm on. 30 | 31 | ## Requirements 32 | 33 | This code requires at least Go 1.18 to build. 34 | 35 | ## Installation 36 | 37 | You can find the latest release on the [releases page][releases] for many 38 | platforms. For other platforms, you may install from source like so: 39 | 40 | ``` 41 | $ go install github.com/Crosse/font-install@latest 42 | ``` 43 | 44 | ## Usage 45 | 46 | General usage details: 47 | ``` 48 | Usage of font-install: 49 | -debug 50 | Enable debug logging 51 | -dry-run 52 | Don't actually download or install anything 53 | -fromFile string 54 | text file containing fonts to install 55 | ``` 56 | 57 | ## Examples 58 | * Download and install the [Source Sans Pro][source-sans-pro] font from 59 | [Font Squirrel][fontsquirrel]: 60 | ``` 61 | $ font-install http://www.fontsquirrel.com/fonts/download/source-sans-pro 62 | Downloading font file from http://www.fontsquirrel.com/fonts/download/source-sans-pro 63 | Skipping non-font file "SIL Open Font License.txt" 64 | ==> Source Sans Pro Black 65 | ==> Source Sans Pro Black Italic 66 | ==> Source Sans Pro Light 67 | ==> Source Sans Pro Light Italic 68 | ==> Source Sans Pro Italic 69 | ==> Source Sans Pro Semibold 70 | ==> Source Sans Pro Semibold Italic 71 | ==> Source Sans Pro ExtraLight 72 | ==> Source Sans Pro ExtraLight Italic 73 | ==> Source Sans Pro 74 | ==> Source Sans Pro Bold 75 | ==> Source Sans Pro Bold Italic 76 | Installed 12 fonts 77 | ``` 78 | 79 | This downloads the source-sans-pro ZIP archive from Font Squirrel, 80 | extracts all of the fonts, and installs them into the user's fonts 81 | directory. 82 | 83 | * Download and install [Chopin Script][chopin-script] from [dafont.com] and enable debug output: 84 | ``` 85 | $ font-install -debug 'http://dl.dafont.com/dl/?f=chopin_script' 86 | Installing font from http://dl.dafont.com/dl/?f=chopin_script 87 | Downloading font file from http://dl.dafont.com/dl/?f=chopin_script 88 | Detected content type: application/zip 89 | Scanning ZIP file for fonts 90 | ==> ChopinScript 91 | Installing "ChopinScript" to /Users/seth/Library/Fonts/ChopinScript.otf 92 | Installed 1 fonts 93 | ``` 94 | 95 | * Install a font file you have stored locally on your file system: 96 | ``` 97 | $ ls *.ttf 98 | OpenSans-Bold.ttf OpenSans-Italic.ttf OpenSans-Regular.ttf 99 | 100 | $ font-install *.ttf 101 | ==> Open Sans Bold 102 | ==> Open Sans Italic 103 | ==> Open Sans 104 | Installed 3 fonts 105 | ``` 106 | * Feed `font-install` a list of fonts to install via a [text file][example.txt]: 107 | ``` 108 | $ font-install -fromFile example.txt 109 | Downloading font file from http://www.fontsquirrel.com/fonts/download/Inconsolata 110 | Skipping non-font file "SIL Open Font License.txt" 111 | Downloading font file from http://www.fontsquirrel.com/fonts/download/dejavu-sans 112 | Skipping non-font file "DejaVu Fonts License.txt" 113 | ==> DejaVu Sans Oblique 114 | ==> DejaVu Sans Condensed Oblique 115 | ==> DejaVu Sans ExtraLight 116 | ==> DejaVu Sans 117 | ==> DejaVu Sans Condensed 118 | ==> DejaVu Sans Condensed Bold 119 | ==> DejaVu Sans Condensed Bold Oblique 120 | ==> DejaVu Sans Bold 121 | ==> DejaVu Sans Bold Oblique 122 | Downloading font file from http://www.fontsquirrel.com/fonts/download/dejavu-sans-mono 123 | Skipping non-font file "DejaVu Fonts License.txt" 124 | ==> DejaVu Sans Mono Bold Oblique 125 | ==> DejaVu Sans Mono 126 | ==> DejaVu Sans Mono Oblique 127 | ==> DejaVu Sans Mono Bold 128 | [...] 129 | Downloading font file from http://www.fontsquirrel.com/fonts/download/ubuntu-mono 130 | Skipping non-font file "UBUNTU FONT LICENCE.txt" 131 | ==> Ubuntu Mono 132 | ==> Ubuntu Mono Italic 133 | ==> Ubuntu Mono Bold 134 | ==> Ubuntu Mono Bold Italic 135 | Downloading font file from http://fontawesome.io/assets/font-awesome-4.7.0.zip 136 | not a font: font-awesome-4.7.0.zip 137 | Installed 72 fonts 138 | ``` 139 | 140 | The above output also shows how `font-install` handles errors. (In this 141 | case, the URL is incorrect.) 142 | 143 | 144 | [releases]: https://github.com/Crosse/font-install/releases/latest 145 | [chopin-script]: http://www.dafont.com/chopin-script.font 146 | [dafont.com]: http://www.dafont.com 147 | [example.txt]: example.txt 148 | [fontsquirrel]: https://www.fontsquirrel.com 149 | [source-sans-pro]: https://www.fontsquirrel.com/fonts/source-sans-pro 150 | -------------------------------------------------------------------------------- /install.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "archive/tar" 5 | "archive/zip" 6 | "bytes" 7 | "compress/gzip" 8 | "context" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "net/http" 13 | "net/url" 14 | "os" 15 | "path" 16 | "runtime" 17 | "strings" 18 | "time" 19 | 20 | log "github.com/Crosse/gosimplelogger" 21 | ) 22 | 23 | var installedFonts = 0 24 | 25 | // InstallFont installs the font specified by fontPath. 26 | // fontPath can either be a URL or a filesystem path. 27 | // For URLs, only the "file", "http", and "https" schemes are currently valid. 28 | func InstallFont(fontPath string) error { 29 | var ( 30 | b []byte 31 | err error 32 | fontData *FontData 33 | ) 34 | 35 | u, err := url.Parse(fontPath) 36 | if err != nil { 37 | return fmt.Errorf("error parsing path: %w", err) 38 | } 39 | 40 | switch u.Scheme { 41 | case "file", "": 42 | if b, err = getLocalFile(fontPath); err != nil { 43 | return err 44 | } 45 | case "http", "https": 46 | if b, err = getRemoteFile(fontPath); err != nil { 47 | return err 48 | } 49 | default: 50 | return fmt.Errorf("unhandled URL scheme: %v", u.Scheme) 51 | } 52 | 53 | filename := path.Base(u.Path) 54 | 55 | ct := getContentType(b) 56 | log.Debugf("content type: %s", ct) 57 | 58 | switch ct { 59 | case "application/zip": 60 | return installFromZIP(b) 61 | case "application/x-gzip": 62 | return installFromGZIP(filename, b) 63 | case "application/octet-stream": 64 | if strings.ToLower(path.Ext(filename)) == ".tar" { 65 | return installFromTarball(bytes.NewReader(b)) 66 | } 67 | 68 | fallthrough 69 | default: 70 | fontData, err = NewFontData(filename, b) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | return install(fontData) 76 | } 77 | } 78 | 79 | func getContentType(data []byte) string { 80 | contentType := http.DetectContentType(data) 81 | log.Debugf("Detected content type: %v", contentType) 82 | 83 | return contentType 84 | } 85 | 86 | func getRemoteFile(url string) ([]byte, error) { 87 | log.Infof("Downloading font file from %v", url) 88 | 89 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 90 | defer cancel() 91 | 92 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 93 | if err != nil { 94 | return nil, fmt.Errorf("cannot make http request: %w", err) 95 | } 96 | 97 | resp, err := http.DefaultClient.Do(req) 98 | if err != nil { 99 | return nil, fmt.Errorf("error getting remote file: %w", err) 100 | } 101 | defer resp.Body.Close() 102 | 103 | if resp.StatusCode != http.StatusOK { 104 | log.Debugf("HTTP request resulted in status %v", resp.StatusCode) 105 | return nil, fmt.Errorf("server returned non-successful status code %d", resp.StatusCode) 106 | } 107 | 108 | data, err := io.ReadAll(resp.Body) 109 | if err != nil { 110 | return nil, fmt.Errorf("erorr reading remote file: %w", err) 111 | } 112 | 113 | return data, nil 114 | } 115 | 116 | func getLocalFile(filename string) ([]byte, error) { 117 | data, err := os.ReadFile(filename) 118 | if err != nil { 119 | return nil, fmt.Errorf("cannot read local file: %w", err) 120 | } 121 | 122 | return data, nil 123 | } 124 | 125 | func installFromGZIP(filename string, data []byte) error { 126 | log.Debug("reading gzipped file") 127 | 128 | bytesReader := bytes.NewReader(data) 129 | 130 | gzipReader, err := gzip.NewReader(bytesReader) 131 | if err != nil { 132 | return fmt.Errorf("cannot read gzip file: %w", err) 133 | } 134 | defer gzipReader.Close() 135 | 136 | uncompressedFilename := strings.TrimSuffix(filename, ".gz") 137 | ext := strings.ToLower(path.Ext(uncompressedFilename)) 138 | 139 | if ext == ".tar" || ext == ".tgz" { 140 | return installFromTarball(gzipReader) 141 | } 142 | 143 | // Gzipped files only contain a single compressed file, so we'll just assume that it's one compressed font. 144 | b, err := io.ReadAll(gzipReader) 145 | if err != nil { 146 | return fmt.Errorf("cannot read compressed file: %w", err) 147 | } 148 | 149 | fontData, err := NewFontData(path.Base(uncompressedFilename), b) 150 | if err != nil { 151 | return err 152 | } 153 | 154 | return install(fontData) 155 | } 156 | 157 | func installFromTarball(r io.Reader) error { 158 | log.Debug("reading tarball") 159 | 160 | tarReader := tar.NewReader(r) 161 | 162 | fonts := make(map[string]*FontData) 163 | 164 | log.Debug("Scanning tarball for fonts") 165 | 166 | for { 167 | hdr, err := tarReader.Next() 168 | if errors.Is(err, io.EOF) { 169 | break 170 | } 171 | 172 | if err != nil { 173 | return fmt.Errorf("cannot read tarball: %w", err) 174 | } 175 | 176 | data, err := io.ReadAll(tarReader) 177 | if err != nil { 178 | return fmt.Errorf("unable to read file %s from tarball: %w", hdr.Name, err) 179 | } 180 | 181 | appendFont(fonts, hdr.Name, data) 182 | } 183 | 184 | return installFonts(fonts) 185 | } 186 | 187 | func installFromZIP(data []byte) error { 188 | log.Debug("reading zipfile") 189 | 190 | bytesReader := bytes.NewReader(data) 191 | 192 | zipReader, err := zip.NewReader(bytesReader, int64(bytesReader.Len())) 193 | if err != nil { 194 | return fmt.Errorf("cannot read zip file: %w", err) 195 | } 196 | 197 | fonts := make(map[string]*FontData) 198 | 199 | log.Debug("Scanning ZIP file for fonts") 200 | 201 | for _, zf := range zipReader.File { 202 | rc, err := zf.Open() 203 | if err != nil { 204 | return fmt.Errorf("cannot open compressed file %s: %w", zf.Name, err) 205 | } 206 | defer rc.Close() 207 | 208 | data, err := io.ReadAll(rc) 209 | if err != nil { 210 | return fmt.Errorf("cannot read compressed file %s: %w", zf.Name, err) 211 | } 212 | 213 | appendFont(fonts, zf.Name, data) 214 | } 215 | 216 | return installFonts(fonts) 217 | } 218 | 219 | func appendFont(fonts map[string]*FontData, fileName string, data []byte) { 220 | fontData, err := NewFontData(fileName, data) 221 | if err != nil { 222 | log.Errorf(`Skipping non-font file "%s"`, fileName) 223 | return 224 | } 225 | 226 | if _, ok := fonts[fontData.Name]; !ok { 227 | fonts[fontData.Name] = fontData 228 | } else { 229 | // Prefer OTF over TTF; otherwise prefer the first font we found. 230 | first := strings.ToLower(path.Ext(fonts[fontData.Name].FileName)) 231 | second := strings.ToLower(path.Ext(fontData.FileName)) 232 | if first != second && second == ".otf" { 233 | log.Infof(`Preferring "%s" over "%s"`, fontData.FileName, fonts[fontData.Name].FileName) 234 | fonts[fontData.Name] = fontData 235 | } 236 | } 237 | } 238 | 239 | func installFonts(fonts map[string]*FontData) error { 240 | for _, font := range fonts { 241 | if strings.Contains(strings.ToLower(font.Name), "windows compatible") { 242 | if runtime.GOOS != "windows" { 243 | // hack to not install the "Windows Compatible" version of every nerd font. 244 | log.Infof(`Ignoring "%s" on non-Windows platform`, font.Name) 245 | continue 246 | } 247 | } 248 | 249 | if err := install(font); err != nil { 250 | return err 251 | } 252 | } 253 | 254 | return nil 255 | } 256 | 257 | func install(fontData *FontData) error { 258 | log.Infof("==> %s", fontData.Name) 259 | 260 | err := platformDependentInstall(fontData) 261 | if err == nil { 262 | installedFonts++ 263 | } 264 | 265 | return err 266 | } 267 | --------------------------------------------------------------------------------