jekyll-imgflow 0.3.3 → 0.4.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.
@@ -2,159 +2,104 @@
2
2
 
3
3
  require "net/http"
4
4
  require "uri"
5
- require "tempfile"
6
5
  require_relative "base_provider"
7
6
 
8
7
  module JekyllImgFlow
9
8
  module Providers
10
9
  # Weserv provider implementation using the standardized tag interface
11
- class Weserv < BaseProvider
10
+ class Weserv < HttpBase
12
11
  TIMEOUT = 30
13
-
14
- def available?
15
- # Check if weserv service is running
16
- return false unless @config.respond_to?(:weserv_url) && @config.weserv_url
17
-
18
- check_http_service(@config.weserv_url)
12
+ # SVGs without explicit pixel dimensions (e.g. width="100%") are valid
13
+ # but cause librsvg to rasterize at the viewBox size, which can be huge.
14
+ # When no resize operation is present, cap the rasterization at 2000px
15
+ # on the longest edge so weserv processes the SVG at a reasonable size.
16
+ SVG_DEFAULT_MAX_SIZE = 2000
17
+
18
+ def service_url
19
+ @config.respond_to?(:weserv_url) ? @config.weserv_url : nil
19
20
  end
20
21
 
21
- def execute(input_path, output_path)
22
- return if @operations.empty?
23
-
24
- # Build single Weserv URL with all operations combined
25
- url = build_combined_weserv_url(input_path)
26
-
27
- # Fetch the result in one request
28
- fetch_and_save(url, output_path)
29
- output_path
30
- ensure
31
- reset_operations
22
+ def build_combined_weserv_url(input_path)
23
+ build_combined_url(input_path)
32
24
  end
33
25
 
34
- def build_combined_weserv_url(input_path)
35
- raise "weserv_url not set" unless @config.weserv_url
26
+ def build_combined_url(input_path)
27
+ raise "weserv_url not set" unless service_url
36
28
 
37
29
  encoded_url = encode_file_url(input_path)
38
30
  params = ["url=#{URI.encode_www_form_component(encoded_url)}"]
39
31
 
40
- # Add all operations to single URL
41
- @operations.each do |operation|
42
- case operation[:type]
43
- when :resize
44
- # Tags now provide complete calculated values
45
- params << if operation[:height]
46
- "w=#{operation[:width]}&h=#{operation[:height]}&fit=fill"
47
- else
48
- "w=#{operation[:width]}"
49
- end
50
-
51
- when :crop
52
- opts = operation[:options]
53
- op_params = operation[:params] || {}
54
-
55
- # Check for keep parameter (smartcrop support)
56
- keep = opts[:keep] || op_params[:keep] || op_params[:position]
57
-
58
- if operation[:ratio] && keep && SMARTCROP_POSITIONS.include?(keep.to_s)
59
- # Use smartcrop (WeServ supports smartcrop)
60
- crop_width = opts[:calculated_width]
61
- crop_height = opts[:calculated_height]
62
-
63
- # Add smartcrop parameters according to WeServ docs
64
- params << "crop=#{crop_width}x#{crop_height}"
65
- params << "a=smart" # smart attention
66
-
67
- # Map keep parameter to WeServ attention type
68
- case keep.to_s
69
- when "attention" then params << "a=attention"
70
- when "entropy" then params << "a=entropy"
71
- when "center", "centre" then params << "a=center"
72
- end
73
- else
74
- # Use basic cropping
75
- if operation[:ratio]
76
- crop_x = opts[:calculated_x]
77
- crop_y = opts[:calculated_y]
78
- crop_width = opts[:calculated_width]
79
- crop_height = opts[:calculated_height]
80
- else
81
- crop_x = opts[:x]
82
- crop_y = opts[:y]
83
- crop_width = opts[:width]
84
- crop_height = opts[:height]
85
- end
86
- params << "cx=#{crop_x}&cy=#{crop_y}&cw=#{crop_width}&ch=#{crop_height}"
87
- end
88
-
89
- when :quality
90
- weserv_quality = translate_quality_to_weserv(operation[:quality])
91
- params << "q=#{weserv_quality}"
92
-
93
- when :format
94
- # Assume validated input from tags
95
- params << "output=#{operation[:format]}"
96
-
97
- when :watermark
98
- watermark_path = operation[:watermark_path]
99
- position = operation[:options][:position]
100
- opacity = operation[:options][:opacity]
101
-
102
- # Weserv watermark parameters
103
- params << "wm=#{encode_file_url(watermark_path)}"
104
- params << "wmpos=#{translate_weserv_position(position)}"
105
- params << "wmo=#{opacity}" if opacity
106
-
107
- when :alpha_opacity
108
- opacity = operation[:opacity]
109
- params << "alpha=#{(opacity * 255).round}"
110
- end
111
- end
32
+ @operations.each { |operation| append_weserv_operation(operation, params) }
112
33
 
113
- query_string = params.join("&")
114
- "#{@config.weserv_url}/?#{query_string}"
34
+ ensure_svg_size_limit(input_path, params)
35
+
36
+ "#{service_url}/?#{params.join('&')}"
115
37
  end
116
38
 
117
39
  private
118
40
 
119
- def translate_weserv_position(position)
120
- # Translate compass directions to Weserv position format
121
- case position
122
- when "northwest" then "tl"
123
- when "northeast" then "tr"
124
- when "southwest" then "bl"
125
- when "southeast" then "br"
126
- when "center" then "c"
127
- else position
128
- end
41
+ def ensure_svg_size_limit(input_path, params)
42
+ return unless svg_file?(input_path)
43
+ return if params.any? { |p| p.start_with?("w=", "h=", "crop=") || p.include?("cw=") }
44
+
45
+ params << "w=#{SVG_DEFAULT_MAX_SIZE}"
129
46
  end
130
47
 
131
- def fetch_and_save(url, output_path)
132
- response = fetch_with_timeout(url)
133
- raise "Weserv request failed" if response.empty?
48
+ def svg_file?(input_path)
49
+ File.extname(input_path).downcase == ".svg"
50
+ end
134
51
 
135
- FileUtils.mkdir_p(File.dirname(output_path))
136
- File.write(output_path, response)
52
+ def append_weserv_operation(operation, params)
53
+ case operation[:type]
54
+ when :resize
55
+ params << if operation[:height]
56
+ "w=#{operation[:width]}&h=#{operation[:height]}&fit=fill"
57
+ else
58
+ "w=#{operation[:width]}"
59
+ end
60
+ when :crop
61
+ append_weserv_crop(operation, params)
62
+ when :quality
63
+ params << "q=#{operation[:quality]}"
64
+ when :format
65
+ params << "output=#{operation[:format]}"
66
+ when :watermark
67
+ append_weserv_watermark(operation, params)
68
+ when :alpha_opacity
69
+ params << "alpha=#{alpha_byte_value(operation[:opacity])}"
70
+ end
137
71
  end
138
72
 
139
- def fetch_with_timeout(url)
140
- uri = URI(url)
141
- Net::HTTP.start(uri.host, uri.port,
142
- use_ssl: uri.scheme == "https",
143
- open_timeout: TIMEOUT,
144
- read_timeout: TIMEOUT) do |http|
145
- request = Net::HTTP::Get.new(uri)
146
- response = http.request(request)
73
+ def append_weserv_crop(operation, params)
74
+ geo = crop_geometry(operation)
147
75
 
148
- raise "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
76
+ if geo[:smartcrop]
77
+ params << "crop=#{geo[:width]}x#{geo[:height]}"
78
+ params << "a=smart"
79
+ params << weserv_attention(geo[:keep])
80
+ else
81
+ params << "cx=#{geo[:x]}&cy=#{geo[:y]}&cw=#{geo[:width]}&ch=#{geo[:height]}"
82
+ end
83
+ end
149
84
 
150
- response.body
85
+ def weserv_attention(keep)
86
+ case keep.to_s
87
+ when "attention" then "a=attention"
88
+ when "entropy" then "a=entropy"
89
+ when "center", "centre" then "a=center"
151
90
  end
152
- rescue StandardError => e
153
- raise "Weserv request failed: #{e.message}"
154
91
  end
155
92
 
156
- def translate_quality_to_weserv(quality)
157
- quality
93
+ def append_weserv_watermark(operation, params)
94
+ parts = watermark_parts(operation)
95
+ params << "wm=#{encode_file_url(parts[:watermark_path])}"
96
+ params << "wmpos=#{compass_to_short(parts[:position])}"
97
+ params << "wmo=#{parts[:opacity]}" if parts[:opacity]
98
+ end
99
+
100
+ # Thin wrapper kept for test compatibility.
101
+ def translate_weserv_position(position)
102
+ compass_to_short(position)
158
103
  end
159
104
  end
160
105
  end
@@ -127,34 +127,14 @@ module JekyllImgFlow
127
127
  image_path = parts.shift
128
128
 
129
129
  # Parse operations as key:value pairs separated by commas or spaces
130
- operations = []
131
130
  remaining = parts.join(" ")
131
+ operation_pairs = split_operation_pairs(remaining)
132
132
 
133
- # Handle comma-separated: key:value,key:value
134
- operation_pairs = if remaining.include?(",")
135
- remaining.split(",")
136
- else
137
- # Handle space-separated: key:value key:value
138
- remaining.split(/\s+/)
139
- end
140
-
141
- operation_pairs.each do |pair|
142
- next if pair.empty?
143
-
144
- next unless pair.include?(":")
133
+ operations = operation_pairs.filter_map do |pair|
134
+ next if pair.empty? || !pair.include?(":")
145
135
 
146
136
  key, value = pair.split(":", 2)
147
- # Convert numeric values using simple string methods
148
- value_stripped = value.strip
149
- converted_value = if value_stripped.match?(/^\d+$/)
150
- value_stripped.to_i
151
- elsif value_stripped.match?(/^\d+\.\d+$/)
152
- value_stripped.to_f
153
- else
154
- value_stripped
155
- end
156
- # Store with converted value for downstream type correctness
157
- operations << "#{key.strip}:#{converted_value}"
137
+ "#{key.strip}:#{convert_value(value.strip)}"
158
138
  end
159
139
 
160
140
  {
@@ -163,6 +143,21 @@ module JekyllImgFlow
163
143
  }
164
144
  end
165
145
 
146
+ def split_operation_pairs(remaining)
147
+ # Handle comma-separated: key:value,key:value
148
+ return remaining.split(",") if remaining.include?(",")
149
+
150
+ # Handle space-separated: key:value key:value
151
+ remaining.split(/\s+/)
152
+ end
153
+
154
+ def convert_value(value)
155
+ return value.to_i if value.match?(/^\d+$/)
156
+ return value.to_f if value.match?(/^\d+\.\d+$/)
157
+
158
+ value
159
+ end
160
+
166
161
  private
167
162
 
168
163
  # Get all scannable content from site
@@ -33,6 +33,14 @@ module JekyllImgFlow
33
33
  end
34
34
  end
35
35
 
36
+ # Like get_image_dimensions but raises if dimensions cannot be read.
37
+ def get_original_dimensions(image_path)
38
+ width, height = get_image_dimensions(image_path)
39
+ raise ArgumentError, "Unable to determine original image dimensions" unless width && height
40
+
41
+ [width, height]
42
+ end
43
+
36
44
  def validate_positive_integer(value, name)
37
45
  return if value.nil?
38
46
 
@@ -47,6 +55,31 @@ module JekyllImgFlow
47
55
  # Config class already handles the fallback (cfg["quality"] || 85)
48
56
  @provider.config&.quality
49
57
  end
58
+
59
+ def validate_quality(value)
60
+ return @default_quality if value.nil?
61
+
62
+ quality = value.to_i
63
+ raise ArgumentError, "Invalid quality: '#{value}'. Must be between 1 and 100." unless quality.between?(1, 100)
64
+
65
+ quality
66
+ end
67
+
68
+ def validate_opacity(value, min: 0.0, max: 1.0)
69
+ raise ArgumentError, "Invalid opacity: '#{value}'. Must be between #{min} and #{max}." unless valid_numeric?(value)
70
+
71
+ opacity = value.to_f
72
+ raise ArgumentError, "Invalid opacity: '#{value}'. Must be between #{min} and #{max}." unless opacity.between?(min, max)
73
+
74
+ opacity
75
+ end
76
+
77
+ def valid_numeric?(value)
78
+ Float(value)
79
+ true
80
+ rescue ArgumentError, TypeError
81
+ false
82
+ end
50
83
  end
51
84
  end
52
85
  end
@@ -9,25 +9,16 @@ module JekyllImgFlow
9
9
  def process(input_path, output_path, options = {})
10
10
  ensure_output_dir(output_path)
11
11
 
12
- # Validate that crop information is provided
13
12
  ratio = options[:ratio] || options[:aspect_ratio]
14
13
  has_pixel_crop = options[:width] || options[:height]
15
14
 
16
- unless ratio || has_pixel_crop
17
- raise ArgumentError,
18
- "Crop operation requires ratio or at least one dimension (width or height)"
19
- end
15
+ validate_crop_request(ratio, has_pixel_crop)
20
16
 
21
17
  if ratio
22
- # Handle aspect ratio cropping (e.g., "16:9", "4:3")
23
18
  validate_ratio(ratio)
24
19
  handle_aspect_ratio_crop(input_path, output_path, ratio, options)
25
20
  else
26
- # Handle pixel-based cropping with flexible dimensions
27
- validate_positive_integer(options[:width], "width") if options[:width]
28
- validate_positive_integer(options[:height], "height") if options[:height]
29
- validate_positive_integer(options[:x], "x") if options[:x]
30
- validate_positive_integer(options[:y], "y") if options[:y]
21
+ validate_pixel_dimensions(options)
31
22
  handle_pixel_crop(input_path, output_path, options)
32
23
  end
33
24
 
@@ -36,16 +27,25 @@ module JekyllImgFlow
36
27
 
37
28
  private
38
29
 
30
+ def validate_crop_request(ratio, has_pixel_crop)
31
+ return if ratio || has_pixel_crop
32
+
33
+ raise ArgumentError,
34
+ "Crop operation requires ratio or at least one dimension (width or height)"
35
+ end
36
+
37
+ def validate_pixel_dimensions(options)
38
+ validate_positive_integer(options[:width], "width") if options[:width]
39
+ validate_positive_integer(options[:height], "height") if options[:height]
40
+ validate_positive_integer(options[:x], "x") if options[:x]
41
+ validate_positive_integer(options[:y], "y") if options[:y]
42
+ end
43
+
39
44
  # Handle aspect ratio cropping
40
45
  def handle_aspect_ratio_crop(input_path, _output_path, ratio, options)
41
46
  # Calculate optimal crop dimensions for the aspect ratio
42
47
  calculated_options = calculate_aspect_ratio_dimensions(input_path, ratio, options)
43
-
44
- # Add keep parameter for smartcrop (JPT compatibility)
45
- if options[:keep] && %w[attention entropy center
46
- centre].include?(options[:keep].to_s)
47
- calculated_options[:keep] = options[:keep]
48
- end
48
+ add_keep_option(calculated_options, options[:keep])
49
49
 
50
50
  # Pass ratio and calculated options to provider (uniform interface)
51
51
  @provider.crop(ratio, calculated_options)
@@ -53,57 +53,34 @@ module JekyllImgFlow
53
53
 
54
54
  # Handle flexible pixel-based cropping
55
55
  def handle_pixel_crop(input_path, _output_path, options)
56
- # Get original image dimensions
57
56
  original_width, original_height = get_original_dimensions(input_path)
58
57
 
59
- # Parse dimensions (support pixels and percentages)
60
58
  crop_width = parse_dimension(options[:width], original_width)
61
59
  crop_height = parse_dimension(options[:height], original_height)
62
60
 
63
- # At least one dimension must be specified
64
61
  raise ArgumentError, "At least width or height must be specified for cropping" unless crop_width || crop_height
65
62
 
66
63
  # Calculate missing dimension if only one provided
67
- if crop_width && !crop_height
68
- crop_height = original_height # Keep original height
69
- elsif crop_height && !crop_width
70
- crop_width = original_width # Keep original width
71
- end
64
+ crop_width ||= original_width # Keep original width
65
+ crop_height ||= original_height # Keep original height
72
66
 
73
67
  # Parse positions with smart defaults - always provide values
74
68
  crop_x = parse_position_with_default(options[:x], crop_width, original_width)
75
69
  crop_y = parse_position_with_default(options[:y], crop_height, original_height)
76
70
 
77
- # Validate crop area is not bigger than original
78
71
  validate_crop_dimensions(crop_x, crop_y, crop_width, crop_height, original_width,
79
72
  original_height)
80
73
 
81
- # Build crop options with keep parameter support
82
- crop_options = {
83
- x: crop_x,
84
- y: crop_y,
85
- width: crop_width,
86
- height: crop_height
87
- }
74
+ crop_options = { x: crop_x, y: crop_y, width: crop_width, height: crop_height }
75
+ add_keep_option(crop_options, options[:keep])
88
76
 
89
- # Add keep parameter for smartcrop (JPT compatibility)
90
- if options[:keep] && %w[attention entropy center
91
- centre].include?(options[:keep].to_s)
92
- crop_options[:keep] = options[:keep]
93
- end
94
-
95
- # Execute crop with calculated dimensions
96
77
  @provider.crop(nil, crop_options)
97
78
  end
98
79
 
99
- # Get original image dimensions using FastImage
100
- def get_original_dimensions(input_path)
101
- require "fastimage"
102
- original_width, original_height = FastImage.size(input_path)
103
-
104
- raise ArgumentError, "Unable to determine original image dimensions" unless original_width && original_height
80
+ def add_keep_option(crop_options, keep)
81
+ return unless keep && %w[attention entropy center centre].include?(keep.to_s)
105
82
 
106
- [original_width, original_height]
83
+ crop_options[:keep] = keep
107
84
  end
108
85
 
109
86
  # Parse dimension (supports pixels and percentages)
@@ -13,31 +13,12 @@ module JekyllImgFlow
13
13
  opacity = options[:opacity]
14
14
  raise ArgumentError, "Opacity parameter is required" if opacity.nil?
15
15
 
16
- validated_opacity = validate_opacity(opacity)
16
+ validated_opacity = validate_opacity(opacity, min: 0.01, max: 0.99)
17
17
 
18
18
  @provider.alpha_opacity = validated_opacity
19
19
  @provider.execute(input_path, output_path)
20
20
  output_path
21
21
  end
22
-
23
- private
24
-
25
- def validate_opacity(value)
26
- # Check if value is numeric before converting
27
- raise ArgumentError, "Invalid opacity: '#{value}'. Must be between 0.01 and 0.99." unless valid_numeric?(value)
28
-
29
- opacity = value.to_f
30
- raise ArgumentError, "Invalid opacity: '#{value}'. Must be between 0.01 and 0.99." unless opacity.between?(0.01, 0.99)
31
-
32
- opacity
33
- end
34
-
35
- def valid_numeric?(value)
36
- Float(value)
37
- true
38
- rescue ArgumentError, TypeError
39
- false
40
- end
41
22
  end
42
23
  end
43
24
  end
@@ -38,15 +38,6 @@ module JekyllImgFlow
38
38
 
39
39
  quality
40
40
  end
41
-
42
- def validate_quality(value)
43
- return @default_quality if value.nil?
44
-
45
- quality = value.to_i
46
- raise ArgumentError, "Invalid quality: '#{value}'. Must be between 1 and 100." unless quality.between?(1, 100)
47
-
48
- quality
49
- end
50
41
  end
51
42
  end
52
43
  end
@@ -15,17 +15,6 @@ module JekyllImgFlow
15
15
  @provider.quality = quality
16
16
  @provider.execute(input_path, output_path)
17
17
  end
18
-
19
- private
20
-
21
- def validate_quality(value)
22
- return @default_quality if value.nil?
23
-
24
- quality = value.to_i
25
- raise ArgumentError, "Invalid quality: '#{value}'. Must be between 1 and 100." unless quality.between?(1, 100)
26
-
27
- quality
28
- end
29
18
  end
30
19
  end
31
20
  end
@@ -13,23 +13,13 @@ module JekyllImgFlow
13
13
  width = validate_positive_integer(options[:width], "width")
14
14
  height = validate_positive_integer(options[:height], "height")
15
15
 
16
- # At least one dimension must be specified
17
16
  raise ArgumentError, "Either width or height must be specified for resize operation." if width.nil? && height.nil?
18
17
 
19
18
  # Get original dimensions
20
19
  original_width, original_height = get_image_dimensions(input_path)
21
20
 
22
21
  # Calculate missing dimension to maintain aspect ratio ONLY when one dimension is missing
23
- if width.nil? && height
24
- # Only height provided - calculate width to maintain aspect ratio
25
- aspect_ratio = original_width.to_f / original_height
26
- width = (height * aspect_ratio).round
27
- elsif height.nil? && width
28
- # Only width provided - calculate height to maintain aspect ratio
29
- aspect_ratio = original_height.to_f / original_width
30
- height = (width * aspect_ratio).round
31
- end
32
- # If both width and height are provided, use them as-is (allows aspect ratio changes)
22
+ width, height = calculate_missing_dimension(width, height, original_width, original_height)
33
23
 
34
24
  # Calculate scale factors for provider
35
25
  scale_x = width && original_width ? width.to_f / original_width : nil
@@ -54,6 +44,23 @@ module JekyllImgFlow
54
44
  result
55
45
  end
56
46
 
47
+ private
48
+
49
+ def calculate_missing_dimension(width, height, original_width, original_height)
50
+ if width.nil? && height
51
+ # Only height provided - calculate width to maintain aspect ratio
52
+ aspect_ratio = original_width.to_f / original_height
53
+ [(height * aspect_ratio).round, height]
54
+ elsif height.nil? && width
55
+ # Only width provided - calculate height to maintain aspect ratio
56
+ aspect_ratio = original_height.to_f / original_width
57
+ [width, (width * aspect_ratio).round]
58
+ else
59
+ # If both width and height are provided, use them as-is (allows aspect ratio changes)
60
+ [width, height]
61
+ end
62
+ end
63
+
57
64
  # validate_positive_integer inherited from BaseTag
58
65
  end
59
66
  end
@@ -36,23 +36,6 @@ module JekyllImgFlow
36
36
  else position.to_s
37
37
  end
38
38
  end
39
-
40
- def validate_opacity(value)
41
- # Check if value is numeric before converting
42
- raise ArgumentError, "Invalid opacity: '#{value}'. Must be between 0.0 and 1.0." unless valid_numeric?(value)
43
-
44
- opacity = value.to_f
45
- raise ArgumentError, "Invalid opacity: '#{value}'. Must be between 0.0 and 1.0." unless opacity.between?(0.0, 1.0)
46
-
47
- opacity
48
- end
49
-
50
- def valid_numeric?(value)
51
- Float(value)
52
- true
53
- rescue ArgumentError, TypeError
54
- false
55
- end
56
39
  end
57
40
  end
58
41
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JekyllImgFlow
4
- VERSION = "0.3.3"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-imgflow
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.3
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svend Gundestrup