Module: SafeImage::VipsBackend

Defined in:
lib/safe_image/vips_backend.rb

Constant Summary collapse

DIMENSIONS_RE =
/\A(?:(?<percent>\d+(?:\.\d+)?)%|(?<w>\d*)x(?<h>\d*)(?<only_down>>)?|(?<pixels>\d+)@)\z/
BUNDLED_DEJAVU =

Maps the public font tokens (shared with the ImageMagick backend) to Pango family names. DejaVu Sans additionally pins the font file bundled with the gem, so its rendering does not depend on host fonts.

File.expand_path("fonts/DejaVuSans.ttf", __dir__)
PANGO_FONTS =
{
  "DejaVu-Sans" => ["DejaVu Sans", BUNDLED_DEJAVU],
  "NimbusSans-Regular" => ["Nimbus Sans", nil],
  "Liberation-Sans" => ["Liberation Sans", nil],
  "Arial" => ["Arial", nil],
  "Helvetica" => ["Helvetica", nil],
  "Adwaita-Sans" => ["Adwaita Sans", nil]
}.freeze

Class Method Summary collapse

Class Method Details

.crop_north(input:, output:, width:, height:, format:, quality: 85, max_pixels: nil) ⇒ Object



9
10
11
# File 'lib/safe_image/vips_backend.rb', line 9

def crop_north(input:, output:, width:, height:, format:, quality: 85, max_pixels: nil)
  Native.crop_north(input.to_s, output.to_s, Integer(width), Integer(height), format.to_s, Integer(quality), max_pixels)
end

.dominant_color(input, max_pixels: nil) ⇒ Object



23
24
25
26
27
# File 'lib/safe_image/vips_backend.rb', line 23

def dominant_color(input, max_pixels: nil)
  input = PathSafety.ensure_regular_file!(input).to_s
  rgb = Native.dominant_color(input, max_pixels)
  format("%02X%02X%02X", *rgb)
end

.downsize(input:, output:, dimensions:, format:, quality: 85, max_pixels: nil) ⇒ Object



13
14
15
16
17
18
19
20
21
# File 'lib/safe_image/vips_backend.rb', line 13

def downsize(input:, output:, dimensions:, format:, quality: 85, max_pixels: nil)
  probe = SafeImage.probe(input, max_pixels: max_pixels)
  scale = scale_for(probe.width, probe.height, dimensions)
  # Never upscale, but always re-encode through the native saver — even on a
  # no-op scale of 1.0 — so the output is metadata-stripped rather than a
  # verbatim copy of the untrusted input bytes.
  scale = [scale, 1.0].min
  Native.resize(input.to_s, output.to_s, scale, normalized_format(format), Integer(quality), max_pixels)
end

.frame_count(input, max_pixels: nil) ⇒ Object



29
30
31
32
# File 'lib/safe_image/vips_backend.rb', line 29

def frame_count(input, max_pixels: nil)
  input = PathSafety.ensure_regular_file!(input).to_s
  Native.pages(input, max_pixels)
end

.letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans") ⇒ Object

Raises:

  • (ArgumentError)


52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/safe_image/vips_backend.rb', line 52

def letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  output = PathSafety.ensure_safe_output_path!(output).to_s
  size = Integer(size)
  raise ArgumentError, "size must be 1..4096" unless (1..4096).cover?(size)
  pointsize = Integer(pointsize)
  raise ArgumentError, "pointsize must be 1..2000" unless (1..2000).cover?(pointsize)
  rgb = Array(background_rgb).map { |value| Integer(value) }
  unless rgb.length == 3 && rgb.all? { |value| (0..255).cover?(value) }
    raise ArgumentError, "background_rgb must have three channels in 0..255"
  end
  family, fontfile = PANGO_FONTS.fetch(font.to_s) { raise ArgumentError, "unsupported font: #{font.to_s.inspect}" }
  fontfile = nil unless fontfile && File.file?(fontfile)

  # vips_text parses Pango markup, and the glyph derives from user input.
  glyph = letter.to_s.each_grapheme_cluster.first.to_s.strip
  markup = glyph.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")

  Native.letter_avatar(output, size, rgb[0], rgb[1], rgb[2], markup, "#{family} #{pointsize}", fontfile.to_s)
  {
    input_format: "generated",
    output_format: "png",
    width: size,
    height: size,
    duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
  }
end

.normalized_format(format) ⇒ Object



80
81
82
83
# File 'lib/safe_image/vips_backend.rb', line 80

def normalized_format(format)
  format = format.to_s.downcase
  format == "jpeg" ? "jpg" : format
end

.orientation(input, max_pixels: nil) ⇒ Object



34
35
36
37
# File 'lib/safe_image/vips_backend.rb', line 34

def orientation(input, max_pixels: nil)
  input = PathSafety.ensure_regular_file!(input).to_s
  Native.orientation(input, max_pixels)
end

.scale_for(width, height, dimensions) ⇒ Object

Raises:

  • (ArgumentError)


85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/safe_image/vips_backend.rb', line 85

def scale_for(width, height, dimensions)
  dimensions = dimensions.to_s
  match = DIMENSIONS_RE.match(dimensions) or raise ArgumentError, "unsupported dimensions: #{dimensions.inspect}"

  if match[:percent]
    return Float(match[:percent]) / 100.0
  end

  if match[:pixels]
    target_pixels = Float(match[:pixels])
    return Math.sqrt(target_pixels / (Integer(width) * Integer(height)))
  end

  target_w = match[:w].to_s.empty? ? nil : Float(match[:w])
  target_h = match[:h].to_s.empty? ? nil : Float(match[:h])
  scales = []
  scales << target_w / width if target_w
  scales << target_h / height if target_h
  raise ArgumentError, "missing width/height in dimensions: #{dimensions.inspect}" if scales.empty?
  scales.min
end