老闆來一份上傳圖片要切

#javascript #ruby
卡米
技術文章
老闆來一份上傳圖片要切

圖片上傳是一個在網路上隨處可見的功能,但是要做起來還真多細節在裡頭。

比方說:

客戶:「那可以預覽圖片嗎?」
客戶:「可以在上傳之前弄個裁切嗎?」
客戶:「可以一次上傳多張圖嗎?」
客戶:「可以拖曳上傳嗎?」

老闆:「加點要等哦~」

圖片上傳

圖片上傳可以使用套件來完成,我會推薦使用 carrierwave

細節就跳過不講,總之我會先用產生器來生成一個 uploader。

rails g uploader normal

會生成這樣的檔案:

class NormalUploader < CarrierWave::Uploader::Base

  storage :file

  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end
end

store_dir 說明了圖檔會被存在哪個資料夾。在一個正常的開發環境下,上傳圖片成功後,你可以在 /public 資料夾下找到 uploads 資料夾。

以上傳使用者大頭照來說,我會在 User 上指定透過 image 這個欄位來儲存圖片路徑:

class User < ApplicationRecord
  mount_uploader :image, NormalUploader
end

對應的 form 欄位大概是長這樣:

<%= form_for(user) do |f| %>

...略

  <div class="field">
    <%= f.label : %>
    <%= f.file_field :image, id: 'add_image' %>
  </div>

...略

<% end %>

只要 image 的值是一個檔案或是一個網址,carrierwave 就會幫我們把圖存進指定的資料夾中。

圖片預覽

在古代,圖片預覽是不可能辦到的事,而現代大概有兩種做法:

透過 server

當用戶選擇一個圖檔後,先透過 ajax 上傳圖片到 server,而 server 將圖片儲存後,回傳圖片網址以及圖片 id,瀏覽器端使用 <img src="https://fakeimg.pl/300/"/> 來達成圖片預覽。

透過 base64

當用戶選擇一個圖檔後,透過 FileReader.readAsDataURL 獲得 base64 格式的檔案內容,使用 img ,在 src 填入 base64 字串,達成圖片預覽。

透過 server 的好處是,實際上圖片早已存在 server 上,這表示在最終表單送出時,用戶就不需要等待圖片上傳了。

但是相對的,透過 server 的缺點是也許用戶上傳圖片後基於某些原因(也許是停電),他無法填完表單,這將導致 server 上將會存在一些被人們遺忘的圖片,而這些圖片需要定期清理。

透過 base64 達成圖片預覽的話,就比較簡單了。

透過 server 的做法應該很容易就能查到,我就不介紹了。

透過 base64 達成圖片預覽的寫法是這樣的:

function file_to_base64(file, callback){
  var reader = new FileReader();
  reader.onload = function (e){
    callback(e.target.result);
  }
  reader.readAsDataURL(file);
}

這是一個丟入檔案進去會回傳 base64 格式回來的函數。

$("#add_image").change(function(e){
  file_to_base64(e.target.files[0],function(base64){
    $("#preview").attr("src", base64);
  });
});

我們在當用戶上傳一張圖片後,將圖檔 e.target.files[0] 傳入 file_to_base64,獲得對應的 base64 字串後放進 img 就完成了,是不是很簡單呢?

圖片裁切

圖片裁切也有兩種做法,透過 server 以及透過 base64。

透過 server

當用戶選擇一個圖檔後,先做預覽圖片,然後在畫面上顯示一個裁切區域提供給用戶標記裁切位置,當用戶裁切完成時,將裁切座標儲存至 hidden field 並且透過 css 將預覽圖調整成裁切後的樣子。
在送出表單後圖片將會傳回 server,由 server 進行圖片裁切,通常這會是點陣圖檔的操作。

透過 base64

當用戶選擇一個圖檔後,先做預覽圖片,然後在畫面上顯示一個裁切區域提供給用戶標記裁切位置,當用戶裁切完成時,透過 canvas 進行裁切並取得對應的 base64 內容,將 base64 儲存至 hidden field 並將預覽圖的 src 值替換掉。
在送出表單後代表圖片的 base64 將會傳回 server,由 server 將 base64 轉存成圖片。

不管採用哪個做法,我們都需要使用裁切套件來協助我們取得裁切座標,我們這裡示範使用的套件是 cropper

cropper 介紹

cropper 官方示範的裁切介面如圖中的左側區塊:

裁切介面

他的用法也很簡單:

$("img").cropper();

這樣寫就可以將一個 image 區塊變成一個裁切介面。

如果我們是這樣寫的話:

$("#add_image").change(function(e){
  file_to_base64(e.target.files[0],function(base64){
    $("#preview").attr("src", base64).cropper();
  });
});

就會在上傳圖片後立即看到裁切介面。

然而我們不只需要一個裁切介面,我們可以做成點擊預覽圖後跳出一個裁切介面,並且包含一個好哦~按鈕,當按下好哦~時,將裁切介面關閉,並且把裁切數據保留。

我們可以把這樣的工作做成一個函數,讓我們傳入預覽圖片的 base64 值,並且在指定位置下生成裁切區域,在裁切之後將裁切資訊以 callback 傳回。

var crop_zone_html = `<div class="crop_zone">
  <img />
  <button class="ok" data-turbolinks="false">好哦~</button>
</div>`;

function crop_for_base64(base64, container, callback){
  var crop_zone = $(crop_zone_html);
  var cropping_img = crop_zone.find("img");
  cropping_img.attr("src", base64).cropper();
  container.append(crop_zone);
  crop_zone.find(".ok").click(function(){
    var base64 = $(cropping_img).cropper('getCroppedCanvas').toDataURL('image/png');
    var cropbox = $(cropping_img).cropper('getCropBoxData');
    callback({base64:base64, cropbox:cropbox});
    $(this).parent().remove();
  });
}

我們使用一個樣板字串 crop_zone_html 去做出裁切區域,裡面包含一個 img 和一個按鈕。

把 base64 值傳入後,設定到空的 image tag 上再呼叫 cropper(),裁切區域就完成了。

當 OK 按鈕被按下後,我們就從 cropper 中擷取對應的 base64 值傳回給 callback,並結束裁切介面。

以下是使用情境:

$("#add_image").change(function(e){
  file_to_base64(e.target.files[0],function(base64){
    $("#preview").attr("src", base64).click(function(){
      var preview_img = $(this);
      crop_for_base64(base64, $("#for_cropper"), function(crop_data){
        preview_img.attr("src", crop_data.base64);
      });
    });
  });
});

我們在點擊預覽圖片後會去呼叫前面寫好的 cropforbase64 函數。在每次裁切後會將 base64 設定回預覽圖,結果當然是完美的。剩下來的問題就是如何將結果傳回 server。

那麼以下將示範如何將 base64 傳回並且串接到 carrierwave。

透過 base64 裁切

上傳 base64

當我們選擇將 base64 的圖檔傳回 server 時,我們就已經不再需要原本的 file field 了。所以我們可以改成用一個 hidden field 去保存 base64。

model:

attr_accessor :base64_image

form:

<%= f.hidden_field :base64_picture, id:"base64_image_field" %>

js:

$("#add_image").change(function(e){
  file_to_base64(e.target.files[0],function(base64){
    $("#base64_image_field").val(base64);
    $("#preview").attr("src", base64).click(function(){
      var preview_img = $(this);
      crop_for_base64(base64, $("#for_cropper"), function(crop_data){
        $("#base64_image_field").val(crop_data.base64);
        preview_img.attr("src", crop_data.base64);
      });
    });
  });
});

這樣 base64 就會存在 hidden field 裡,前端的部分這樣就完成了,接下來是在後端收到 base64 後該如何串接 carrierwave 的問題。

base64 與 carrierwave 串接

簡單的想法是 carrierwave 可以接受檔案的格式,所以我們應該先將圖片存成檔案,再傳給 carrierwave。不過,我們可能不想因為存個檔就產生一個暫存檔。如果他可以只存在記憶體,不產生實體檔案,那就更理想了。

直接使用 StringIO 是個不錯的想法,不過我決定要繼承 StringIO。

目標程式:

image = Base64ImageStringIO.new(base64_image)

希望經過一個轉換後將 base64 轉換為 StringIO 的樣子讓 carrierwave 能夠進行存檔工作。

完整的 Base64ImageStringIO 的寫法如下:

class Base64ImageStringIO < StringIO
  REGEXP = /\Adata:([-\w]+\/[-\w\+\.]+)?;base64,(.*)/m

  def initialize(base64_image)
    @extension = '.png'
    super(base64_to_binary(base64_image))
  end
  def original_filename
    @original_filename ||= "#{SecureRandom.uuid}.#{@extension}"
  end

  def base64_to_binary(base64_image)
    data_uri_parts = base64_image.match(REGEXP)
    return nil if data_uri_parts.nil?
    @extension = MIME::Types[data_uri_parts[1]].first.preferred_extension
    Base64.decode64(data_uri_parts[2])
  end
end

在建構時,先假設所有圖片都是 png,並且將 base64 轉換為 binary 再丟給 StringIO 的建構式。

關於檔名的部分,程式會隨機生成一個檔案名稱,並且確保副檔名binary 中的內容相同。carrierwave 會自動抓取 original_filename 的值做為檔案名稱。

這就是完整的圖片裁切流程。

如果是上傳多張圖片再加上裁切,同時還要考慮更新資料時舊圖片跟新圖片的處理,那情況就更複雜了,不過跟透過 server 裁切的方法比起來,我覺得還是各有利弊啦。

透過 server 裁切

如果是透過 server 裁切的話,前端就要將 cropbox 資訊透過 hidden field 傳到後端。

可以讓 carrierwave 幫我們做點事:

class CropUploader < CarrierWave::Uploader::Base

  # Include RMagick or MiniMagick support:
  # include CarrierWave::RMagick
  include CarrierWave::MiniMagick

  # Choose what kind of storage to use for this uploader:
  storage :file
  # storage :fog

  # Override the directory where uploaded files will be stored.
  # This is a sensible default for uploaders that are meant to be mounted:
  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end

  process :crop

  def crop
    if model.crop_attributes.present?
      cropbox = JSON.parse(model.crop_attributes)
      manipulate! do |img|
        x = cropbox["x"].to_i
        y = cropbox["y"].to_i
        w = cropbox["w"].to_i
        h = cropbox["h"].to_i
        # example: mogrify +repage -crop 1x2+3+4 file_patch
        img.combine_options do |c|
          c.repage.+
          c.crop("#{w}x#{h}+#{x}+#{y}")
        end
      end
    end
  end
end

其他的部分就不多說了,最難的部分就是這個 crop,其中:

include CarrierWave::MiniMagick

需要 gem mini_magick 以及 brew install imagemagick。

process :crop

這是一個 callback,會在圖片儲存前給你一個機會對圖片做事。所以只要前端傳遞一個矩形座標到後端就能切圖。這裡就先隨便用 1、2、3、4 做為代表。

img.crop("#{w}x#{h}+#{x}+#{y}")

這個 crop 方法會被轉為系統指令

mogrify -crop 3x4+1+2 file_path

mogrify 是 imagemagick 提供的指令,可以拿它來切圖。

說明書在這裡:https://www.imagemagick.org/script/mogrify.php

一切運作良好,直到我遇到這張圖:

Color

怎麼切位置都是錯的。

強者我同事爬了一下文,發現是 ImageMagick 支援叫做 Virtual Canvas (虛擬圖層?)的資訊,這種東西其實是圖片的 Metadata 的一部分。

把出問題那張圖片拿去解析 Metadata會發現

Image Offset: 54, 64

也就是 ImageMagick 發現他有設定位移,所以就照這個設定去裁切了。然後用 +repage 可以讓他把 Offset 設回 0,0

所以這是我們的目標指令:

mogrify +repage -crop 3x4+1+2 file_path

但 ruby 是要這樣寫:

img.combine_options do |c|
  c.repage.+
  c.crop("#{w}x#{h}+#{x}+#{y}")
end

因為有兩個以上的參數,所以需要用 combine_options 去串接參數。

c.repage.+

會生成出

+repage

事實上他會把函數名稱拿去當作參數名稱,如果我這樣寫:

img.combine_options do |c|
  c.jsdiofaodj.+
  c.crop("#{w}x#{h}+#{x}+#{y}")
end

他就會嘗試執行

mogrify +jsdiofaodj -crop 199x154+234+343 file_path

如果把 .+ 拔掉:

img.combine_options do |c|
  c.jsdiofaodj
  c.crop("#{w}x#{h}+#{x}+#{y}")
end

就會變成

mogrify -jsdiofaodj -crop 199x154+234+343 file_path

如果調換順序:

img.combine_options do |c|
  c.crop("#{w}x#{h}+#{x}+#{y}")
  c.repage.+
end

會變成

mogrify -crop 3x4+1+2 +repage file_path

你可能會想說,參數順序有差嗎?還真的有差。

因為他不是參數順序,而是執行順序。

總而言之,強者我同事守護了世界的和平。

還是切雞排容易多了。


👩‍🏫 課務小幫手:

✨ 想掌握 Ruby on Rails 觀念和原理嗎?

我們有開設 🏓 Ruby on Rails 實戰課程 課程唷 ❤️️