Module: SafeImage::Optimizer

Defined in:
lib/safe_image/optimizer.rb

Constant Summary collapse

MAX_PNGQUANT_SIZE =
500_000
JPEGTRAN_OPERATIONS =

EXIF orientation values mapped onto jpegtran’s lossless transforms.

{
  2 => ["-flip", "horizontal"],
  3 => ["-rotate", "180"],
  4 => ["-flip", "vertical"],
  5 => ["-transpose"],
  6 => ["-rotate", "90"],
  7 => ["-transverse"],
  8 => ["-rotate", "270"]
}.freeze

Class Method Summary collapse

Class Method Details

.jpeg_orientation(path) ⇒ Object



120
121
122
123
124
125
# File 'lib/safe_image/optimizer.rb', line 120

def jpeg_orientation(path)
  case SafeImage.config.backend
  when :vips then VipsBackend.orientation(path.to_s)
  when :imagemagick then ImageMagickBackend.orientation(path.to_s)
  end
end

.optimize(path, mode: :lossless, strip_metadata: true, quality: nil, timeout: Runner::DEFAULT_TIMEOUT, strict: true, assume_upright: false) ⇒ Object

assume_upright: skips the JPEG orientation check; only for callers optimising output this gem just encoded (which is always upright).



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/safe_image/optimizer.rb', line 24

def optimize(path, mode: :lossless, strip_metadata: true, quality: nil, timeout: Runner::DEFAULT_TIMEOUT, strict: true, assume_upright: false)
  path = PathSafety.ensure_regular_file!(path)

  ext = path.extname.delete_prefix(".").downcase
  ext = "jpg" if ext == "jpeg"

  before = File.size(path)
  tools = []
  rotated_from = nil
  trimmed = false

  case ext
  when "jpg"
    # Stripping metadata deletes the EXIF orientation tag, so an oriented
    # image must have the rotation baked into its pixels first or it ships
    # sideways. jpegtran does that losslessly; without it, leave the file
    # untouched rather than strip-without-rotate.
    orientation =  && !assume_upright ? jpeg_orientation(path) : 1
    if orientation > 1
      unless Runner.available?("jpegtran")
        raise Error, "jpegtran is required to optimize a JPEG with EXIF orientation" if strict
        return { format: ext, before_bytes: before, after_bytes: before, saved_bytes: 0, tools: tools, rotated_from: nil, trimmed: false }
      end
      trimmed = upright!(path, orientation, timeout: timeout)
      rotated_from = orientation
      tools << "jpegtran"
    end

    if Runner.available?("jpegoptim")
      argv = ["jpegoptim", "--quiet"]
      argv << ( ? "--strip-all" : "--strip-none")
      argv << "--max=#{Integer(quality)}" if quality
      argv << path.to_s
      Runner.run!(argv, timeout: timeout)
      tools << "jpegoptim"
    else
      raise Error, "jpegoptim is required for strict JPEG optimisation" if strict
    end
  when "png"
    if mode.to_sym == :lossy && before < MAX_PNGQUANT_SIZE
      if Runner.available?("pngquant")
        tmp = Tempfile.new([path.basename(".*").to_s, ".pngquant.png"], path.dirname.to_s)
        tmp_path = Pathname.new(tmp.path)
        tmp.close
        begin
          argv = ["pngquant", "--force", "--skip-if-larger", "--output", tmp_path.to_s]
          argv << "--quality=#{quality}" if quality # e.g. "65-90"
          argv << path.to_s
          skipped = false
          begin
            Runner.run!(argv, timeout: timeout)
          rescue CommandError => e
            # 98: --skip-if-larger declined the result; 99: --quality not
            # met. Both mean "keep the original", not a failure — and the
            # pre-created tempfile is still empty, so it must not win the
            # size comparison below.
            raise unless [98, 99].include?(e.status)
            skipped = true
          end
          if !skipped && tmp_path.file? && File.size(tmp_path).positive? && File.size(tmp_path) < File.size(path)
            FileUtils.mv(tmp_path, path)
            tools << "pngquant"
          end
        ensure
          FileUtils.rm_f(tmp_path)
        end
      elsif strict
        raise Error, "pngquant is required for strict lossy PNG optimisation"
      end
    end

    if Runner.available?("oxipng")
      argv = ["oxipng", "--quiet", "-o", "3"]
      argv.concat(["--strip",  ? "safe" : "none"])
      argv << path.to_s
      Runner.run!(argv, timeout: timeout)
      tools << "oxipng"
    else
      raise Error, "oxipng is required for strict PNG optimisation" if strict
    end
  else
    raise UnsupportedFormatError, "unsupported optimize format: #{ext.inspect}"
  end

  after = File.size(path)
  {
    format: ext,
    before_bytes: before,
    after_bytes: after,
    saved_bytes: before - after,
    tools: tools,
    rotated_from: rotated_from,
    trimmed: trimmed
  }
end

.upright!(path, orientation, timeout:) ⇒ Object

Applies the orientation’s lossless jpegtran transform in place, dropping the metadata in the same pass (-copy none; this path only runs when strip_metadata is set). -perfect refuses dimensions that are not MCU-aligned; the -trim retry drops the partial edge blocks (under one MCU, at most 15px) instead of hiding a lossy re-encode here. Returns true when the fallback trimmed.



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'lib/safe_image/optimizer.rb', line 133

def upright!(path, orientation, timeout:)
  transform = JPEGTRAN_OPERATIONS.fetch(orientation)
  tmp = Tempfile.new([path.basename(".*").to_s, ".jpegtran.jpg"], path.dirname.to_s)
  tmp_path = Pathname.new(tmp.path)
  tmp.close
  begin
    trimmed = false
    begin
      Runner.run!(["jpegtran", "-copy", "none", "-perfect", *transform, "-outfile", tmp_path.to_s, path.to_s], timeout: timeout)
    rescue CommandError
      Runner.run!(["jpegtran", "-copy", "none", "-trim", *transform, "-outfile", tmp_path.to_s, path.to_s], timeout: timeout)
      trimmed = true
    end
    FileUtils.mv(tmp_path, path)
    trimmed
  ensure
    FileUtils.rm_f(tmp_path)
  end
end