diff --git a/backend/internal/sys/config/conf.go b/backend/internal/sys/config/conf.go index bad0ccf0..3efbacd1 100644 --- a/backend/internal/sys/config/conf.go +++ b/backend/internal/sys/config/conf.go @@ -71,6 +71,8 @@ type LabelMakerConf struct { DynamicLength bool `yaml:"bool" conf:"default:true"` LabelServiceUrl *string `yaml:"label_service_url"` LabelServiceTimeout *time.Duration `yaml:"label_service_timeout"` + RegularFontPath *string `yaml:"regular_font_path"` + BoldFontPath *string `yaml:"bold_font_path"` } type BarcodeAPIConf struct { diff --git a/backend/pkgs/labelmaker/labelmaker.go b/backend/pkgs/labelmaker/labelmaker.go index a28bb72a..49dc3f0d 100644 --- a/backend/pkgs/labelmaker/labelmaker.go +++ b/backend/pkgs/labelmaker/labelmaker.go @@ -27,6 +27,13 @@ import ( "golang.org/x/image/font/gofont/gomedium" ) +type FontType int + +const ( + FontTypeRegular FontType = iota + FontTypeBold +) + type GenerateParameters struct { Width int Height int @@ -140,6 +147,48 @@ func wrapText(text string, face font.Face, maxWidth int, maxHeight int, lineHeig return wrappedLines, "" } +func loadFont(cfg *config.Config, fontType FontType) (*truetype.Font, error) { + var fontPath *string + var fallbackData []byte + + switch fontType { + case FontTypeRegular: + if cfg != nil && cfg.LabelMaker.RegularFontPath != nil { + fontPath = cfg.LabelMaker.RegularFontPath + } + fallbackData = gomedium.TTF + case FontTypeBold: + if cfg != nil && cfg.LabelMaker.BoldFontPath != nil { + fontPath = cfg.LabelMaker.BoldFontPath + } + fallbackData = gobold.TTF + default: + return nil, fmt.Errorf("unknown font type: %d", fontType) + } + + if fontPath != nil && *fontPath != "" { + data, err := os.ReadFile(*fontPath) + if err != nil { + log.Printf("Failed to load font from %s: %v, using fallback font", *fontPath, err) + } else { + font, err := truetype.Parse(data) + if err != nil { + log.Printf("Failed to parse font from %s: %v, using fallback font", *fontPath, err) + } else { + log.Printf("Successfully loaded font from %s", *fontPath) + return font, nil + } + } + } + + font, err := truetype.Parse(fallbackData) + if err != nil { + return nil, err + } + + return font, nil +} + func GenerateLabel(w io.Writer, params *GenerateParameters, cfg *config.Config) error { if err := params.Validate(); err != nil { return err @@ -165,12 +214,12 @@ func GenerateLabel(w io.Writer, params *GenerateParameters, cfg *config.Config) qr.DisableBorder = true qrImage := qr.Image(params.QrSize) - regularFont, err := truetype.Parse(gomedium.TTF) + regularFont, err := loadFont(cfg, FontTypeRegular) if err != nil { return err } - boldFont, err := truetype.Parse(gobold.TTF) + boldFont, err := loadFont(cfg, FontTypeBold) if err != nil { return err } diff --git a/backend/pkgs/labelmaker/labelmaker_test.go b/backend/pkgs/labelmaker/labelmaker_test.go new file mode 100644 index 00000000..3ee3d73c --- /dev/null +++ b/backend/pkgs/labelmaker/labelmaker_test.go @@ -0,0 +1,187 @@ +package labelmaker + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/sysadminsmedia/homebox/backend/internal/sys/config" + "golang.org/x/image/font/gofont/gobold" + "golang.org/x/image/font/gofont/gomedium" +) + +func TestLoadFont_WithNilConfig(t *testing.T) { + font, err := loadFont(nil, FontTypeRegular) + require.NoError(t, err) + assert.NotNil(t, font) + + font, err = loadFont(nil, FontTypeBold) + require.NoError(t, err) + assert.NotNil(t, font) +} + +func TestLoadFont_WithEmptyConfig(t *testing.T) { + cfg := &config.Config{} + + font, err := loadFont(cfg, FontTypeRegular) + require.NoError(t, err) + assert.NotNil(t, font) + + font, err = loadFont(cfg, FontTypeBold) + require.NoError(t, err) + assert.NotNil(t, font) +} + +func TestLoadFont_WithCustomFontPath(t *testing.T) { + tempDir := t.TempDir() + fontPath := filepath.Join(tempDir, "test-font.ttf") + + err := os.WriteFile(fontPath, gomedium.TTF, 0644) + require.NoError(t, err) + + cfg := &config.Config{ + LabelMaker: config.LabelMakerConf{ + RegularFontPath: &fontPath, + }, + } + + font, err := loadFont(cfg, FontTypeRegular) + require.NoError(t, err) + assert.NotNil(t, font) +} + +func TestLoadFont_WithNonExistentFontPath(t *testing.T) { + nonExistentPath := "/non/existent/path/font.ttf" + cfg := &config.Config{ + LabelMaker: config.LabelMakerConf{ + RegularFontPath: &nonExistentPath, + }, + } + + font, err := loadFont(cfg, FontTypeRegular) + require.NoError(t, err) + assert.NotNil(t, font) +} + +func TestLoadFont_UnknownFontType(t *testing.T) { + cfg := &config.Config{} + + _, err := loadFont(cfg, FontType(999)) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown font type") +} + +func TestLoadFont_BoldFontWithCustomPath(t *testing.T) { + tempDir := t.TempDir() + fontPath := filepath.Join(tempDir, "test-bold-font.ttf") + + err := os.WriteFile(fontPath, gobold.TTF, 0644) + require.NoError(t, err) + + cfg := &config.Config{ + LabelMaker: config.LabelMakerConf{ + BoldFontPath: &fontPath, + }, + } + + font, err := loadFont(cfg, FontTypeBold) + require.NoError(t, err) + assert.NotNil(t, font) +} + +func TestLoadFont_EmptyStringPath(t *testing.T) { + emptyPath := "" + cfg := &config.Config{ + LabelMaker: config.LabelMakerConf{ + RegularFontPath: &emptyPath, + }, + } + + font, err := loadFont(cfg, FontTypeRegular) + require.NoError(t, err) + assert.NotNil(t, font) +} + +func TestLoadFont_CJKRendering(t *testing.T) { + cjkFontPath := filepath.Join("testdata", "NotoSansKR-VF.ttf") + + tests := []struct { + name string + text string + fontPath string + shouldHaveGlyph bool + }{ + { + name: "Korean with default font", + text: "한글", + fontPath: "", + shouldHaveGlyph: false, + }, + { + name: "Chinese with default font", + text: "中文", + fontPath: "", + shouldHaveGlyph: false, + }, + { + name: "Japanese with default font", + text: "ひらがなカタカナ", + fontPath: "", + shouldHaveGlyph: false, + }, + { + name: "Korean with Noto Sans CJK", + text: "한글", + fontPath: cjkFontPath, + shouldHaveGlyph: true, + }, + { + name: "Chinese with Noto Sans CJK", + text: "中文", + fontPath: cjkFontPath, + shouldHaveGlyph: true, + }, + { + name: "Japanese with Noto Sans CJK", + text: "ひらがなカタカナ", + fontPath: cjkFontPath, + shouldHaveGlyph: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var cfg *config.Config + if tt.fontPath != "" { + if _, err := os.Stat(tt.fontPath); os.IsNotExist(err) { + t.Skipf("Font file not found: %s", tt.fontPath) + } + cfg = &config.Config{ + LabelMaker: config.LabelMakerConf{ + RegularFontPath: &tt.fontPath, + }, + } + } + + font, err := loadFont(cfg, FontTypeRegular) + require.NoError(t, err) + require.NotNil(t, font) + + hasAllGlyphs := true + for _, r := range tt.text { + if font.Index(r) == 0 { + hasAllGlyphs = false + break + } + } + + if tt.shouldHaveGlyph { + assert.True(t, hasAllGlyphs, "Font should render %s characters", tt.name) + } else { + assert.False(t, hasAllGlyphs, "Default font should not render %s characters", tt.name) + } + }) + } +} diff --git a/backend/pkgs/labelmaker/testdata/NotoSans-LICENSE b/backend/pkgs/labelmaker/testdata/NotoSans-LICENSE new file mode 100644 index 00000000..d952d62c --- /dev/null +++ b/backend/pkgs/labelmaker/testdata/NotoSans-LICENSE @@ -0,0 +1,92 @@ +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/backend/pkgs/labelmaker/testdata/NotoSansKR-VF.ttf b/backend/pkgs/labelmaker/testdata/NotoSansKR-VF.ttf new file mode 100644 index 00000000..15b3f19b Binary files /dev/null and b/backend/pkgs/labelmaker/testdata/NotoSansKR-VF.ttf differ diff --git a/backend/pkgs/labelmaker/testdata/README.md b/backend/pkgs/labelmaker/testdata/README.md new file mode 100644 index 00000000..b37ff888 --- /dev/null +++ b/backend/pkgs/labelmaker/testdata/README.md @@ -0,0 +1,15 @@ +# Test Data + +This directory contains font files used only for testing purposes. + +## Fonts + +- **NotoSansKR-VF.ttf**: Noto Sans CJK Korean Variable Font + - Used for testing CJK (Chinese, Japanese, Korean) character rendering + - License: See `NotoSans-LICENSE` file + +## Notes + +- These fonts are **only used during testing** and are **not included in production builds** +- Go's build system automatically excludes `testdata` directories from production builds +- The fonts support rendering of Korean, Chinese, and Japanese characters diff --git a/docs/en/configure/index.md b/docs/en/configure/index.md index d3fc2cde..61aa8f18 100644 --- a/docs/en/configure/index.md +++ b/docs/en/configure/index.md @@ -48,6 +48,8 @@ aside: false | HBOX_LABEL_MAKER_PRINT_COMMAND | | the command to use for printing labels. if empty, label printing is disabled. `{{.FileName}}` in the command will be replaced with the png filename of the label | | HBOX_LABEL_MAKER_DYNAMIC_LENGTH | true | allow label generation with open length. `HBOX_LABEL_MAKER_HEIGHT` is still used for layout and minimal height. If not used, long text may be cut off, but all labels have the same size. | | HBOX_LABEL_MAKER_ADDITIONAL_INFORMATION | | Additional information added to the label like name or phone number | +| HBOX_LABEL_MAKER_REGULAR_FONT_PATH | | path to regular font file for label generation (e.g., `/fonts/NotoSansKR-Regular.ttf`). If not set, uses embedded font. Supports TTF format. | +| HBOX_LABEL_MAKER_BOLD_FONT_PATH | | path to bold font file for label generation (e.g., `/fonts/NotoSansKR-Bold.ttf`). If not set, uses embedded font. Supports TTF format. | | HBOX_THUMBNAIL_ENABLED | true | enable thumbnail generation for images, supports PNG, JPEG, AVIF, WEBP, GIF file types | | HBOX_THUMBNAIL_WIDTH | 500 | width for generated thumbnails in pixels | | HBOX_THUMBNAIL_HEIGHT | 500 | height for generated thumbnails in pixels | @@ -192,6 +194,8 @@ OPTIONS --label-maker-print-command/$HBOX_LABEL_MAKER_PRINT_COMMAND --label-maker-dynamic-length/$HBOX_LABEL_MAKER_DYNAMIC_LENGTH (default: true) --label-maker-additional-information/$HBOX_LABEL_MAKER_ADDITIONAL_INFORMATION +--label-maker-regular-font-path/$HBOX_LABEL_MAKER_REGULAR_FONT_PATH +--label-maker-bold-font-path/$HBOX_LABEL_MAKER_BOLD_FONT_PATH --thumbnail-enabled/$HBOX_THUMBNAIL_ENABLED (default: true) --thumbnail-width/$HBOX_THUMBNAIL_WIDTH (default: 500) --thumbnail-height/$HBOX_THUMBNAIL_HEIGHT (default: 500) diff --git a/docs/en/custom-font-setup.md b/docs/en/custom-font-setup.md new file mode 100644 index 00000000..69f0023c --- /dev/null +++ b/docs/en/custom-font-setup.md @@ -0,0 +1,80 @@ +# External Font Support for Label Maker + +Label maker supports external font files. + +## Quick Start + +### Docker/Podman Setup + +1. **Download external fonts** (e.g., Noto Sans KR): + - Download from [Google Fonts](https://fonts.google.com/noto/specimen/Noto+Sans+KR) + - Or use the Variable Font from [GitHub](https://github.com/notofonts/noto-cjk) + +2. **Create a fonts directory**: +```bash +mkdir -p ./fonts +# Place your font files in this directory +# e.g., NotoSansKR-VF.ttf +``` + +3. **Mount the fonts directory and set environment variables**: +```yaml +# docker-compose.yml +services: + homebox: + image: homebox:latest + volumes: + - ./data:/data + - ./fonts:/fonts:ro # Mount fonts directory as read-only + environment: + - HBOX_LABEL_MAKER_REGULAR_FONT_PATH=/fonts/NotoSansKR-VF.ttf + - HBOX_LABEL_MAKER_BOLD_FONT_PATH=/fonts/NotoSansKR-VF.ttf + ports: + - 3100:7745 +``` + +Or with podman: +```bash +podman run -d \ + --name homebox \ + -p 3100:7745 \ + -v ./data:/data \ + -v ./fonts:/fonts:ro \ + -e HBOX_LABEL_MAKER_REGULAR_FONT_PATH=/fonts/NotoSansKR-VF.ttf \ + -e HBOX_LABEL_MAKER_BOLD_FONT_PATH=/fonts/NotoSansKR-VF.ttf \ + homebox:latest +``` + +4. **Restart the container** and test label generation with Chinese, Japanese, Korean text! + +## Supported Fonts + +- **Format**: TTF (TrueType Font) +- **Recommended Fonts**: + - Noto Sans KR (Korean) + - Noto Sans CJK (Chinese, Japanese, Korean) + - Noto Sans SC (Simplified Chinese) + - Noto Sans JP (Japanese) + +## Fallback Behavior + +1. **External font specified** → Tries to load from `HBOX_LABEL_MAKER_*_FONT_PATH` +2. **External font fails or not specified** → Falls back to bundled Go fonts (Latin-only, **does not support CJK characters**) + +## Troubleshooting + +### Labels still show squares (□□□) +- Check if the font file exists at the specified path +- Verify the font file format (must be TTF, not OTF) +- Check container logs: `podman logs homebox | grep -i font` + +### Font file not found +- Ensure the volume is correctly mounted +- Check file permissions (font files should be readable) +- Use absolute paths in environment variables + +## Why External Fonts? + +- **Smaller base image**: No need to embed large font files (~10MB per font) +- **Flexibility**: Easily switch fonts without rebuilding the image +- **Multi-language support**: Add support for any language by mounting appropriate fonts