Skip to content

feat: add TextOverlay functionality for rendering text on images - #1

Open
llnhnv wants to merge 1 commit into
mainfrom
feat/update-new-usecases
Open

feat: add TextOverlay functionality for rendering text on images#1
llnhnv wants to merge 1 commit into
mainfrom
feat/update-new-usecases

Conversation

@llnhnv

@llnhnv llnhnv commented Apr 18, 2026

Copy link
Copy Markdown
Member
  • 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.
Copilot AI review requested due to automatic review settings April 18, 2026 16:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/TextAlign and DrawTextOverlays for rendering text onto RGBA images (with embedded default font support).
  • Adds MergeQRAndTextAutoDetect / MergeQRAndTextWithFrame to 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.

Comment thread example_test.go
Comment on lines +205 to +209
// 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},

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread README.md
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×

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment thread text.go
Font []byte

// LineSpacing is the multiplier applied to font size for multi-line text.
// 0 or 1 means single-space (FontSize px between baselines).

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// 0 or 1 means single-space (FontSize px between baselines).
// Values <= 0 use the default spacing; 1 means single-space
// (FontSize px between baselines).

Copilot uses AI. Check for mistakes.
Comment thread README.md
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×

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment thread ecard_test.go
Comment on lines +55 to +57
os.MkdirAll("outputs/eCard", 0755)
name := fmt.Sprintf("outputs/eCard/%s", tc.file[len("inputs/eCard/"):])
os.WriteFile(name, pngBytes, 0644)

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)
}

Copilot uses AI. Check for mistakes.
Comment thread example_test.go
Comment on lines 28 to 33
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)

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread text.go
Comment on lines +134 to +135
// splitLines splits text on explicit "\n" and, when maxWidth > 0, also wraps
// long words at word boundaries to fit within maxWidth pixels.

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
Comment thread text.go
Comment on lines +74 to +105
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()

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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
}

Copilot uses AI. Check for mistakes.
Comment thread ecard_test.go
Comment on lines +22 to +26
{"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},

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants