logosoup 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/CHANGELOG.md +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +196 -0
- data/lib/logo_soup/core/background_detector.rb +84 -0
- data/lib/logo_soup/core/css.rb +28 -0
- data/lib/logo_soup/core/dimension_calculator.rb +44 -0
- data/lib/logo_soup/core/feature_measurer.rb +57 -0
- data/lib/logo_soup/core/image_loader.rb +93 -0
- data/lib/logo_soup/core/pixel_analyzer.rb +122 -0
- data/lib/logo_soup/core/svg_dimensions.rb +57 -0
- data/lib/logo_soup/core/visual_center_transform.rb +44 -0
- data/lib/logo_soup/style.rb +236 -0
- data/lib/logo_soup/version.rb +5 -0
- data/lib/logo_soup.rb +26 -0
- metadata +265 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4398d59376687e027112633787c08a5d3237ccd9faedf078c8dc59b484903fcd
|
|
4
|
+
data.tar.gz: 143a9f9a2f55465c6ed3c49cce265529208b2e5957ace137e8688f46afd2fa9e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 438698259b4c3baf2494e7d58f32e47a6acb1149879ce37403287306fd490e2dbb6a293cd88343b7fc8ea56a6dc33f9fe5163f6f4e0364f27a15934c64d4f7fd
|
|
7
|
+
data.tar.gz: 50dfd2923054f75a6d4e5b1edd95c531d3add224dfb772c61c11c05ad52baad346bfa706e43cf3082d095e3d4de26d843d2c0b263ea8b5bd3d5676c1b1fe0c8e
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Codeminer42
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# LogoSoup
|
|
2
|
+
|
|
3
|
+
[](CHANGELOG.md)
|
|
4
|
+
[](LICENSE.txt)
|
|
5
|
+
[](coverage/index.html)
|
|
6
|
+
|
|
7
|
+
Framework-agnostic Ruby gem for **normalizing logo rendering**.
|
|
8
|
+
|
|
9
|
+
Given an input logo (SVG or raster), LogoSoup returns an **inline CSS style string** that you can apply to an `<img>` (or equivalent) so different logos render with a consistent perceived size, with optional visual-center alignment.
|
|
10
|
+
|
|
11
|
+
This gem is inspired by the original Logo Soup project for React ([auroris/logo-soup](https://github.com/auroris/logo-soup)) developed by [Rostislav Melkumyan](https://www.sanity.io/blog/the-logo-soup-problem).
|
|
12
|
+
|
|
13
|
+
## Why
|
|
14
|
+
|
|
15
|
+
Logos often have different intrinsic sizes, padding, and visual weight. If you render them at the same width/height, they still *look* inconsistent.
|
|
16
|
+
|
|
17
|
+
LogoSoup aims to:
|
|
18
|
+
|
|
19
|
+
- Normalize sizing so logos look consistent at a given `base_size`.
|
|
20
|
+
- Optionally align by visual center (e.g., Y axis) for better baseline alignment.
|
|
21
|
+
- Stay framework-agnostic (no Rails dependencies).
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Add this line to your application's Gemfile:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
gem 'logosoup'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
bundle install
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or, with Bundler:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
bundle add logosoup
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Requirements
|
|
44
|
+
|
|
45
|
+
- Ruby: `>= 2.7`, `< 4.0`
|
|
46
|
+
- System dependency: **libvips** (required for raster analysis)
|
|
47
|
+
|
|
48
|
+
### Installing libvips
|
|
49
|
+
|
|
50
|
+
- macOS (Homebrew):
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
brew install vips
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- Ubuntu/Debian:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
sudo apt-get update && sudo apt-get install -y libvips
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
LogoSoup exposes a single entrypoint: `LogoSoup.style`.
|
|
65
|
+
|
|
66
|
+
### SVG (string)
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
style = LogoSoup.style(
|
|
70
|
+
|
|
71
|
+
svg: File.read('logo.svg'),
|
|
72
|
+
base_size: 48
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# => "width: 48px; height: 48px; object-fit: contain; display: block; transform: translate(0px, 0px);"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Raster image (file path)
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
style = LogoSoup.style(
|
|
82
|
+
image_path: 'logo.png',
|
|
83
|
+
base_size: 48
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Bytes (IO/String)
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
bytes = File.binread('logo.webp')
|
|
91
|
+
|
|
92
|
+
style = LogoSoup.style(
|
|
93
|
+
image_bytes: bytes,
|
|
94
|
+
content_type: 'image/webp',
|
|
95
|
+
base_size: 48
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
If `content_type` includes `svg` (e.g. `image/svg+xml`), `image_bytes:` is treated as SVG and handled by the SVG pipeline.
|
|
100
|
+
|
|
101
|
+
## API
|
|
102
|
+
|
|
103
|
+
### `LogoSoup.style`
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
LogoSoup.style(
|
|
107
|
+
svg: nil,
|
|
108
|
+
image_path: nil,
|
|
109
|
+
image_bytes: nil,
|
|
110
|
+
content_type: nil,
|
|
111
|
+
base_size:,
|
|
112
|
+
on_error: nil,
|
|
113
|
+
**options
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
#### Inputs (choose one)
|
|
118
|
+
|
|
119
|
+
- `svg:` String containing SVG XML
|
|
120
|
+
- `image_path:` filesystem path to an image (PNG/JPG/WebP/GIF/TIFF, etc.)
|
|
121
|
+
- `image_bytes:` String/IO of image bytes
|
|
122
|
+
|
|
123
|
+
#### Required
|
|
124
|
+
|
|
125
|
+
- `base_size:` Integer (pixels). Used as the normalization target and also as fallback width/height.
|
|
126
|
+
|
|
127
|
+
#### Error handling
|
|
128
|
+
|
|
129
|
+
- `on_error: nil` (default): return a fallback style (`width/height = base_size`, no transform)
|
|
130
|
+
- `on_error: :raise`: re-raise the original exception
|
|
131
|
+
|
|
132
|
+
#### Options (with defaults)
|
|
133
|
+
|
|
134
|
+
These map directly to `LogoSoup::Style::DEFAULTS`:
|
|
135
|
+
|
|
136
|
+
- `scale_factor:` `0.5`
|
|
137
|
+
- `density_aware:` `true`
|
|
138
|
+
- `density_factor:` `0.5`
|
|
139
|
+
- `contrast_threshold:` `10`
|
|
140
|
+
- `align_by:` `'visual-center-y'`
|
|
141
|
+
- `pixel_budget:` `2048`
|
|
142
|
+
|
|
143
|
+
Notes:
|
|
144
|
+
|
|
145
|
+
- Raster images are analyzed with libvips to estimate features (e.g. pixel density / content box / visual center offsets) that inform sizing and transforms.
|
|
146
|
+
- For SVG input, LogoSoup currently uses intrinsic SVG dimensions and skips raster feature measurement.
|
|
147
|
+
|
|
148
|
+
## Output
|
|
149
|
+
|
|
150
|
+
The return value is a single inline CSS string including (at least):
|
|
151
|
+
|
|
152
|
+
- `width: ...px;`
|
|
153
|
+
- `height: ...px;`
|
|
154
|
+
- `object-fit: contain;`
|
|
155
|
+
- `display: block;`
|
|
156
|
+
- `transform: ...` (only when alignment produces a non-nil transform)
|
|
157
|
+
|
|
158
|
+
This is designed to be applied directly to an `<img>` tag or any element that supports these properties.
|
|
159
|
+
|
|
160
|
+
## Testing
|
|
161
|
+
|
|
162
|
+
Run the test suite:
|
|
163
|
+
|
|
164
|
+
```sh
|
|
165
|
+
bundle exec rake spec
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Coverage
|
|
169
|
+
|
|
170
|
+
Generate a local coverage report:
|
|
171
|
+
|
|
172
|
+
```sh
|
|
173
|
+
bundle exec rake spec:coverage
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
This writes HTML reports to `coverage/index.html`.
|
|
177
|
+
|
|
178
|
+
The badge at the top of this README reflects the last recorded SimpleCov result in `coverage/.last_run.json` (current line coverage: **86.14%**; branch coverage in that file: **53.17%**).
|
|
179
|
+
|
|
180
|
+
## Development
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
bundle install
|
|
184
|
+
bundle exec rake spec
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Contributing
|
|
188
|
+
|
|
189
|
+
Bug reports and pull requests are welcome.
|
|
190
|
+
|
|
191
|
+
- Keep changes focused and add specs where it makes sense.
|
|
192
|
+
- If you change behavior, update `CHANGELOG.md`
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
Released under the MIT License. See `LICENSE.txt`.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LogoSoup
|
|
4
|
+
module Core
|
|
5
|
+
# Detects whether background should be treated as transparent and estimates
|
|
6
|
+
# background RGB from the image perimeter.
|
|
7
|
+
class BackgroundDetector
|
|
8
|
+
QUANTIZATION_SHIFT = 5
|
|
9
|
+
ALPHA_TRANSPARENCY_THRESHOLD = 128
|
|
10
|
+
TRANSPARENT_PERIMETER_RATIO_THRESHOLD = 0.1
|
|
11
|
+
|
|
12
|
+
# @param bytes [Array<Integer>] RGBA bytes
|
|
13
|
+
# @param width [Integer]
|
|
14
|
+
# @param height [Integer]
|
|
15
|
+
# @return [Array(Boolean, Integer, Integer, Integer)] [alpha_only, bg_r, bg_g, bg_b]
|
|
16
|
+
def self.call(bytes, width, height)
|
|
17
|
+
levels = 1 << (8 - QUANTIZATION_SHIFT)
|
|
18
|
+
bucket_count = levels * levels * levels
|
|
19
|
+
|
|
20
|
+
bucket_counts = Array.new(bucket_count, 0)
|
|
21
|
+
bucket_r = Array.new(bucket_count, 0)
|
|
22
|
+
bucket_g = Array.new(bucket_count, 0)
|
|
23
|
+
bucket_b = Array.new(bucket_count, 0)
|
|
24
|
+
|
|
25
|
+
opaque_count = 0
|
|
26
|
+
transparent_count = 0
|
|
27
|
+
|
|
28
|
+
sample = lambda do |x, y|
|
|
29
|
+
idx = ((y * width) + x) * 4
|
|
30
|
+
a = bytes[idx + 3]
|
|
31
|
+
return if a.nil?
|
|
32
|
+
|
|
33
|
+
if a < ALPHA_TRANSPARENCY_THRESHOLD
|
|
34
|
+
transparent_count += 1
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
opaque_count += 1
|
|
39
|
+
r = bytes[idx]
|
|
40
|
+
g = bytes[idx + 1]
|
|
41
|
+
b = bytes[idx + 2]
|
|
42
|
+
|
|
43
|
+
key = ((((r >> QUANTIZATION_SHIFT) * levels) + (g >> QUANTIZATION_SHIFT)) * levels) + (b >> QUANTIZATION_SHIFT)
|
|
44
|
+
bucket_counts[key] += 1
|
|
45
|
+
bucket_r[key] += r
|
|
46
|
+
bucket_g[key] += g
|
|
47
|
+
bucket_b[key] += b
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
width.times do |x|
|
|
51
|
+
sample.call(x, 0)
|
|
52
|
+
sample.call(x, height - 1) if height > 1
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
(1...(height - 1)).each do |y|
|
|
56
|
+
sample.call(0, y)
|
|
57
|
+
sample.call(width - 1, y) if width > 1
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
total_perimeter = opaque_count + transparent_count
|
|
61
|
+
transparent = total_perimeter.positive? && transparent_count > total_perimeter * TRANSPARENT_PERIMETER_RATIO_THRESHOLD
|
|
62
|
+
return [true, 0, 0, 0] if transparent
|
|
63
|
+
|
|
64
|
+
best_idx = 0
|
|
65
|
+
best_count = 0
|
|
66
|
+
bucket_counts.each_with_index do |count, i|
|
|
67
|
+
next unless count > best_count
|
|
68
|
+
|
|
69
|
+
best_count = count
|
|
70
|
+
best_idx = i
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if best_count.positive?
|
|
74
|
+
bg_r = (bucket_r[best_idx].to_f / best_count).round
|
|
75
|
+
bg_g = (bucket_g[best_idx].to_f / best_count).round
|
|
76
|
+
bg_b = (bucket_b[best_idx].to_f / best_count).round
|
|
77
|
+
[false, bg_r, bg_g, bg_b]
|
|
78
|
+
else
|
|
79
|
+
[false, 255, 255, 255]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LogoSoup
|
|
4
|
+
module Core
|
|
5
|
+
# CSS utilities.
|
|
6
|
+
module Css
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Formats a numeric value rounded to 1 decimal place.
|
|
10
|
+
#
|
|
11
|
+
# @param value [Numeric]
|
|
12
|
+
# @return [String]
|
|
13
|
+
def fmt_tenth_px(value)
|
|
14
|
+
rounded = (value.to_f * 10).round / 10.0
|
|
15
|
+
rounded = 0.0 if rounded.abs < 1e-9
|
|
16
|
+
rounded.to_i == rounded ? rounded.to_i.to_s : rounded.to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Builds an inline style string from a hash.
|
|
20
|
+
#
|
|
21
|
+
# @param styles [Hash{Symbol=>String,nil}]
|
|
22
|
+
# @return [String]
|
|
23
|
+
def style_string(styles)
|
|
24
|
+
styles.compact.map { |key, val| "#{key.to_s.tr('_', '-')}: #{val};" }.join(" ")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LogoSoup
|
|
4
|
+
module Core
|
|
5
|
+
# Computes normalized render dimensions from intrinsic dimensions.
|
|
6
|
+
class DimensionCalculator
|
|
7
|
+
REFERENCE_DENSITY = 0.35
|
|
8
|
+
MIN_DENSITY_SCALE = 0.5
|
|
9
|
+
MAX_DENSITY_SCALE = 2.0
|
|
10
|
+
|
|
11
|
+
# @param width [Numeric]
|
|
12
|
+
# @param height [Numeric]
|
|
13
|
+
# @param base_size [Numeric]
|
|
14
|
+
# @param scale_factor [Numeric]
|
|
15
|
+
# @param density_factor [Numeric]
|
|
16
|
+
# @param pixel_density [Float, nil]
|
|
17
|
+
# @return [Array(Integer, Integer)]
|
|
18
|
+
def self.call(width:, height:, base_size:, scale_factor:, density_factor: 0.0, pixel_density: nil)
|
|
19
|
+
w = width.to_f
|
|
20
|
+
h = height.to_f
|
|
21
|
+
base = base_size.to_f
|
|
22
|
+
|
|
23
|
+
return [base.round, base.round] if w <= 0 || h <= 0
|
|
24
|
+
|
|
25
|
+
aspect_ratio = w / h
|
|
26
|
+
normalized_width = (aspect_ratio**scale_factor.to_f) * base
|
|
27
|
+
normalized_height = normalized_width / aspect_ratio
|
|
28
|
+
|
|
29
|
+
df = density_factor.to_f
|
|
30
|
+
if df.positive? && pixel_density
|
|
31
|
+
density_ratio = pixel_density.to_f / REFERENCE_DENSITY
|
|
32
|
+
if density_ratio.positive?
|
|
33
|
+
density_scale = (1.0 / density_ratio)**(df * 0.5)
|
|
34
|
+
clamped_scale = [[density_scale, MAX_DENSITY_SCALE].min, MIN_DENSITY_SCALE].max
|
|
35
|
+
normalized_width *= clamped_scale
|
|
36
|
+
normalized_height *= clamped_scale
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
[normalized_width.round, normalized_height.round]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "image_loader"
|
|
4
|
+
require_relative "background_detector"
|
|
5
|
+
require_relative "pixel_analyzer"
|
|
6
|
+
|
|
7
|
+
module LogoSoup
|
|
8
|
+
module Core
|
|
9
|
+
# Measures raster features (density, content box, visual center offsets).
|
|
10
|
+
class FeatureMeasurer
|
|
11
|
+
DEFAULT_PIXEL_BUDGET = 2_048
|
|
12
|
+
|
|
13
|
+
# @param path [String]
|
|
14
|
+
# @param contrast_threshold [Integer]
|
|
15
|
+
# @param pixel_budget [Integer]
|
|
16
|
+
# @param on_error [:raise, nil]
|
|
17
|
+
# @return [Hash]
|
|
18
|
+
def self.call(path:, contrast_threshold:, pixel_budget: DEFAULT_PIXEL_BUDGET, on_error: nil)
|
|
19
|
+
payload = ImageLoader.call(path: path, pixel_budget: pixel_budget, on_error: on_error)
|
|
20
|
+
bytes = payload.fetch(:bytes)
|
|
21
|
+
sample_width = payload.fetch(:sample_width)
|
|
22
|
+
sample_height = payload.fetch(:sample_height)
|
|
23
|
+
original_width = payload.fetch(:original_width)
|
|
24
|
+
original_height = payload.fetch(:original_height)
|
|
25
|
+
|
|
26
|
+
alpha_only, bg_r, bg_g, bg_b = BackgroundDetector.call(bytes, sample_width, sample_height)
|
|
27
|
+
|
|
28
|
+
measured = PixelAnalyzer.call(
|
|
29
|
+
bytes: bytes,
|
|
30
|
+
sample_width: sample_width,
|
|
31
|
+
sample_height: sample_height,
|
|
32
|
+
original_width: original_width,
|
|
33
|
+
original_height: original_height,
|
|
34
|
+
contrast_threshold: contrast_threshold,
|
|
35
|
+
alpha_only: alpha_only,
|
|
36
|
+
bg_r: bg_r,
|
|
37
|
+
bg_g: bg_g,
|
|
38
|
+
bg_b: bg_b
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
measured || default_features(original_width, original_height)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.default_features(w, h)
|
|
45
|
+
{
|
|
46
|
+
pixel_density: 0.5,
|
|
47
|
+
content_box_width: w,
|
|
48
|
+
content_box_height: h,
|
|
49
|
+
visual_center_offset_x: 0.0,
|
|
50
|
+
visual_center_offset_y: 0.0
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private_class_method :default_features
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "vips"
|
|
4
|
+
|
|
5
|
+
module LogoSoup
|
|
6
|
+
module Core
|
|
7
|
+
# Loads an image from disk and returns sampled RGBA bytes.
|
|
8
|
+
class ImageLoader
|
|
9
|
+
RGBA_CHANNELS = 4
|
|
10
|
+
|
|
11
|
+
# @param path [String]
|
|
12
|
+
# @param pixel_budget [Integer]
|
|
13
|
+
# @param on_error [:raise, nil]
|
|
14
|
+
# @return [Hash]
|
|
15
|
+
def self.call(path:, pixel_budget:, on_error: nil)
|
|
16
|
+
image = Vips::Image.new_from_file(path, access: :sequential)
|
|
17
|
+
original_width = image.width
|
|
18
|
+
original_height = image.height
|
|
19
|
+
|
|
20
|
+
sample_width, sample_height, image_small = downsample(image, pixel_budget: pixel_budget)
|
|
21
|
+
rgba = ensure_rgba_uchar(image_small, on_error: on_error)
|
|
22
|
+
|
|
23
|
+
bytes = rgba.write_to_memory.bytes
|
|
24
|
+
raise "Empty image bytes" if bytes.empty?
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
bytes: bytes,
|
|
28
|
+
original_width: original_width,
|
|
29
|
+
original_height: original_height,
|
|
30
|
+
sample_width: sample_width,
|
|
31
|
+
sample_height: sample_height
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.downsample(image, pixel_budget:)
|
|
36
|
+
w = image.width
|
|
37
|
+
h = image.height
|
|
38
|
+
return [1, 1, image] if w <= 0 || h <= 0
|
|
39
|
+
|
|
40
|
+
total_pixels = w * h
|
|
41
|
+
ratio = total_pixels > pixel_budget ? Math.sqrt(pixel_budget.to_f / total_pixels) : 1.0
|
|
42
|
+
ratio = 1.0 if ratio.nan? || ratio.infinite? || ratio <= 0
|
|
43
|
+
|
|
44
|
+
sw = [1, (w * ratio).round].max
|
|
45
|
+
sh = [1, (h * ratio).round].max
|
|
46
|
+
|
|
47
|
+
small = ratio == 1.0 ? image : image.resize(ratio)
|
|
48
|
+
if small.width != sw || small.height != sh
|
|
49
|
+
hscale = sw.to_f / small.width
|
|
50
|
+
vscale = sh.to_f / small.height
|
|
51
|
+
small = small.resize(hscale, vscale: vscale)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
[sw, sh, small]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.ensure_rgba_uchar(image, on_error: nil)
|
|
58
|
+
img = begin
|
|
59
|
+
image.colourspace("srgb")
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
raise e if on_error == :raise
|
|
62
|
+
raise unless vips_error?(e)
|
|
63
|
+
image
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
img = img.cast("uchar")
|
|
67
|
+
|
|
68
|
+
if img.bands > RGBA_CHANNELS
|
|
69
|
+
img = img.extract_band(0, n: RGBA_CHANNELS)
|
|
70
|
+
elsif img.bands == 3
|
|
71
|
+
img = img.bandjoin(255)
|
|
72
|
+
elsif img.bands == 2
|
|
73
|
+
gray = img.extract_band(0)
|
|
74
|
+
alpha = img.extract_band(1)
|
|
75
|
+
rgb = gray.bandjoin(gray).bandjoin(gray)
|
|
76
|
+
img = rgb.bandjoin(alpha)
|
|
77
|
+
elsif img.bands == 1
|
|
78
|
+
gray = img
|
|
79
|
+
rgb = gray.bandjoin(gray).bandjoin(gray)
|
|
80
|
+
img = rgb.bandjoin(255)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
img
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.vips_error?(error)
|
|
87
|
+
error.class.name.start_with?("Vips::")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private_class_method :downsample, :ensure_rgba_uchar, :vips_error?
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LogoSoup
|
|
4
|
+
module Core
|
|
5
|
+
# Measures visual features from sampled RGBA pixels.
|
|
6
|
+
class PixelAnalyzer
|
|
7
|
+
# @return [Hash, nil]
|
|
8
|
+
def self.call(
|
|
9
|
+
bytes:,
|
|
10
|
+
sample_width:,
|
|
11
|
+
sample_height:,
|
|
12
|
+
original_width:,
|
|
13
|
+
original_height:,
|
|
14
|
+
contrast_threshold:,
|
|
15
|
+
alpha_only:,
|
|
16
|
+
bg_r:,
|
|
17
|
+
bg_g:,
|
|
18
|
+
bg_b:
|
|
19
|
+
)
|
|
20
|
+
sw = sample_width
|
|
21
|
+
sh = sample_height
|
|
22
|
+
w = original_width
|
|
23
|
+
h = original_height
|
|
24
|
+
|
|
25
|
+
contrast_distance_sq = contrast_threshold.to_f * contrast_threshold.to_f * 3
|
|
26
|
+
min_x = sw
|
|
27
|
+
min_y = sh
|
|
28
|
+
max_x = 0
|
|
29
|
+
max_y = 0
|
|
30
|
+
|
|
31
|
+
total_weight = 0.0
|
|
32
|
+
weighted_x = 0.0
|
|
33
|
+
weighted_y = 0.0
|
|
34
|
+
|
|
35
|
+
filled_pixels = 0
|
|
36
|
+
total_weighted_opacity = 0.0
|
|
37
|
+
|
|
38
|
+
pixel_count = sw * sh
|
|
39
|
+
pixel_count.times do |i|
|
|
40
|
+
base = i * 4
|
|
41
|
+
r = bytes[base]
|
|
42
|
+
g = bytes[base + 1]
|
|
43
|
+
b = bytes[base + 2]
|
|
44
|
+
a = bytes[base + 3]
|
|
45
|
+
next if a.nil? || a <= contrast_threshold
|
|
46
|
+
|
|
47
|
+
if alpha_only
|
|
48
|
+
weight = a * a
|
|
49
|
+
opacity = a
|
|
50
|
+
else
|
|
51
|
+
dr = r - bg_r
|
|
52
|
+
dg = g - bg_g
|
|
53
|
+
db = b - bg_b
|
|
54
|
+
dist_sq = (dr * dr) + (dg * dg) + (db * db)
|
|
55
|
+
next if dist_sq < contrast_distance_sq
|
|
56
|
+
|
|
57
|
+
weight = dist_sq * a
|
|
58
|
+
opacity = [a, Math.sqrt(dist_sq)].min
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
x = i % sw
|
|
62
|
+
y = (i - x) / sw
|
|
63
|
+
|
|
64
|
+
min_x = x if x < min_x
|
|
65
|
+
max_x = x if x > max_x
|
|
66
|
+
min_y = y if y < min_y
|
|
67
|
+
max_y = y if y > max_y
|
|
68
|
+
|
|
69
|
+
total_weight += weight
|
|
70
|
+
weighted_x += (x + 0.5) * weight
|
|
71
|
+
weighted_y += (y + 0.5) * weight
|
|
72
|
+
|
|
73
|
+
filled_pixels += 1
|
|
74
|
+
total_weighted_opacity += opacity
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
return nil if min_x > max_x || min_y > max_y
|
|
78
|
+
|
|
79
|
+
scan_area = (max_x - min_x + 1) * (max_y - min_y + 1)
|
|
80
|
+
return nil if scan_area <= 0
|
|
81
|
+
|
|
82
|
+
coverage_ratio = filled_pixels.to_f / scan_area
|
|
83
|
+
average_opacity = filled_pixels.positive? ? (total_weighted_opacity / 255.0 / filled_pixels) : 0.0
|
|
84
|
+
pixel_density = coverage_ratio * average_opacity
|
|
85
|
+
|
|
86
|
+
scale_x = w.to_f / sw
|
|
87
|
+
scale_y = h.to_f / sh
|
|
88
|
+
|
|
89
|
+
cb_x = (min_x * scale_x).floor
|
|
90
|
+
cb_y = (min_y * scale_y).floor
|
|
91
|
+
cb_right = [[((max_x + 1) * scale_x).ceil.to_i, w].min, 0].max
|
|
92
|
+
cb_bottom = [[((max_y + 1) * scale_y).ceil.to_i, h].min, 0].max
|
|
93
|
+
content_box_width = [[cb_right - cb_x, 1].max, w].min
|
|
94
|
+
content_box_height = [[cb_bottom - cb_y, 1].max, h].min
|
|
95
|
+
|
|
96
|
+
if total_weight <= 0
|
|
97
|
+
offset_x = 0.0
|
|
98
|
+
offset_y = 0.0
|
|
99
|
+
else
|
|
100
|
+
global_center_x = (weighted_x / total_weight) * scale_x
|
|
101
|
+
global_center_y = (weighted_y / total_weight) * scale_y
|
|
102
|
+
local_center_x = global_center_x - cb_x
|
|
103
|
+
local_center_y = global_center_y - cb_y
|
|
104
|
+
|
|
105
|
+
geometric_center_x = content_box_width.to_f / 2
|
|
106
|
+
geometric_center_y = content_box_height.to_f / 2
|
|
107
|
+
|
|
108
|
+
offset_x = local_center_x - geometric_center_x
|
|
109
|
+
offset_y = local_center_y - geometric_center_y
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
pixel_density: pixel_density,
|
|
114
|
+
content_box_width: content_box_width,
|
|
115
|
+
content_box_height: content_box_height,
|
|
116
|
+
visual_center_offset_x: offset_x,
|
|
117
|
+
visual_center_offset_y: offset_y
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module LogoSoup
|
|
6
|
+
module Core
|
|
7
|
+
# Extracts width/height from SVG XML.
|
|
8
|
+
class SvgDimensions
|
|
9
|
+
# @param svg_string [String]
|
|
10
|
+
# @param on_error [:raise, nil]
|
|
11
|
+
# @return [Array(Float, Float), nil]
|
|
12
|
+
def self.call(svg_string, on_error: nil)
|
|
13
|
+
raw = svg_string.to_s
|
|
14
|
+
return nil if raw.empty?
|
|
15
|
+
|
|
16
|
+
doc = Nokogiri::XML(raw) { |cfg| cfg.nonet }
|
|
17
|
+
svg = doc.at_xpath("//*[local-name()='svg']")
|
|
18
|
+
return nil unless svg
|
|
19
|
+
|
|
20
|
+
view_box = svg["viewBox"] || svg["viewbox"]
|
|
21
|
+
if view_box
|
|
22
|
+
parts = view_box.split(/[\s,]+/).filter_map do |p|
|
|
23
|
+
Float(p)
|
|
24
|
+
rescue ArgumentError, TypeError, RangeError => e
|
|
25
|
+
handle_error(e, on_error: on_error)
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
return [parts[2], parts[3]] if parts.length == 4
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
w = numeric_dimension(svg["width"], on_error: on_error)
|
|
32
|
+
h = numeric_dimension(svg["height"], on_error: on_error)
|
|
33
|
+
return nil unless w && h
|
|
34
|
+
|
|
35
|
+
[w, h]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.numeric_dimension(value, on_error: nil)
|
|
39
|
+
return nil if value.nil?
|
|
40
|
+
|
|
41
|
+
num = value.to_s.strip[/[-+]?\d*\.?\d+/, 0]
|
|
42
|
+
return nil if num.blank?
|
|
43
|
+
|
|
44
|
+
Float(num)
|
|
45
|
+
rescue ArgumentError, RangeError => e
|
|
46
|
+
handle_error(e, on_error: on_error)
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.handle_error(error, on_error:)
|
|
51
|
+
raise error if on_error == :raise
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private_class_method :numeric_dimension, :handle_error
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "css"
|
|
4
|
+
|
|
5
|
+
module LogoSoup
|
|
6
|
+
module Core
|
|
7
|
+
# Computes a CSS translate() transform to align by visual center.
|
|
8
|
+
class VisualCenterTransform
|
|
9
|
+
# @param features [Hash]
|
|
10
|
+
# @param normalized_width [Numeric]
|
|
11
|
+
# @param normalized_height [Numeric]
|
|
12
|
+
# @param align_by [String, Symbol]
|
|
13
|
+
# @param intrinsic_width [Numeric]
|
|
14
|
+
# @param intrinsic_height [Numeric]
|
|
15
|
+
# @return [String, nil]
|
|
16
|
+
def self.call(features:, normalized_width:, normalized_height:, align_by:, intrinsic_width:, intrinsic_height:)
|
|
17
|
+
mode = align_by.to_s.strip
|
|
18
|
+
return nil if mode.empty? || mode == "bounds"
|
|
19
|
+
|
|
20
|
+
offset_x = features[:visual_center_offset_x]
|
|
21
|
+
offset_y = features[:visual_center_offset_y]
|
|
22
|
+
return nil unless offset_x.is_a?(Numeric) && offset_y.is_a?(Numeric)
|
|
23
|
+
|
|
24
|
+
content_w = features[:content_box_width].to_f
|
|
25
|
+
content_h = features[:content_box_height].to_f
|
|
26
|
+
content_w = intrinsic_width.to_f if content_w <= 0
|
|
27
|
+
content_h = intrinsic_height.to_f if content_h <= 0
|
|
28
|
+
return nil if content_w <= 0 || content_h <= 0
|
|
29
|
+
|
|
30
|
+
scale_x = normalized_width.to_f / content_w
|
|
31
|
+
scale_y = normalized_height.to_f / content_h
|
|
32
|
+
|
|
33
|
+
dx = %w[visual-center visual-center-x].include?(mode) ? (-offset_x.to_f * scale_x) : 0.0
|
|
34
|
+
dy = %w[visual-center visual-center-y].include?(mode) ? (-offset_y.to_f * scale_y) : 0.0
|
|
35
|
+
|
|
36
|
+
dx_fmt = Css.fmt_tenth_px(dx)
|
|
37
|
+
dy_fmt = Css.fmt_tenth_px(dy)
|
|
38
|
+
return nil if dx_fmt == "0" && dy_fmt == "0"
|
|
39
|
+
|
|
40
|
+
"translate(#{dx_fmt}px, #{dy_fmt}px)"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "vips"
|
|
5
|
+
|
|
6
|
+
module LogoSoup
|
|
7
|
+
# Composes the core logic into a single style string.
|
|
8
|
+
class Style
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
scale_factor: 0.5,
|
|
11
|
+
density_aware: true,
|
|
12
|
+
density_factor: 0.5,
|
|
13
|
+
contrast_threshold: 10,
|
|
14
|
+
align_by: "visual-center-y",
|
|
15
|
+
pixel_budget: 2_048
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# @param on_error [:raise, nil] error handling strategy
|
|
19
|
+
# - nil (default): return fallback style
|
|
20
|
+
# - :raise: re-raise the original exception
|
|
21
|
+
# @return [String] inline CSS style
|
|
22
|
+
def self.call(base_size:, svg: nil, image_path: nil, image_bytes: nil, content_type: nil, on_error: nil, **options)
|
|
23
|
+
opts = DEFAULTS.merge(options).merge(base_size: base_size)
|
|
24
|
+
|
|
25
|
+
if svg
|
|
26
|
+
handle_svg(svg, opts: opts, on_error: on_error)
|
|
27
|
+
elsif image_path
|
|
28
|
+
handle_image_path(image_path, opts: opts, on_error: on_error)
|
|
29
|
+
elsif image_bytes
|
|
30
|
+
handle_image_bytes(image_bytes, content_type: content_type, opts: opts, on_error: on_error)
|
|
31
|
+
else
|
|
32
|
+
fallback_style(opts)
|
|
33
|
+
end
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
handle_error(e, opts: opts, on_error: on_error)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.handle_svg(svg_string, opts:, on_error:)
|
|
39
|
+
intrinsic_w, intrinsic_h = Core::SvgDimensions.call(svg_string, on_error: on_error) || [0.0, 0.0]
|
|
40
|
+
|
|
41
|
+
features =
|
|
42
|
+
if wants_visual_center?(opts.fetch(:align_by, nil))
|
|
43
|
+
measure_svg_features(svg_string, intrinsic_width: intrinsic_w, intrinsic_height: intrinsic_h, opts: opts, on_error: on_error)
|
|
44
|
+
else
|
|
45
|
+
empty_features
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
build_style(
|
|
49
|
+
intrinsic_width: intrinsic_w,
|
|
50
|
+
intrinsic_height: intrinsic_h,
|
|
51
|
+
features: features,
|
|
52
|
+
**opts
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.handle_image_path(image_path, opts:, on_error:)
|
|
57
|
+
# Raster analysis is required; libvips must be installed.
|
|
58
|
+
image = Vips::Image.new_from_file(image_path, access: :sequential)
|
|
59
|
+
intrinsic_w = image.width
|
|
60
|
+
intrinsic_h = image.height
|
|
61
|
+
|
|
62
|
+
features = Core::FeatureMeasurer.call(
|
|
63
|
+
path: image_path,
|
|
64
|
+
contrast_threshold: opts.fetch(:contrast_threshold).to_i,
|
|
65
|
+
pixel_budget: opts.fetch(:pixel_budget).to_i,
|
|
66
|
+
on_error: on_error
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
build_style(
|
|
70
|
+
intrinsic_width: intrinsic_w,
|
|
71
|
+
intrinsic_height: intrinsic_h,
|
|
72
|
+
features: features,
|
|
73
|
+
**opts
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.handle_image_bytes(image_bytes, content_type:, opts:, on_error:)
|
|
78
|
+
bytes = image_bytes.respond_to?(:read) ? image_bytes.read : image_bytes
|
|
79
|
+
bytes = bytes.to_s
|
|
80
|
+
|
|
81
|
+
if content_type.to_s.include?("svg")
|
|
82
|
+
svg_string = bytes.dup.force_encoding("UTF-8")
|
|
83
|
+
return handle_svg(svg_string, opts: opts, on_error: on_error)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
file = nil
|
|
87
|
+
ext = file_extension_for(content_type)
|
|
88
|
+
file = Tempfile.new(["logo_soup", ext])
|
|
89
|
+
file.binmode
|
|
90
|
+
file.write(bytes)
|
|
91
|
+
file.flush
|
|
92
|
+
file.close
|
|
93
|
+
|
|
94
|
+
handle_image_path(file.path, opts: opts, on_error: on_error)
|
|
95
|
+
ensure
|
|
96
|
+
file.unlink if file
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.handle_error(error, opts:, on_error:)
|
|
100
|
+
case on_error
|
|
101
|
+
when :raise
|
|
102
|
+
raise error
|
|
103
|
+
else
|
|
104
|
+
fallback_style(opts)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.wants_visual_center?(align_by)
|
|
109
|
+
mode = align_by.to_s.strip
|
|
110
|
+
%w[visual-center visual-center-x visual-center-y].include?(mode)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.measure_svg_features(svg_string, intrinsic_width:, intrinsic_height:, opts:, on_error:)
|
|
114
|
+
return empty_features if intrinsic_width.to_f <= 0 || intrinsic_height.to_f <= 0
|
|
115
|
+
|
|
116
|
+
file = Tempfile.new(["logo_soup", ".svg"])
|
|
117
|
+
file.binmode
|
|
118
|
+
file.write(svg_string.to_s)
|
|
119
|
+
file.flush
|
|
120
|
+
file.close
|
|
121
|
+
|
|
122
|
+
rendered = Vips::Image.new_from_file(file.path, access: :sequential)
|
|
123
|
+
rendered_w = rendered.width.to_f
|
|
124
|
+
rendered_h = rendered.height.to_f
|
|
125
|
+
return empty_features if rendered_w <= 0 || rendered_h <= 0
|
|
126
|
+
|
|
127
|
+
measured = Core::FeatureMeasurer.call(
|
|
128
|
+
path: file.path,
|
|
129
|
+
contrast_threshold: opts.fetch(:contrast_threshold).to_i,
|
|
130
|
+
pixel_budget: opts.fetch(:pixel_budget).to_i,
|
|
131
|
+
on_error: on_error
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
scale_x = intrinsic_width.to_f / rendered_w
|
|
135
|
+
scale_y = intrinsic_height.to_f / rendered_h
|
|
136
|
+
|
|
137
|
+
scaled = measured.dup
|
|
138
|
+
scaled[:content_box_width] = scaled[:content_box_width].to_f * scale_x if scaled[:content_box_width].is_a?(Numeric)
|
|
139
|
+
scaled[:content_box_height] = scaled[:content_box_height].to_f * scale_y if scaled[:content_box_height].is_a?(Numeric)
|
|
140
|
+
scaled[:visual_center_offset_x] = scaled[:visual_center_offset_x].to_f * scale_x if scaled[:visual_center_offset_x].is_a?(Numeric)
|
|
141
|
+
scaled[:visual_center_offset_y] = scaled[:visual_center_offset_y].to_f * scale_y if scaled[:visual_center_offset_y].is_a?(Numeric)
|
|
142
|
+
scaled
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
raise e if on_error == :raise
|
|
145
|
+
|
|
146
|
+
empty_features
|
|
147
|
+
ensure
|
|
148
|
+
file.unlink if file
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def self.file_extension_for(content_type)
|
|
152
|
+
case content_type.to_s
|
|
153
|
+
when "image/png" then ".png"
|
|
154
|
+
when "image/jpeg", "image/jpg" then ".jpg"
|
|
155
|
+
when "image/webp" then ".webp"
|
|
156
|
+
when "image/gif" then ".gif"
|
|
157
|
+
when "image/tiff" then ".tif"
|
|
158
|
+
else
|
|
159
|
+
".img"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def self.build_style(
|
|
164
|
+
intrinsic_width:,
|
|
165
|
+
intrinsic_height:,
|
|
166
|
+
features:,
|
|
167
|
+
base_size:,
|
|
168
|
+
scale_factor:,
|
|
169
|
+
density_aware:,
|
|
170
|
+
density_factor:,
|
|
171
|
+
align_by:,
|
|
172
|
+
**_unused
|
|
173
|
+
)
|
|
174
|
+
pixel_density = density_aware ? features[:pixel_density] : nil
|
|
175
|
+
effective_density_factor = density_aware ? density_factor.to_f : 0.0
|
|
176
|
+
|
|
177
|
+
normalized_w, normalized_h = Core::DimensionCalculator.call(
|
|
178
|
+
width: intrinsic_width,
|
|
179
|
+
height: intrinsic_height,
|
|
180
|
+
base_size: base_size,
|
|
181
|
+
scale_factor: scale_factor,
|
|
182
|
+
density_factor: effective_density_factor,
|
|
183
|
+
pixel_density: pixel_density
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
transform = Core::VisualCenterTransform.call(
|
|
187
|
+
features: features,
|
|
188
|
+
normalized_width: normalized_w,
|
|
189
|
+
normalized_height: normalized_h,
|
|
190
|
+
align_by: align_by,
|
|
191
|
+
intrinsic_width: intrinsic_width,
|
|
192
|
+
intrinsic_height: intrinsic_height
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
Core::Css.style_string(
|
|
196
|
+
width: "#{normalized_w}px",
|
|
197
|
+
height: "#{normalized_h}px",
|
|
198
|
+
object_fit: "contain",
|
|
199
|
+
display: "block",
|
|
200
|
+
transform: transform
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def self.fallback_style(opts)
|
|
205
|
+
base = opts.fetch(:base_size).to_i
|
|
206
|
+
Core::Css.style_string(
|
|
207
|
+
width: "#{base}px",
|
|
208
|
+
height: "#{base}px",
|
|
209
|
+
object_fit: "contain",
|
|
210
|
+
display: "block",
|
|
211
|
+
transform: nil
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def self.empty_features
|
|
216
|
+
{
|
|
217
|
+
pixel_density: nil,
|
|
218
|
+
content_box_width: nil,
|
|
219
|
+
content_box_height: nil,
|
|
220
|
+
visual_center_offset_x: nil,
|
|
221
|
+
visual_center_offset_y: nil
|
|
222
|
+
}
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private_class_method :build_style,
|
|
226
|
+
:fallback_style,
|
|
227
|
+
:empty_features,
|
|
228
|
+
:file_extension_for,
|
|
229
|
+
:handle_error,
|
|
230
|
+
:handle_svg,
|
|
231
|
+
:handle_image_path,
|
|
232
|
+
:handle_image_bytes,
|
|
233
|
+
:measure_svg_features,
|
|
234
|
+
:wants_visual_center?
|
|
235
|
+
end
|
|
236
|
+
end
|
data/lib/logo_soup.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "logo_soup/version"
|
|
4
|
+
|
|
5
|
+
require_relative "logo_soup/core/css"
|
|
6
|
+
require_relative "logo_soup/core/svg_dimensions"
|
|
7
|
+
require_relative "logo_soup/core/dimension_calculator"
|
|
8
|
+
require_relative "logo_soup/core/visual_center_transform"
|
|
9
|
+
|
|
10
|
+
require_relative "logo_soup/core/image_loader"
|
|
11
|
+
require_relative "logo_soup/core/background_detector"
|
|
12
|
+
require_relative "logo_soup/core/pixel_analyzer"
|
|
13
|
+
require_relative "logo_soup/core/feature_measurer"
|
|
14
|
+
|
|
15
|
+
require_relative "logo_soup/style"
|
|
16
|
+
|
|
17
|
+
module LogoSoup
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
|
|
20
|
+
# Public API.
|
|
21
|
+
#
|
|
22
|
+
# @return [String] inline CSS style string
|
|
23
|
+
def self.style(**kwargs)
|
|
24
|
+
Style.call(**kwargs)
|
|
25
|
+
end
|
|
26
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: logosoup
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Alexandre Camillo
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: nokogiri
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.15'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '1.19'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '1.15'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '1.19'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: ruby-vips
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '2.2'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '3'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '2.2'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '3'
|
|
52
|
+
- !ruby/object:Gem::Dependency
|
|
53
|
+
name: rake
|
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - "~>"
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '13.0'
|
|
59
|
+
type: :development
|
|
60
|
+
prerelease: false
|
|
61
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - "~>"
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '13.0'
|
|
66
|
+
- !ruby/object:Gem::Dependency
|
|
67
|
+
name: rspec
|
|
68
|
+
requirement: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - "~>"
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '3.12'
|
|
73
|
+
type: :development
|
|
74
|
+
prerelease: false
|
|
75
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - "~>"
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '3.12'
|
|
80
|
+
- !ruby/object:Gem::Dependency
|
|
81
|
+
name: simplecov
|
|
82
|
+
requirement: !ruby/object:Gem::Requirement
|
|
83
|
+
requirements:
|
|
84
|
+
- - "~>"
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: '0.22'
|
|
87
|
+
type: :development
|
|
88
|
+
prerelease: false
|
|
89
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - "~>"
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: '0.22'
|
|
94
|
+
- !ruby/object:Gem::Dependency
|
|
95
|
+
name: simplecov-lcov
|
|
96
|
+
requirement: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - "~>"
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: '0.8'
|
|
101
|
+
type: :development
|
|
102
|
+
prerelease: false
|
|
103
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
104
|
+
requirements:
|
|
105
|
+
- - "~>"
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: '0.8'
|
|
108
|
+
- !ruby/object:Gem::Dependency
|
|
109
|
+
name: simplecov-console
|
|
110
|
+
requirement: !ruby/object:Gem::Requirement
|
|
111
|
+
requirements:
|
|
112
|
+
- - "~>"
|
|
113
|
+
- !ruby/object:Gem::Version
|
|
114
|
+
version: '0.9'
|
|
115
|
+
type: :development
|
|
116
|
+
prerelease: false
|
|
117
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
118
|
+
requirements:
|
|
119
|
+
- - "~>"
|
|
120
|
+
- !ruby/object:Gem::Version
|
|
121
|
+
version: '0.9'
|
|
122
|
+
- !ruby/object:Gem::Dependency
|
|
123
|
+
name: minitest
|
|
124
|
+
requirement: !ruby/object:Gem::Requirement
|
|
125
|
+
requirements:
|
|
126
|
+
- - ">="
|
|
127
|
+
- !ruby/object:Gem::Version
|
|
128
|
+
version: '5'
|
|
129
|
+
- - "<"
|
|
130
|
+
- !ruby/object:Gem::Version
|
|
131
|
+
version: '5.26'
|
|
132
|
+
type: :development
|
|
133
|
+
prerelease: false
|
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
135
|
+
requirements:
|
|
136
|
+
- - ">="
|
|
137
|
+
- !ruby/object:Gem::Version
|
|
138
|
+
version: '5'
|
|
139
|
+
- - "<"
|
|
140
|
+
- !ruby/object:Gem::Version
|
|
141
|
+
version: '5.26'
|
|
142
|
+
- !ruby/object:Gem::Dependency
|
|
143
|
+
name: rubocop
|
|
144
|
+
requirement: !ruby/object:Gem::Requirement
|
|
145
|
+
requirements:
|
|
146
|
+
- - "~>"
|
|
147
|
+
- !ruby/object:Gem::Version
|
|
148
|
+
version: '1.50'
|
|
149
|
+
type: :development
|
|
150
|
+
prerelease: false
|
|
151
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
152
|
+
requirements:
|
|
153
|
+
- - "~>"
|
|
154
|
+
- !ruby/object:Gem::Version
|
|
155
|
+
version: '1.50'
|
|
156
|
+
- !ruby/object:Gem::Dependency
|
|
157
|
+
name: rubocop-github
|
|
158
|
+
requirement: !ruby/object:Gem::Requirement
|
|
159
|
+
requirements:
|
|
160
|
+
- - "~>"
|
|
161
|
+
- !ruby/object:Gem::Version
|
|
162
|
+
version: '0.26'
|
|
163
|
+
type: :development
|
|
164
|
+
prerelease: false
|
|
165
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
166
|
+
requirements:
|
|
167
|
+
- - "~>"
|
|
168
|
+
- !ruby/object:Gem::Version
|
|
169
|
+
version: '0.26'
|
|
170
|
+
- !ruby/object:Gem::Dependency
|
|
171
|
+
name: rubocop-performance
|
|
172
|
+
requirement: !ruby/object:Gem::Requirement
|
|
173
|
+
requirements:
|
|
174
|
+
- - "~>"
|
|
175
|
+
- !ruby/object:Gem::Version
|
|
176
|
+
version: '1.26'
|
|
177
|
+
type: :development
|
|
178
|
+
prerelease: false
|
|
179
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
180
|
+
requirements:
|
|
181
|
+
- - "~>"
|
|
182
|
+
- !ruby/object:Gem::Version
|
|
183
|
+
version: '1.26'
|
|
184
|
+
- !ruby/object:Gem::Dependency
|
|
185
|
+
name: activesupport
|
|
186
|
+
requirement: !ruby/object:Gem::Requirement
|
|
187
|
+
requirements:
|
|
188
|
+
- - ">="
|
|
189
|
+
- !ruby/object:Gem::Version
|
|
190
|
+
version: '6.1'
|
|
191
|
+
- - "<"
|
|
192
|
+
- !ruby/object:Gem::Version
|
|
193
|
+
version: '7.1'
|
|
194
|
+
type: :development
|
|
195
|
+
prerelease: false
|
|
196
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
197
|
+
requirements:
|
|
198
|
+
- - ">="
|
|
199
|
+
- !ruby/object:Gem::Version
|
|
200
|
+
version: '6.1'
|
|
201
|
+
- - "<"
|
|
202
|
+
- !ruby/object:Gem::Version
|
|
203
|
+
version: '7.1'
|
|
204
|
+
- !ruby/object:Gem::Dependency
|
|
205
|
+
name: brakeman
|
|
206
|
+
requirement: !ruby/object:Gem::Requirement
|
|
207
|
+
requirements:
|
|
208
|
+
- - "~>"
|
|
209
|
+
- !ruby/object:Gem::Version
|
|
210
|
+
version: '6.1'
|
|
211
|
+
type: :development
|
|
212
|
+
prerelease: false
|
|
213
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
214
|
+
requirements:
|
|
215
|
+
- - "~>"
|
|
216
|
+
- !ruby/object:Gem::Version
|
|
217
|
+
version: '6.1'
|
|
218
|
+
description: Compute CSS sizing/alignment styles for SVG and raster logos with consistent
|
|
219
|
+
perceived rendering.
|
|
220
|
+
email:
|
|
221
|
+
- alexandre.camillo@codeminer42.com
|
|
222
|
+
executables: []
|
|
223
|
+
extensions: []
|
|
224
|
+
extra_rdoc_files: []
|
|
225
|
+
files:
|
|
226
|
+
- CHANGELOG.md
|
|
227
|
+
- LICENSE.txt
|
|
228
|
+
- README.md
|
|
229
|
+
- lib/logo_soup.rb
|
|
230
|
+
- lib/logo_soup/core/background_detector.rb
|
|
231
|
+
- lib/logo_soup/core/css.rb
|
|
232
|
+
- lib/logo_soup/core/dimension_calculator.rb
|
|
233
|
+
- lib/logo_soup/core/feature_measurer.rb
|
|
234
|
+
- lib/logo_soup/core/image_loader.rb
|
|
235
|
+
- lib/logo_soup/core/pixel_analyzer.rb
|
|
236
|
+
- lib/logo_soup/core/svg_dimensions.rb
|
|
237
|
+
- lib/logo_soup/core/visual_center_transform.rb
|
|
238
|
+
- lib/logo_soup/style.rb
|
|
239
|
+
- lib/logo_soup/version.rb
|
|
240
|
+
homepage: https://example.com/logosoup
|
|
241
|
+
licenses:
|
|
242
|
+
- MIT
|
|
243
|
+
metadata:
|
|
244
|
+
rubygems_mfa_required: 'true'
|
|
245
|
+
rdoc_options: []
|
|
246
|
+
require_paths:
|
|
247
|
+
- lib
|
|
248
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
249
|
+
requirements:
|
|
250
|
+
- - ">="
|
|
251
|
+
- !ruby/object:Gem::Version
|
|
252
|
+
version: '3.1'
|
|
253
|
+
- - "<"
|
|
254
|
+
- !ruby/object:Gem::Version
|
|
255
|
+
version: '4.0'
|
|
256
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
257
|
+
requirements:
|
|
258
|
+
- - ">="
|
|
259
|
+
- !ruby/object:Gem::Version
|
|
260
|
+
version: '0'
|
|
261
|
+
requirements: []
|
|
262
|
+
rubygems_version: 3.6.9
|
|
263
|
+
specification_version: 4
|
|
264
|
+
summary: Framework-agnostic logo normalization (CSS style output)
|
|
265
|
+
test_files: []
|