├── aspect.png ├── .github └── workflows │ └── test.yml ├── aspect_ratio.gemspec ├── LICENSE ├── lib └── aspect_ratio.rb ├── README.md └── test └── aspect_ratio_test.rb /aspect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/envato/aspect_ratio/HEAD/aspect.png -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: tests 3 | on: [ push, pull_request ] 4 | jobs: 5 | test: 6 | name: Test (Ruby ${{ matrix.ruby }}) 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | ruby: [ '2.6', '2.7', '3.0', '3.1', '3.2', '3.3' ] 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Ruby ${{ matrix.ruby }} 14 | uses: ruby/setup-ruby@v1 15 | with: 16 | ruby-version: ${{ matrix.ruby }} 17 | - name: Test 18 | run: ruby test/aspect_ratio_test.rb 19 | -------------------------------------------------------------------------------- /aspect_ratio.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | 4 | Gem::Specification.new do |s| 5 | s.name = 'aspect_ratio' 6 | s.version = '1.0.3' 7 | s.date = '2016-04-26' 8 | s.summary = 'Image aspect ratio calculation utility' 9 | s.description = 'Image aspect ratio calculation utility' 10 | s.authors = ['Trung Lê'] 11 | s.email = 'trung.le@ruby-journal.com' 12 | s.files = `git ls-files -z -- lib/* LICENSE README.md aspect_ratio.gemspec`.split("\x0") 13 | s.homepage = 'http://github.com/envato/aspect_ratio' 14 | s.license = 'MIT' 15 | 16 | s.require_paths = ['lib'] 17 | s.required_ruby_version = '>= 2.4.0' 18 | s.test_files = s.files.grep(%r{^(test)/}) 19 | 20 | s.add_development_dependency 'minitest', '~> 5' 21 | end 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Trung Lê 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. 22 | -------------------------------------------------------------------------------- /lib/aspect_ratio.rb: -------------------------------------------------------------------------------- 1 | require 'bigdecimal' 2 | 3 | module AspectRatio 4 | def self.resize(x, y, x_max = nil, y_max = nil, enlarge = true) 5 | x = BigDecimal(x) 6 | y = BigDecimal(y) 7 | x_max = BigDecimal(x_max) if x_max 8 | y_max = BigDecimal(y_max) if y_max 9 | 10 | if x_max && y_max 11 | return [x.to_i, y.to_i] if !enlarge && x <= x_max && y <= y_max 12 | 13 | # Maximum values of height and width given, aspect ratio preserved. 14 | if y > x 15 | return [(y_max * x / y).round, y_max] 16 | else 17 | return [x_max, (x_max * y / x).round] 18 | end 19 | elsif x_max 20 | return [x.to_i, y.to_i] if !enlarge && x <= x_max 21 | 22 | # Width given, height automagically selected to preserve aspect ratio. 23 | return [x_max, (x_max * y / x).round] 24 | else 25 | return [x.to_i, y.to_i] if !enlarge && y <= y_max 26 | 27 | # Height given, width automagically selected to preserve aspect ratio. 28 | return [(y_max * x / y).round, y_max] 29 | end 30 | end 31 | 32 | def self.crop(x, y, r) 33 | orient = r.split('!')[1] 34 | ratio = r.split('!')[0].split(':').sort.map { |r| BigDecimal(r) } 35 | 36 | vertical = y > x 37 | rotate = y > x && orient == 'h' || x > y && orient == 'v' 38 | 39 | if (vertical || rotate) && !(vertical && rotate) 40 | x = x + y 41 | y = x - y 42 | x = x - y 43 | end 44 | 45 | xʹ = x 46 | yʹ = x * (ratio[1] / ratio[0]) 47 | 48 | if (yʹ > y) || rotate && (yʹ > x) 49 | yʹ = y 50 | xʹ = y * (ratio[1] / ratio[0]) 51 | 52 | if xʹ > x 53 | xʹ = x 54 | yʹ = x * (ratio[0] / ratio[1]) 55 | end 56 | end 57 | 58 | delta_x = ((x - xʹ) / 2).to_f.floor 59 | delta_y = ((y - yʹ) / 2).to_f.floor 60 | 61 | if (vertical || rotate) && !(vertical && rotate) 62 | [ 63 | delta_y, # crop top left x 64 | delta_x, # crop top left y 65 | y - delta_y * 2, # crop width 66 | x - delta_x * 2 # crop height 67 | ] 68 | else 69 | [ 70 | delta_x.to_f, # crop top left x 71 | delta_y, # crop top left y 72 | x - delta_x * 2, # crop width 73 | y - delta_y * 2 # crop height 74 | ] 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aspect_ratio 2 | 3 | [![License MIT](https://img.shields.io/badge/license-MIT-brightgreen.svg)](https://github.com/envato/aspect_ratio/blob/main/LICENSE) 4 | [![Gem Version](https://img.shields.io/gem/v/aspect_ratio.svg?maxAge=2592000)](https://rubygems.org/gems/aspect_ratio) 5 | [![Gem Downloads](https://img.shields.io/gem/dt/aspect_ratio.svg?maxAge=2592000)](https://rubygems.org/gems/aspect_ratio) 6 | [![Build Status](https://github.com/envato/aspect_ratio/workflows/tests/badge.svg?branch=main)](https://github.com/envato/aspect_ratio/actions?query=branch%3Amain+workflow%3Atests) 7 | 8 | Image aspect ratio utilities. 9 | 10 | The Ruby port of [node-aspectratio](https://www.npmjs.com/package/aspectratio) npm module. 11 | 12 | ## Install 13 | 14 | Install globally 15 | 16 | ``` 17 | gem install aspect_ratio 18 | ``` 19 | 20 | OR 21 | 22 | Install locally with Bundler 23 | 24 | Please include 25 | 26 | ``` 27 | gem 'aspect_ratio' 28 | ``` 29 | 30 | in your `Gemfile` then `bundle install` 31 | 32 | ## Test 33 | 34 | ``` 35 | ruby test/aspect_ratio_test.rb 36 | ``` 37 | 38 | ## API 39 | 40 | ### crop(**integer** `width`, **integer** `height`, **string** `ratio`) 41 | 42 | Apply a fixed aspect `ratio` crop without distoring the image aspect ratio. 43 | 44 | * **integer** `width` - original image width 45 | * **integer** `height` - original image height 46 | * **string** `ratio` - new image ratio 47 | 48 | > The `ratio` must be on the following format: `x`:`y` where `x` and `y` are 49 | > integers. The order of `x` and `z` does not matter and `3:4` will be treated 50 | > as `4:3`. 51 | 52 | > By default #crop() will match the orientation of the original image unless a 53 | > forced orientation is given on the follwing format: `x`:`y`!`z` where `z` is 54 | > the orientation (`v` for vertical, or `h` for horizontal). 55 | 56 | #### Return 57 | 58 | This will return an `Array` of four values: 59 | 60 | 1. **integer** `x` - top lef x coordinate 61 | 2. **integer** `y` - top lef y coordinate 62 | 3. **integer** `width` - new image width 63 | 4. **integer** `height` - new image height 64 | 65 | #### Example 66 | 67 | ```ruby 68 | require 'aspect_ratio' 69 | AspectRatio.crop(2048, 768, '4:3'); 70 | // [512, 768, 1024, 768] 71 | ``` 72 | 73 | ![Crop with fixed ratio](./aspect.png) 74 | 75 | ### resize(**integer** `x`, **integer** `y`, **integer** `maxX`, **integer** `maxY`, **boolean** `enlarge`) 76 | 77 | Get resized height and width of an image while perserving the aspect ratio of 78 | the image. 79 | 80 | * **integer** `x` - original image width 81 | * **integer** `y` - original image height 82 | * **integer** `maxX` - max image width 83 | * **integer** `maxY` - max image height 84 | * **boolean** `enlarge` - enlarge when original is smaller than the max - default true 85 | 86 | ### Return 87 | 88 | Returns an `Array` of the resized `x` and `y` values: 89 | 90 | * **integer** `x` - resized image width 91 | * **integer** `y` - resized image height 92 | 93 | #### Example 94 | 95 | ```ruby 96 | require 'aspect_ratio' 97 | AspectRatio.resize(2048, 768, 640, 640); 98 | // [640, 240] 99 | ``` 100 | 101 | ## [MIT License](./LICENSE) 102 | -------------------------------------------------------------------------------- /test/aspect_ratio_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require_relative '../lib/aspect_ratio' 3 | 4 | describe AspectRatio do 5 | describe('.resize') do 6 | let(:vertical) { {x: 3456, y: 5184} } 7 | let(:horizontal) { {x: 5184, y: 3456} } 8 | 9 | let(:max_x) { 500 } 10 | let(:max_y) { 500 } 11 | 12 | describe('max_x only') do 13 | it('returns bounds for horizontal image') do 14 | bounds = AspectRatio.resize(horizontal.fetch(:x), horizontal.fetch(:y), max_x) 15 | bounds.must_equal([500, 333]) 16 | end 17 | 18 | it('returns bounds for vertical image') do 19 | bounds = AspectRatio.resize(vertical.fetch(:x), vertical.fetch(:y), max_x) 20 | bounds.must_equal([500, 750]) 21 | end 22 | 23 | describe('when enlarge is false') do 24 | it('returns the original') do 25 | bounds = AspectRatio.resize(200, 300, max_x, nil, false) 26 | bounds.must_equal([200, 300]) 27 | end 28 | end 29 | end 30 | 31 | describe('max_y only') do 32 | it('returns bounds for horizontal image') do 33 | bounds = AspectRatio.resize(horizontal.fetch(:x), horizontal.fetch(:y), nil, max_y) 34 | bounds.must_equal([750, 500]) 35 | end 36 | 37 | it('returns bounds for vertical image') do 38 | bounds = AspectRatio.resize(vertical.fetch(:x), vertical.fetch(:y), nil, max_y) 39 | bounds.must_equal([333, 500]) 40 | end 41 | 42 | describe('when enlarge is false') do 43 | it('returns the original') do 44 | bounds = AspectRatio.resize(200, 300, nil, max_y, false) 45 | bounds.must_equal([200, 300]) 46 | end 47 | end 48 | end 49 | 50 | describe('max_x and max_y') do 51 | it('returns bounds for horizontal image') do 52 | bounds = AspectRatio.resize(horizontal.fetch(:x), horizontal.fetch(:y), 500, 500) 53 | bounds.must_equal([500, 333]) 54 | end 55 | 56 | it('returns bounds for vertical image') do 57 | bounds = AspectRatio.resize(vertical.fetch(:x), vertical.fetch(:y), 500, 500) 58 | bounds.must_equal([333, 500]) 59 | end 60 | 61 | it('properly rounds all edges') do 62 | bounds = AspectRatio.resize(800, 534, 500, 500) 63 | bounds.must_equal([500, 334]) 64 | end 65 | 66 | describe('when enlarge is false') do 67 | it('returns the original') do 68 | bounds = AspectRatio.resize(200, 300, max_x, max_y, false) 69 | bounds.must_equal([200, 300]) 70 | end 71 | end 72 | end 73 | end 74 | 75 | describe '.crop' do 76 | describe 'horizontal image' do 77 | # 5184 × 3456 78 | let(:width) { 5184 } 79 | let(:height) { 3456 } 80 | 81 | describe 'same orientation' do 82 | it('returns crop for 1:1 aspect ratio') do 83 | # 3456 × 3456 84 | crop = AspectRatio.crop(width, height, '1:1') 85 | crop.must_equal([864, 0, 3456, 3456]) 86 | end 87 | 88 | it('returns crop for 3:2 aspect ratio') do 89 | # 5184 × 3456 90 | crop = AspectRatio.crop(width, height, '3:2') 91 | crop.must_equal([0, 0, 5184, 3456]) 92 | end 93 | 94 | it('returns crop for 3:5 aspect ratio') do 95 | # 5184 × 3110 96 | crop = AspectRatio.crop(width, height, '3:5') 97 | crop.must_equal([0, 172, 5184, 3112]) 98 | end 99 | 100 | it('returns crop for 4:3 aspect ratio') do 101 | # 4608 × 3456 102 | crop = AspectRatio.crop(width, height, '4:3') 103 | crop.must_equal([288, 0, 4608, 3456]) 104 | end 105 | 106 | it('returns crop for 5:7 aspect ratio') do 107 | # 4838 × 3456 108 | crop = AspectRatio.crop(width, height, '5:7') 109 | crop.must_equal([172, 0, 4840, 3456]) 110 | end 111 | 112 | it('returns crop for 8:10 aspect ratio') do 113 | # 4320 × 3456 114 | crop = AspectRatio.crop(width, height, '8:10') 115 | crop.must_equal([1209, 0, 2766, 3456]) 116 | end 117 | 118 | it('returns crop for 16:9 aspect ratio') do 119 | # 5184 × 2916 120 | crop = AspectRatio.crop(width, height, '16:9') 121 | crop.must_equal([0, 270, 5184, 2916]) 122 | end 123 | end 124 | 125 | describe 'vertical orientation' do 126 | it('returns crop for 1:1 aspect ratio') do 127 | # 3456 × 3456 128 | crop = AspectRatio.crop(width, height, '1:1!v') 129 | crop.must_equal([864, 0, 3456, 3456]) 130 | end 131 | 132 | it('returns crop for 3:2 aspect ratio') do 133 | # 2304 × 3456 134 | crop = AspectRatio.crop(width, height, '3:2!v') 135 | crop.must_equal([1440, 0, 2304, 3456]) 136 | end 137 | 138 | it('returns crop for 3:5 aspect ratio') do 139 | # 2074 × 3456 140 | crop = AspectRatio.crop(width, height, '3:5!v') 141 | crop.must_equal([1555, 0, 2074, 3456]) 142 | end 143 | 144 | it('returns crop for 4:3 aspect ratio') do 145 | # 2592 × 3456 146 | crop = AspectRatio.crop(width, height, '4:3!v') 147 | crop.must_equal([1296, 0, 2592, 3456]) 148 | end 149 | 150 | it('returns crop for 5:7 aspect ratio') do 151 | # 2469 × 3456 152 | crop = AspectRatio.crop(width, height, '5:7!v') 153 | crop.must_equal([1357, 0, 2470, 3456]) 154 | end 155 | 156 | it('returns crop for 8:10 aspect ratio') do 157 | # 2765 × 3456 158 | crop = AspectRatio.crop(width, height, '8:10!v') 159 | crop.must_equal([1209, 0, 2766, 3456]) 160 | end 161 | 162 | it('returns crop for 16:9 aspect ratio') do 163 | # 1944 × 3456 164 | crop = AspectRatio.crop(width, height, '16:9!v') 165 | crop.must_equal([1620, 0, 1944, 3456]) 166 | end 167 | end 168 | end 169 | 170 | describe 'vertical image' do 171 | # 3456 x 5184 172 | let(:width) { 3456 } 173 | let(:height) { 5184 } 174 | 175 | describe 'same orientation' do 176 | it('returns crop for 1:1 aspect ratio') do 177 | # 3456 × 3456 178 | crop = AspectRatio.crop(width, height, '1:1') 179 | crop.must_equal([0, 864, 3456, 3456]) 180 | end 181 | 182 | it('returns crop for 3:2 aspect ratio') do 183 | # 5184 × 3456 184 | crop = AspectRatio.crop(width, height, '3:2') 185 | crop.must_equal([0, 0, 3456, 5184]) 186 | end 187 | 188 | it('returns crop for 3:5 aspect ratio') do 189 | # 5184 × 3110 190 | crop = AspectRatio.crop(width, height, '3:5') 191 | crop.must_equal([172, 0, 3112, 5184]) 192 | end 193 | 194 | it('returns crop for 4:3 aspect ratio') do 195 | # 4608 × 3456 196 | crop = AspectRatio.crop(width, height, '4:3') 197 | crop.must_equal([0, 288, 3456, 4608]) 198 | end 199 | 200 | it('returns crop for 5:7 aspect ratio') do 201 | # 4838 × 3456 202 | crop = AspectRatio.crop(width, height, '5:7') 203 | crop.must_equal([0, 172, 3456, 4840]) 204 | end 205 | 206 | it('returns crop for 8:10 aspect ratio') do 207 | # 4320 × 3456 208 | crop = AspectRatio.crop(width, height, '8:10') 209 | crop.must_equal([0, 1209, 3456, 2766]) 210 | end 211 | 212 | it('returns crop for 16:9 aspect ratio') do 213 | # 5184 × 2916 214 | crop = AspectRatio.crop(width, height, '16:9') 215 | crop.must_equal([270, 0, 2916, 5184]) 216 | end 217 | end 218 | 219 | describe 'horizontal orientation' do 220 | it('returns crop for 1:1 aspect ratio') do 221 | # 3456 × 3456 222 | crop = AspectRatio.crop(width, height, '1:1!h') 223 | crop.must_equal([0, 864, 3456, 3456]) 224 | end 225 | 226 | it('returns crop for 3:2 aspect ratio') do 227 | # 3456 × 2304 228 | crop = AspectRatio.crop(width, height, '3:2!h') 229 | crop.must_equal([0, 1440, 3456, 2304]) 230 | end 231 | 232 | it('returns crop for 3:5 aspect ratio') do 233 | # 3456 × 2074 234 | crop = AspectRatio.crop(width, height, '3:5!h') 235 | crop.must_equal([0, 1555, 3456, 2074]) 236 | end 237 | 238 | it('returns crop for 4:3 aspect ratio') do 239 | # 3456 × 2592 240 | crop = AspectRatio.crop(width, height, '4:3!h') 241 | crop.must_equal([0, 1296, 3456, 2592]) 242 | end 243 | 244 | it('returns crop for 5:7 aspect ratio') do 245 | # 3456 × 2469 246 | crop = AspectRatio.crop(width, height, '5:7!h') 247 | crop.must_equal([0, 1357, 3456, 2470]) 248 | end 249 | 250 | it('returns crop for 8:10 aspect ratio') do 251 | # 3456 × 2765 252 | crop = AspectRatio.crop(width, height, '8:10!h') 253 | crop.must_equal([0, 1209, 3456, 2766]) 254 | end 255 | 256 | it('returns crop for 16:9 aspect ratio') do 257 | # 3456 × 1944 258 | crop = AspectRatio.crop(width, height, '16:9!h') 259 | crop.must_equal([0, 1620, 3456, 1944]) 260 | end 261 | end 262 | end 263 | end 264 | end 265 | --------------------------------------------------------------------------------