nameplate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +122 -0
- data/Rakefile +39 -0
- data/lib/nameplate/avatar/cache.rb +59 -0
- data/lib/nameplate/avatar/generator.rb +237 -0
- data/lib/nameplate/avatar/identity.rb +41 -0
- data/lib/nameplate/avatar.rb +37 -0
- data/lib/nameplate/colors/palette.rb +45 -0
- data/lib/nameplate/colors/palettes/custom.rb +20 -0
- data/lib/nameplate/colors/palettes/dracula.rb +29 -0
- data/lib/nameplate/colors/palettes/google.rb +55 -0
- data/lib/nameplate/colors/palettes/iwanthue.rb +234 -0
- data/lib/nameplate/colors/palettes/jedi_light.rb +26 -0
- data/lib/nameplate/colors/palettes/monokai.rb +27 -0
- data/lib/nameplate/colors/palettes/pastel.rb +24 -0
- data/lib/nameplate/colors.rb +42 -0
- data/lib/nameplate/configuration.rb +109 -0
- data/lib/nameplate/fonts/Lato-Light.ttf +0 -0
- data/lib/nameplate/fonts/Roboto-Medium +0 -0
- data/lib/nameplate/has_avatar.rb +33 -0
- data/lib/nameplate/image/resize.rb +111 -0
- data/lib/nameplate/results/failure_result.rb +35 -0
- data/lib/nameplate/results/success_result.rb +27 -0
- data/lib/nameplate/utils/path_helper.rb +18 -0
- data/lib/nameplate/version.rb +5 -0
- data/lib/nameplate/view_helpers/avatar_helper.rb +60 -0
- data/lib/nameplate.rb +57 -0
- data/spec/fixtures/in.png +0 -0
- data/spec/nameplate/avatar/cache_spec.rb +35 -0
- data/spec/nameplate/avatar/generator_spec.rb +108 -0
- data/spec/nameplate/avatar/identity_spec.rb +27 -0
- data/spec/nameplate/colors_spec.rb +34 -0
- data/spec/nameplate/image/resize_spec.rb +96 -0
- data/spec/nameplate/utils/path_helper_spec.rb +16 -0
- data/spec/nameplate/view_helpers/avatar_helper_spec.rb +38 -0
- data/spec/nameplate_spec.rb +102 -0
- data/spec/spec_helper.rb +13 -0
- metadata +111 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 71033d48e71e5ea461b075ae0d1d6b5f359eb9dfcd39885ec2994ec4c1475641
|
|
4
|
+
data.tar.gz: bc180c2c1ebee448c4c19ee3ed16e20cf4f20cde96f131963f12451e0b9e0bde
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4647667e97c3ab81e10b2730048c8dd3098524eb2d67e635c3ee60652145b8c5d52ab10cb3b5a3cefa7d2ea3f0266eada2596dd459832d978972f15b76a7125b
|
|
7
|
+
data.tar.gz: 2b88c146ad0373b3069723d962b964e432ccd2f84d46abc49d9fb4034c477fe99be90c5c2dc58d00fec8508d2c46b9fff37f83b0a5e62e0f7f7df77f8369aa9b
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 t0nylombardi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# NamePlate
|
|
2
|
+
|
|
3
|
+
**NamePlate** is a Ruby gem for generating simple, Google-style avatar images from names or usernames. It’s a modernized successor to [letter_avatar](https://github.com/ksz2k/letter_avatar), built with maintainability, SOLID design, and Rails-friendly integration in mind.
|
|
4
|
+
|
|
5
|
+
Think of it as a quick way to give everyone in your app a unique, consistent avatar — without storing profile photos.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- 🔠 Generates avatars with initials (e.g., **Tony Lombardi → TL**)
|
|
12
|
+
- 🎨 Deterministic background colors from input strings
|
|
13
|
+
- 📐 Flexible sizing (from tiny icons to large images)
|
|
14
|
+
- 🖼 Transparent padding and proper centering with MiniMagick
|
|
15
|
+
- ⚡ Caching support for faster repeated lookups
|
|
16
|
+
- ✅ Rails helpers for easy view integration
|
|
17
|
+
- 🧪 Fully tested and type-annotated with RBS
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Add this line to your application’s Gemfile:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
gem "nameplate"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
And then execute:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bundle install
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or install it yourself with:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
gem install nameplate
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
### Quick Start
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
require "nameplate"
|
|
49
|
+
|
|
50
|
+
# Generate a 128x128 avatar for "Tony"
|
|
51
|
+
path = NamePlate::Avatar::Generator.call("Tony", 128)
|
|
52
|
+
|
|
53
|
+
# => "tmp/generated/128.png"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### With Rails View Helper
|
|
57
|
+
|
|
58
|
+
```erb
|
|
59
|
+
<%= nameplate_avatar("Tony", size: 64, class: "avatar") %>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Outputs an `<img>` tag pointing to the cached avatar file.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Configuration
|
|
67
|
+
|
|
68
|
+
You can configure fonts, colors, and weights globally:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
NamePlate.configure do |config|
|
|
72
|
+
config.font = Rails.root.join("app/assets/fonts/YourFont.ttf")
|
|
73
|
+
config.weight = 400
|
|
74
|
+
config.pointsize = 64
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Async Generation
|
|
81
|
+
|
|
82
|
+
For heavy workloads (e.g., pre-warming caches):
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
future = NamePlate::Avatar::Generator.async_call("Tony", 128)
|
|
86
|
+
future.value! # blocks until done
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
Clone the repo and run:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
bin/setup
|
|
97
|
+
bundle exec rake spec
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Roadmap
|
|
103
|
+
|
|
104
|
+
- SVG support
|
|
105
|
+
- Rails engine integration (asset pipeline)
|
|
106
|
+
- Configurable color palettes
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Contributing
|
|
111
|
+
|
|
112
|
+
Bug reports and pull requests are welcome on GitHub at [https://github.com/t0nylombardi/nameplate](https://github.com/t0nylombardi/nameplate).
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT License. See `LICENSE.txt` for details.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
⚡ **NamePlate** — because every name deserves a face.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "standard/rake"
|
|
5
|
+
require "pry"
|
|
6
|
+
require "pry-byebug"
|
|
7
|
+
require_relative "lib/nameplate"
|
|
8
|
+
|
|
9
|
+
SIG_DIR = "sig"
|
|
10
|
+
|
|
11
|
+
task default: %i[specstandard]
|
|
12
|
+
task :standard do
|
|
13
|
+
Standard::RakeTask.new.execute
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
desc "Iterate over [A..Z] letters in every theme"
|
|
17
|
+
task :iterate_letters do
|
|
18
|
+
output_dir = File.expand_path("demo_images", __dir__)
|
|
19
|
+
FileUtils.mkdir_p(output_dir)
|
|
20
|
+
|
|
21
|
+
size = 128
|
|
22
|
+
|
|
23
|
+
NamePlate::Colors.registry.each do |key, palette|
|
|
24
|
+
puts "== Theme: #{key} =="
|
|
25
|
+
|
|
26
|
+
NamePlate.colors_palette = key
|
|
27
|
+
theme_dir = File.join(output_dir, key.to_s)
|
|
28
|
+
FileUtils.mkdir_p(theme_dir)
|
|
29
|
+
|
|
30
|
+
("A".."Z").each do |letter|
|
|
31
|
+
path = NamePlate::Avatar.generate(letter, size)
|
|
32
|
+
dest = File.join(theme_dir, "#{letter}.png")
|
|
33
|
+
FileUtils.cp(path, dest)
|
|
34
|
+
puts " #{letter} → #{dest}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
puts "All images generated under #{output_dir}/"
|
|
39
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent-ruby"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module NamePlate
|
|
7
|
+
class Avatar
|
|
8
|
+
# Thread-safe cache manager for avatar file paths.
|
|
9
|
+
class Cache
|
|
10
|
+
@cache = Concurrent::Map.new
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Returns the base path for cached avatars.
|
|
14
|
+
#
|
|
15
|
+
# @return [String] Base cache path.
|
|
16
|
+
def base_path
|
|
17
|
+
"#{NamePlate.cache_base_path || "public/system"}/nameplate/#{Avatar::VERSION}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Builds or fetches the file path for a cached avatar.
|
|
21
|
+
#
|
|
22
|
+
# Uses a Concurrent::Map to ensure thread-safe memoization.
|
|
23
|
+
#
|
|
24
|
+
# @param [Identity] identity The identity object representing the user.
|
|
25
|
+
# @param [Integer] size The size of the avatar.
|
|
26
|
+
# @return [String] The file path for the cached avatar.
|
|
27
|
+
def path(identity, size)
|
|
28
|
+
key = cache_key(identity, size)
|
|
29
|
+
|
|
30
|
+
@cache.compute_if_absent(key) do
|
|
31
|
+
dir = File.join(base_path, identity.letters, identity.color.join("_"))
|
|
32
|
+
FileUtils.mkdir_p(dir)
|
|
33
|
+
File.join(dir, "#{size}.png")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Check if a cached avatar exists for the given identity and size.
|
|
38
|
+
#
|
|
39
|
+
# @param [Identity] identity The identity object representing the user.
|
|
40
|
+
# @param [Integer] size The size of the avatar.
|
|
41
|
+
# @return [Boolean] True if a cached avatar exists, false otherwise.
|
|
42
|
+
def cached?(identity, size)
|
|
43
|
+
File.exist?(path(identity, size))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Builds a unique cache key from identity + size.
|
|
49
|
+
#
|
|
50
|
+
# @param [Identity] identity
|
|
51
|
+
# @param [Integer] size
|
|
52
|
+
# @return [String]
|
|
53
|
+
def cache_key(identity, size)
|
|
54
|
+
"#{identity.letters}-#{identity.color.join("_")}-#{size}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mini_magick"
|
|
4
|
+
require "concurrent-ruby"
|
|
5
|
+
|
|
6
|
+
module NamePlate
|
|
7
|
+
class Avatar
|
|
8
|
+
# Generates PNG avatars from usernames using MiniMagick/ImageMagick.
|
|
9
|
+
#
|
|
10
|
+
# - Derives an {Identity} (initial letters + background color) from a username.
|
|
11
|
+
# - Produces a square PNG at the requested size (capped at {Avatar::FULLSIZE}).
|
|
12
|
+
# - Uses {Avatar::Cache} to reuse existing files and to build deterministic paths.
|
|
13
|
+
#
|
|
14
|
+
# The high-level API for consumers is {NamePlate::Avatar.generate}. This class
|
|
15
|
+
# exists as the lower-level, orchestration entry point that handles logging,
|
|
16
|
+
# validation, caching, and MiniMagick invocation.
|
|
17
|
+
#
|
|
18
|
+
# Requirements:
|
|
19
|
+
# - ImageMagick must be installed and accessible on the PATH (MiniMagick uses `convert`).
|
|
20
|
+
# - A valid font file must be configured at {Avatar::FONT_FILE}.
|
|
21
|
+
#
|
|
22
|
+
# Configuration sources used during generation:
|
|
23
|
+
# - `NamePlate.pointsize`, `NamePlate.weight`, `NamePlate.annotate_position`
|
|
24
|
+
# - {Avatar::FILL_COLOR} (foreground text/letters color)
|
|
25
|
+
# - {Avatar::FONT_FILE} (font used by ImageMagick)
|
|
26
|
+
# - `NamePlate.cache_base_path` (root for cached files; see {Avatar::Cache})
|
|
27
|
+
# - `ENV["NAMEPLATE_LOG_LEVEL"]` (set to `DEBUG` for verbose logging)
|
|
28
|
+
#
|
|
29
|
+
# @example Generate and return a cached 128px avatar path
|
|
30
|
+
# path = NamePlate::Avatar::Generator.call("Tony Baloney", 128)
|
|
31
|
+
# # => "public/system/nameplate/1/TB/163_163_163/128.png"
|
|
32
|
+
#
|
|
33
|
+
# @example Disable cache and provide a custom logger
|
|
34
|
+
# logger = Logger.new($stderr)
|
|
35
|
+
# path = NamePlate::Avatar::Generator.call("Ada Lovelace", 256, cache: false, logger: logger)
|
|
36
|
+
# # Generates a fresh 256px PNG even if a cached one exists
|
|
37
|
+
#
|
|
38
|
+
# @see NamePlate::Avatar.generate User-facing convenience API
|
|
39
|
+
# @see NamePlate::Avatar::Cache Path building and cache helpers
|
|
40
|
+
class Generator
|
|
41
|
+
# Base class for avatar generation errors
|
|
42
|
+
class GenerationError < StandardError; end
|
|
43
|
+
|
|
44
|
+
# Raised when MiniMagick/ImageMagick fails to render an avatar.
|
|
45
|
+
class ImageMagickError < GenerationError; end
|
|
46
|
+
|
|
47
|
+
# Raised when filesystem operations (write/verify) fail.
|
|
48
|
+
class FileSystemError < GenerationError; end
|
|
49
|
+
|
|
50
|
+
# Raised when inputs or configuration are invalid.
|
|
51
|
+
class ConfigurationError < GenerationError; end
|
|
52
|
+
|
|
53
|
+
# Instantiate a new generator.
|
|
54
|
+
#
|
|
55
|
+
# Prefer {::call} unless you need a long-lived instance.
|
|
56
|
+
#
|
|
57
|
+
# @param username [String] The source name used to derive initials and color.
|
|
58
|
+
# @param size [Integer] Target size in pixels (> 0). Capped at {Avatar::FULLSIZE}.
|
|
59
|
+
# @param cache [Boolean] Reuse existing cached PNG when present. Defaults to `true`.
|
|
60
|
+
# @param logger [Logger, nil] Optional logger; defaults to a simple STDOUT logger.
|
|
61
|
+
# @raise [ConfigurationError] If parameters are invalid or required assets are missing.
|
|
62
|
+
def initialize(username, size, cache: true, logger: nil)
|
|
63
|
+
@username = username
|
|
64
|
+
@size = size
|
|
65
|
+
@cache = cache
|
|
66
|
+
@font = Avatar::FONT_FILE
|
|
67
|
+
@fill = Avatar::FILL_COLOR
|
|
68
|
+
@logger = logger || default_logger
|
|
69
|
+
|
|
70
|
+
validate_inputs!
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.call(username, size, cache: true, logger: nil)
|
|
74
|
+
new(username, size, cache: cache, logger: logger).execute!
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Same API as {.call}, but returns a Future so callers can decide
|
|
78
|
+
# when to block.
|
|
79
|
+
#
|
|
80
|
+
# @return [Concurrent::Future<String>] Future that resolves to the avatar path.
|
|
81
|
+
# @see .call
|
|
82
|
+
def self.async_call(username, size, cache: true, logger: nil)
|
|
83
|
+
Concurrent::Future.execute do
|
|
84
|
+
new(username, size, cache: cache, logger: logger).execute!
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Run the avatar generation pipeline: build identity, resolve cache path,
|
|
89
|
+
# and generate if needed.
|
|
90
|
+
#
|
|
91
|
+
# @return [String] Filesystem path to the generated or cached avatar PNG.
|
|
92
|
+
# @raise [GenerationError] If anything goes wrong during generation.
|
|
93
|
+
def execute!
|
|
94
|
+
with_error_handling do
|
|
95
|
+
logger.info "Starting avatar generation for '#{username}' at size #{size}px"
|
|
96
|
+
path = generate
|
|
97
|
+
logger.info "Avatar generation completed successfully: #{path}"
|
|
98
|
+
path
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
attr_reader :username, :size, :cache, :font, :fill, :logger
|
|
105
|
+
|
|
106
|
+
# Generate or reuse an avatar at the requested size.
|
|
107
|
+
#
|
|
108
|
+
# Builds an identity from `username`, computes the cache path,
|
|
109
|
+
# renders the PNG if not already cached, and returns the file path.
|
|
110
|
+
#
|
|
111
|
+
# @return [String] Filesystem path to the generated or cached avatar PNG.
|
|
112
|
+
def generate
|
|
113
|
+
identity = build_identity
|
|
114
|
+
target_size = normalize_size
|
|
115
|
+
target_path = Avatar::Cache.path(identity, target_size)
|
|
116
|
+
return use_cached(target_path) if cached?(target_path)
|
|
117
|
+
|
|
118
|
+
Concurrent::Future.execute { generate_avatar(identity, target_size, target_path) }.value!
|
|
119
|
+
|
|
120
|
+
target_path
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Build an avatar identity from the configured username.
|
|
124
|
+
#
|
|
125
|
+
# @return [NamePlate::Avatar::Identity] Derived initials and background color.
|
|
126
|
+
def build_identity
|
|
127
|
+
Avatar::Identity.from_username(username).tap do |identity|
|
|
128
|
+
logger.debug "Generated identity: #{identity.inspect}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Clamp the requested size to the maximum full size.
|
|
133
|
+
#
|
|
134
|
+
# @return [Integer] Target size in pixels (<= {Avatar::FULLSIZE}).
|
|
135
|
+
def normalize_size
|
|
136
|
+
[size, Avatar::FULLSIZE].min.tap do |s|
|
|
137
|
+
logger.debug "Target size: #{s}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Check if a cached avatar exists at the given path.
|
|
142
|
+
#
|
|
143
|
+
# @param [String] path The expected avatar file path.
|
|
144
|
+
# @return [Boolean] True if a cached file exists, false otherwise.
|
|
145
|
+
def cached?(path)
|
|
146
|
+
cache && File.exist?(path)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Use an existing cached avatar path.
|
|
150
|
+
#
|
|
151
|
+
# @param [String] path The cached avatar file path.
|
|
152
|
+
# @return [String] The same cached path that was provided.
|
|
153
|
+
def use_cached(path)
|
|
154
|
+
path
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Render the avatar image using MiniMagick/ImageMagick.
|
|
158
|
+
#
|
|
159
|
+
# @param identity [Identity] Letters and background color.
|
|
160
|
+
# @param size [Integer] Target size in pixels.
|
|
161
|
+
# @param filename [String, Pathname] Output file path.
|
|
162
|
+
# @raise [ImageMagickError] If MiniMagick raises during conversion.
|
|
163
|
+
def generate_avatar(identity, size, filename)
|
|
164
|
+
MiniMagick.convert do |c|
|
|
165
|
+
c.size "#{size}x#{size}"
|
|
166
|
+
c << "xc:#{to_rgb(identity.color)}"
|
|
167
|
+
c.pointsize NamePlate.pointsize.to_s
|
|
168
|
+
c.font font.to_s
|
|
169
|
+
c.weight NamePlate.weight.to_s
|
|
170
|
+
c.fill fill.to_s.gsub(/\s+/, "")
|
|
171
|
+
c.gravity "Center"
|
|
172
|
+
c.annotate NamePlate.annotate_position.to_s, identity.letters.to_s
|
|
173
|
+
c << filename.to_s
|
|
174
|
+
end
|
|
175
|
+
rescue => e
|
|
176
|
+
raise ImageMagickError, "MiniMagick failed to generate avatar: #{e.message}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Convert `[r, g, b]` array to `rgb(r,g,b)` string accepted by ImageMagick.
|
|
180
|
+
#
|
|
181
|
+
# @param color [Array<Integer>] RGB values in the 0..255 range.
|
|
182
|
+
# @return [String] `rgb(r,g,b)` formatted string.
|
|
183
|
+
# @raise [ConfigurationError] If the color is not a valid triplet.
|
|
184
|
+
def to_rgb(color)
|
|
185
|
+
"rgb(#{color.join(",")})"
|
|
186
|
+
rescue => e
|
|
187
|
+
raise ConfigurationError, "Invalid color format: #{color.inspect} - #{e.message}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Validate constructor inputs and required configuration.
|
|
191
|
+
#
|
|
192
|
+
# @return [void]
|
|
193
|
+
# @raise [ConfigurationError] When any validation fails.
|
|
194
|
+
def validate_inputs!
|
|
195
|
+
raise ConfigurationError, "Username cannot be empty" if username.to_s.strip.empty?
|
|
196
|
+
raise ConfigurationError, "Size must be positive integer" unless size.is_a?(Integer) && size.positive?
|
|
197
|
+
raise ConfigurationError, "Font file not found: #{font}" unless File.exist?(font.to_s)
|
|
198
|
+
raise ConfigurationError, "Fill color not configured" if fill.to_s.empty?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Runs a block, wrapping/logging any errors in GenerationError subclasses.
|
|
202
|
+
#
|
|
203
|
+
# @yield The block to execute safely.
|
|
204
|
+
# @return [Object] The block's return value if successful.
|
|
205
|
+
# @raise [GenerationError] A normalized error if anything fails.
|
|
206
|
+
def with_error_handling
|
|
207
|
+
yield
|
|
208
|
+
rescue GenerationError => e
|
|
209
|
+
logger.error "#{e.class}: #{e.message}"
|
|
210
|
+
raise
|
|
211
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::EIO => e
|
|
212
|
+
logger.error "File system error: #{e.class} - #{e.message}"
|
|
213
|
+
raise FileSystemError, e.message
|
|
214
|
+
rescue => e
|
|
215
|
+
logger.error "Unexpected error: #{e.class} - #{e.message}"
|
|
216
|
+
logger.debug e.backtrace.join("\n")
|
|
217
|
+
raise GenerationError, "Unexpected error: #{e.message}"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Build a default logger that writes to STDOUT.
|
|
221
|
+
#
|
|
222
|
+
# Log level defaults to `INFO`; set `ENV["NAMEPLATE_LOG_LEVEL"] = "DEBUG"`
|
|
223
|
+
# to enable verbose output during generation.
|
|
224
|
+
#
|
|
225
|
+
# @return [Logger]
|
|
226
|
+
def default_logger
|
|
227
|
+
require "logger"
|
|
228
|
+
Logger.new($stdout).tap do |log|
|
|
229
|
+
log.level = (ENV["NAMEPLATE_LOG_LEVEL"]&.upcase == "DEBUG") ? Logger::DEBUG : Logger::INFO
|
|
230
|
+
log.formatter = proc do |severity, datetime, _progname, msg|
|
|
231
|
+
"[#{datetime.strftime("%H:%M:%S")}] #{severity}: #{msg}\n"
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NamePlate
|
|
4
|
+
class Avatar
|
|
5
|
+
# Represents a derived avatar identity: letters and color.
|
|
6
|
+
class Identity
|
|
7
|
+
attr_reader :color, :letters
|
|
8
|
+
|
|
9
|
+
def initialize(color, letters)
|
|
10
|
+
@color = color
|
|
11
|
+
@letters = letters
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Build an identity from a username.
|
|
15
|
+
#
|
|
16
|
+
# @param [String] username The input name.
|
|
17
|
+
# @return [Identity] The derived avatar identity.
|
|
18
|
+
def self.from_username(username)
|
|
19
|
+
color = NamePlate::Colors.for(username)
|
|
20
|
+
letters = initials(username, count(username))
|
|
21
|
+
new(color, letters)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def initials(username, count)
|
|
28
|
+
username
|
|
29
|
+
.split(/\s+/)
|
|
30
|
+
.map { |word| word[0] }
|
|
31
|
+
.join
|
|
32
|
+
.upcase[0..count - 1]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def count(username)
|
|
36
|
+
(username.strip.split(/\s+/).size >= 2) ? 2 : 1
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "avatar/generator"
|
|
5
|
+
require_relative "avatar/cache"
|
|
6
|
+
require_relative "avatar/identity"
|
|
7
|
+
|
|
8
|
+
module NamePlate
|
|
9
|
+
# Avatar generation from usernames.
|
|
10
|
+
#
|
|
11
|
+
# Responsibilities are split into:
|
|
12
|
+
# - {Identity}: maps username → initials + color
|
|
13
|
+
# - {Cache}: builds consistent cache paths
|
|
14
|
+
# - {Generator}: orchestrates avatar creation and resizing
|
|
15
|
+
#
|
|
16
|
+
# Public API:
|
|
17
|
+
# NamePlate::Avatar.generate("John Doe", 128)
|
|
18
|
+
#
|
|
19
|
+
class Avatar
|
|
20
|
+
VERSION = 1 # bump on any change to avatar generation
|
|
21
|
+
FULLSIZE = 600
|
|
22
|
+
FILL_COLOR = "rgba(0, 0, 0, 0.65)" # black at 65% opacity
|
|
23
|
+
# Use __dir__ and fall back to the current working directory for Steep
|
|
24
|
+
# which can type __dir__ as String | nil.
|
|
25
|
+
FONT_FILE = File.expand_path("fonts/Roboto-Medium", __dir__ || Dir.pwd)
|
|
26
|
+
|
|
27
|
+
# Public API entry point
|
|
28
|
+
#
|
|
29
|
+
# @param username [String]
|
|
30
|
+
# @param size [Integer]
|
|
31
|
+
# @param opts [Hash] options, e.g. { cache: true }
|
|
32
|
+
# @return [String] path to avatar file
|
|
33
|
+
def self.generate(username, size, opts = {})
|
|
34
|
+
Generator.call(username, size, **opts)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module NamePlate
|
|
6
|
+
module Colors
|
|
7
|
+
class Palette
|
|
8
|
+
attr_reader :colors
|
|
9
|
+
|
|
10
|
+
# Initialize a new color palette.
|
|
11
|
+
#
|
|
12
|
+
# @param [Array<Array<Integer>>] colors The array of RGB triplets.
|
|
13
|
+
# If nil, uses the default {COLORS} constant defined in subclasses.
|
|
14
|
+
# @raise [NotImplementedError] If the subclass does not define COLORS constant.
|
|
15
|
+
# @raise [ArgumentError] If COLORS is not a valid array of RGB triplets
|
|
16
|
+
def initialize(colors = nil)
|
|
17
|
+
colors ||= self.class.const_get(:COLORS)
|
|
18
|
+
@colors = colors.freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.key
|
|
22
|
+
raise NotImplementedError, "#{self}.key must return a Symbol"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Select a color based on a username string.
|
|
26
|
+
#
|
|
27
|
+
# @param [String] username The username to base the color selection on.
|
|
28
|
+
# @return [Array<Integer>] RGB triplet
|
|
29
|
+
def pick(username)
|
|
30
|
+
index = hash_index(username)
|
|
31
|
+
colors[index % colors.length]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Generate a hash index from a username.
|
|
37
|
+
#
|
|
38
|
+
# @param [String] username The username to hash.
|
|
39
|
+
# @return [Integer] The hash index.
|
|
40
|
+
def hash_index(username)
|
|
41
|
+
Digest::MD5.hexdigest(username)[0...15].to_i(16)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NamePlate
|
|
4
|
+
module Colors
|
|
5
|
+
module Palettes
|
|
6
|
+
class Custom < Palette
|
|
7
|
+
def self.key = :custom
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
super(NamePlate.custom_palette || [])
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def pick(username)
|
|
14
|
+
raise "Custom palette not set" if colors.empty?
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NamePlate
|
|
4
|
+
module Colors
|
|
5
|
+
module Palettes
|
|
6
|
+
# Inspired by Dracula theme
|
|
7
|
+
class Dracula < Palette
|
|
8
|
+
COLORS = [
|
|
9
|
+
[40, 42, 54], # background
|
|
10
|
+
[68, 71, 90], # current line
|
|
11
|
+
[98, 114, 164], # comment
|
|
12
|
+
[139, 233, 253], # cyan
|
|
13
|
+
[80, 250, 123], # green
|
|
14
|
+
[255, 184, 108], # orange
|
|
15
|
+
[255, 121, 198], # pink
|
|
16
|
+
[189, 147, 249], # purple
|
|
17
|
+
[241, 250, 140], # yellow
|
|
18
|
+
[255, 85, 85] # red
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
def self.key = :dracula
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
super(COLORS)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|