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 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
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial gem scaffold
6
+ - `LogoSoup.style` supports SVG strings and raster image paths
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
+ [![Version](https://img.shields.io/badge/version-0.1.0-blue)](CHANGELOG.md)
4
+ [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE.txt)
5
+ [![Coverage](https://img.shields.io/badge/coverage-86.14%25-brightgreen)](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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogoSoup
4
+ VERSION = "0.1.0"
5
+ 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: []