feat: add TextOverlay functionality for rendering text on images - #1
feat: add TextOverlay functionality for rendering text on images#1llnhnv wants to merge 1 commit into
Conversation
llnhnv
commented
Apr 18, 2026
- Introduced TextOverlay struct to define text properties such as content, position, width, font size, color, alignment, and line spacing.
- Implemented DrawTextOverlays function to render multiple text overlays onto an image.
- Added support for custom fonts via embedded TTF/OTF files.
- Included line wrapping and alignment options for better text layout.
- Utilized golang.org/x/image/font for text rendering and measurement.
- Introduced TextOverlay struct to define text properties such as content, position, width, font size, color, alignment, and line spacing. - Implemented DrawTextOverlays function to render multiple text overlays onto an image. - Added support for custom fonts via embedded TTF/OTF files. - Included line wrapping and alignment options for better text layout. - Utilized golang.org/x/image/font for text rendering and measurement.
There was a problem hiding this comment.
Pull request overview
Adds text overlay rendering to QR+frame composition, allowing callers to place wrapped/aligned text (with optional custom fonts) onto the output image.
Changes:
- Introduces
TextOverlay/TextAlignandDrawTextOverlaysfor rendering text onto RGBA images (with embedded default font support). - Adds
MergeQRAndTextAutoDetect/MergeQRAndTextWithFrameto render overlays after QR composition. - Enhances placeholder auto-detection with candidate validation (interior uniformity + border presence) and updates docs/tests accordingly.
Reviewed changes
Copilot reviewed 8 out of 11 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
text.go |
New text overlay API and rendering implementation (embedded default font, wrapping/alignment). |
merge.go |
Adds merge helpers that render text overlays; refactors QR canvas building for reuse. |
detect.go |
Adds candidate validation (border + interior variance) and a secondary attempt in edge phase. |
README.md |
Documents new text overlay API and merge functions (EN + VI). |
example_test.go |
Expands integration tests and output generation; adds (currently fixture-dependent) overlay tests. |
ecard_test.go |
Adds eCard detection/merge test that depends on local fixtures. |
doc_test.go |
Adds a pkg.go.dev example for MergeQRAndTextWithFrame. |
go.mod |
Adds indirect dependency on golang.org/x/text. |
go.sum |
Updates checksums for golang.org/x/text. |
.gitignore |
Updates ignore rules for inputs/* and outputs/*. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Portrait 3240×5760 | ||
| {"inputs/eCard/1.png", 200, 20, "eCard/text_1.png", 200, 3375, 2840, 120, color.White}, | ||
| {"inputs/eCard/2.png", 200, 20, "eCard/text_2.png", 200, 3040, 2840, 140, color.White}, | ||
| {"inputs/eCard/5.png", 200, 20, "eCard/text_5.png", 200, 3170, 2840, 140, color.White}, | ||
| {"inputs/eCard/6.png", 200, 20, "eCard/text_6.png", 200, 1820, 2840, 140, color.White}, |
There was a problem hiding this comment.
These cases depend on inputs/eCard/.png fixtures that are not present in the repository, so this test will always hit the "SKIP" path in CI and won’t provide regression coverage for the new text overlay functionality. Consider either committing minimal fixtures (and adjusting .gitignore accordingly) or rewriting this test to use the existing frame_sample.png assets so it exercises MergeQRAndText* deterministically.
| Color color.Color // mặc định: đen | ||
| Align TextAlign // AlignLeft | AlignCenter | AlignRight | ||
| Font []byte // TTF/OTF bytes; nil = font Go Regular tích hợp sẵn | ||
| LineSpacing float64 // hệ số khoảng cách dòng; 0 hoặc 1 → 1.2× |
There was a problem hiding this comment.
Vietnamese API docs also say LineSpacing "0 hoặc 1 → 1.2×", but the code only defaults when LineSpacing<=0 (and 1.0 produces single-spacing). Please update this section to reflect actual behavior (or align implementation).
| LineSpacing float64 // hệ số khoảng cách dòng; 0 hoặc 1 → 1.2× | |
| LineSpacing float64 // hệ số khoảng cách dòng; <= 0 → 1.2×, 1.0 = giãn dòng đơn |
| Font []byte | ||
|
|
||
| // LineSpacing is the multiplier applied to font size for multi-line text. | ||
| // 0 or 1 means single-space (FontSize px between baselines). |
There was a problem hiding this comment.
TextOverlay.LineSpacing doc comment says "0 or 1 means single-space", but drawTextOverlay currently treats LineSpacing<=0 as default 1.2 (and LineSpacing==1 results in 1.0). Please align the public API docs with the actual behavior (or change the defaulting logic to match the doc).
| // 0 or 1 means single-space (FontSize px between baselines). | |
| // Values <= 0 use the default spacing; 1 means single-space | |
| // (FontSize px between baselines). |
| Color color.Color // default: black | ||
| Align TextAlign // AlignLeft | AlignCenter | AlignRight | ||
| Font []byte // raw TTF/OTF bytes; nil = built-in Go Regular | ||
| LineSpacing float64 // baseline-to-baseline multiplier; 0 or 1 = 1.2× |
There was a problem hiding this comment.
The README states "LineSpacing: 0 or 1 = 1.2×", but the implementation defaults only when LineSpacing<=0; an explicit 1.0 currently yields single-spacing. Please update the README to match the code (or adjust the code to match the README) to avoid confusing API consumers.
| LineSpacing float64 // baseline-to-baseline multiplier; 0 or 1 = 1.2× | |
| LineSpacing float64 // baseline-to-baseline multiplier; <= 0 = 1.2× default, 1.0 = single-spacing |
| os.MkdirAll("outputs/eCard", 0755) | ||
| name := fmt.Sprintf("outputs/eCard/%s", tc.file[len("inputs/eCard/"):]) | ||
| os.WriteFile(name, pngBytes, 0644) |
There was a problem hiding this comment.
Errors from os.MkdirAll / os.WriteFile are ignored here, which can hide failures (permissions, read-only FS, etc.) and lead to misleading test passes/logs. Please handle and fail the test on error.
| os.MkdirAll("outputs/eCard", 0755) | |
| name := fmt.Sprintf("outputs/eCard/%s", tc.file[len("inputs/eCard/"):]) | |
| os.WriteFile(name, pngBytes, 0644) | |
| if err := os.MkdirAll("outputs/eCard", 0755); err != nil { | |
| t.Fatalf("mkdir outputs/eCard: %v", err) | |
| } | |
| name := fmt.Sprintf("outputs/eCard/%s", tc.file[len("inputs/eCard/"):]) | |
| if err := os.WriteFile(name, pngBytes, 0644); err != nil { | |
| t.Fatalf("write %s: %v", name, err) | |
| } |
| path := outputDir + "/" + name | ||
| if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { | ||
| t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) | ||
| } | ||
| if err := os.WriteFile(path, data, 0644); err != nil { | ||
| t.Fatalf("write %s: %v", path, err) |
There was a problem hiding this comment.
writeOutput builds paths using string concatenation with "/". For cross-platform correctness (Windows) and cleaner path handling, prefer filepath.Join(outputDir, name) and avoid hardcoded separators.
| // splitLines splits text on explicit "\n" and, when maxWidth > 0, also wraps | ||
| // long words at word boundaries to fit within maxWidth pixels. |
There was a problem hiding this comment.
splitLines comment says it "wraps long words" to fit maxWidth, but wrapWords does not handle a single word longer than maxWidth (it will still exceed width). Either implement breaking of overlong tokens (e.g., rune-level or grapheme-level splitting) or adjust the comment/doc to state that very long words may overflow.
| // splitLines splits text on explicit "\n" and, when maxWidth > 0, also wraps | |
| // long words at word boundaries to fit within maxWidth pixels. | |
| // splitLines splits text on explicit "\n" and, when maxWidth > 0, wraps text | |
| // at word boundaries to fit within maxWidth pixels where possible. A single | |
| // word longer than maxWidth is not split and may overflow. |
| func DrawTextOverlays(dst *image.RGBA, overlays []TextOverlay) error { | ||
| for i := range overlays { | ||
| if err := drawTextOverlay(dst, &overlays[i]); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func drawTextOverlay(dst *image.RGBA, ov *TextOverlay) error { | ||
| fontBytes := ov.Font | ||
| if fontBytes == nil { | ||
| fontBytes = defaultFontBytes | ||
| } | ||
|
|
||
| ft, err := opentype.Parse(fontBytes) | ||
| if err != nil { | ||
| return fmt.Errorf("qrframe text: parse font: %w", err) | ||
| } | ||
|
|
||
| size := ov.FontSize | ||
| if size <= 0 { | ||
| size = 16 | ||
| } | ||
| face, err := opentype.NewFace(ft, &opentype.FaceOptions{ | ||
| Size: size, | ||
| DPI: 72, | ||
| }) | ||
| if err != nil { | ||
| return fmt.Errorf("qrframe text: create face: %w", err) | ||
| } | ||
| defer face.Close() |
There was a problem hiding this comment.
drawTextOverlay parses the font and creates an opentype.Face for every overlay, which becomes expensive when rendering multiple overlays (especially with repeated fonts/sizes). Consider caching the parsed font and/or face (keyed by font bytes + size/DPI) across overlays, and only closing faces when evicted or when DrawTextOverlays completes.
| func DrawTextOverlays(dst *image.RGBA, overlays []TextOverlay) error { | |
| for i := range overlays { | |
| if err := drawTextOverlay(dst, &overlays[i]); err != nil { | |
| return err | |
| } | |
| } | |
| return nil | |
| } | |
| func drawTextOverlay(dst *image.RGBA, ov *TextOverlay) error { | |
| fontBytes := ov.Font | |
| if fontBytes == nil { | |
| fontBytes = defaultFontBytes | |
| } | |
| ft, err := opentype.Parse(fontBytes) | |
| if err != nil { | |
| return fmt.Errorf("qrframe text: parse font: %w", err) | |
| } | |
| size := ov.FontSize | |
| if size <= 0 { | |
| size = 16 | |
| } | |
| face, err := opentype.NewFace(ft, &opentype.FaceOptions{ | |
| Size: size, | |
| DPI: 72, | |
| }) | |
| if err != nil { | |
| return fmt.Errorf("qrframe text: create face: %w", err) | |
| } | |
| defer face.Close() | |
| type textFace interface { | |
| xfont.Face | |
| Close() error | |
| } | |
| type textFaceCacheKey struct { | |
| font string | |
| size float64 | |
| dpi float64 | |
| } | |
| type textOverlayFaceCache struct { | |
| fonts map[string]*opentype.Font | |
| faces map[textFaceCacheKey]textFace | |
| } | |
| func newTextOverlayFaceCache() *textOverlayFaceCache { | |
| return &textOverlayFaceCache{ | |
| fonts: make(map[string]*opentype.Font), | |
| faces: make(map[textFaceCacheKey]textFace), | |
| } | |
| } | |
| func (c *textOverlayFaceCache) close() { | |
| for _, face := range c.faces { | |
| _ = face.Close() | |
| } | |
| } | |
| func (c *textOverlayFaceCache) getFace(fontBytes []byte, size, dpi float64) (textFace, error) { | |
| fontKey := string(fontBytes) | |
| faceKey := textFaceCacheKey{ | |
| font: fontKey, | |
| size: size, | |
| dpi: dpi, | |
| } | |
| if face, ok := c.faces[faceKey]; ok { | |
| return face, nil | |
| } | |
| ft, ok := c.fonts[fontKey] | |
| if !ok { | |
| var err error | |
| ft, err = opentype.Parse(fontBytes) | |
| if err != nil { | |
| return nil, fmt.Errorf("qrframe text: parse font: %w", err) | |
| } | |
| c.fonts[fontKey] = ft | |
| } | |
| face, err := opentype.NewFace(ft, &opentype.FaceOptions{ | |
| Size: size, | |
| DPI: dpi, | |
| }) | |
| if err != nil { | |
| return nil, fmt.Errorf("qrframe text: create face: %w", err) | |
| } | |
| c.faces[faceKey] = face | |
| return face, nil | |
| } | |
| func DrawTextOverlays(dst *image.RGBA, overlays []TextOverlay) error { | |
| cache := newTextOverlayFaceCache() | |
| defer cache.close() | |
| for i := range overlays { | |
| if err := drawTextOverlay(dst, &overlays[i], cache); err != nil { | |
| return err | |
| } | |
| } | |
| return nil | |
| } | |
| func drawTextOverlay(dst *image.RGBA, ov *TextOverlay, cache *textOverlayFaceCache) error { | |
| fontBytes := ov.Font | |
| if fontBytes == nil { | |
| fontBytes = defaultFontBytes | |
| } | |
| size := ov.FontSize | |
| if size <= 0 { | |
| size = 16 | |
| } | |
| face, err := cache.getFace(fontBytes, size, 72) | |
| if err != nil { | |
| return err | |
| } |
| {"inputs/eCard/1.png", 200, 20}, | ||
| {"inputs/eCard/2.png", 200, 20}, | ||
| {"inputs/eCard/3.png", 200, 20}, | ||
| {"inputs/eCard/4.png", 200, 20}, | ||
| {"inputs/eCard/5.png", 200, 20}, |
There was a problem hiding this comment.
This test references inputs/eCard/*.png, but those fixtures are not present in the repository. As written, this will fail in a clean checkout/CI (the file server returns 404 and image.Decode fatals). Either add the required fixtures to the repo, or gate this as an integration test (build tag / env var) and t.Skip when files are missing.