[rails][RubyXL]テンプレートエクセルを編集してクライアントへ返送

# 概要

バックエンドで保持しているテンプレートエクセルファイルをいい感じに編集してクライアントへ返送するAPIの話。
ChatGPT 4oに聞いてもいまいち情報を得られなかったので自力調査した内容を纏めておく。

サンプルコードのエクセル編集内容は以下。

- 書式設定された行をコピー
- 入力規則の設定
- 値入力

# 動作環境

```Gemfile.lock
RUBY VERSION
   ruby 3.1.1p18

GEM
  specs:
    rails (7.0.4)
    rubyXL (3.4.27)
```

# ソースコード

## バックエンド
一部コード抜粋
```template_generate_service.rb
class TemplateGenerateService
  INPUT_FILE_NAME = 'フォーマット.xlsx'.freeze
  SOURCE_ROW_INDEX = 5 # データ行書式コピー元の行インデックス
  START_ROW_INDEX = 5 # データ行の開始インデックス

  def initialize(header_data:, rows_data:)
    @header_data = header_data
    @rows_data = rows_data
  end

  def file_extension
    File.extname(INPUT_FILE_NAME)
  end

  def generate
    template_path = fetch_template_path

    workbook = RubyXL::Parser.parse(template_path)
    worksheet = workbook[0]

    # 特定セルへの値入力
    update_template_headers(worksheet)

    end_row_index = START_ROW_INDEX + @rows_data.length - 1

    # データ行生成と書式設定
    styling_rows(worksheet:, start_row_index: START_ROW_INDEX, end_row_index:)

    # データ行の一部に入力規則設定
    set_data_validations(worksheet:, start_row_index: START_ROW_INDEX, end_row_index:)

    # データ入力
    put_rows_data(worksheet:, start_row_index: START_ROW_INDEX)

    # 編集されたエクセルデータ返却
    workbook.stream.string
  end

  private

  def fetch_template_path
    base_path = Rails.public_path.join('templates')
    file_path = base_path.join(INPUT_FILE_NAME)
    raise "Template file not found: #{file_path}" unless File.exist?(file_path)

    file_path
  end

  def update_template_headers(worksheet)
    worksheet[1][1].change_contents(@header_data.label) # B2
    worksheet[2][1].change_contents(@header_data.note) # B3
  end

  # 元ファイルに予め書式設定された行を他の行へコピー
  def styling_rows(worksheet:, start_row_index:, end_row_index:)
    source_row = worksheet[SOURCE_ROW_INDEX]

    (start_row_index..end_row_index).each do |row_index|
      next if row_index == SOURCE_ROW_INDEX

      source_row.cells.each_with_index do |source_cell, col_index|
        next unless source_cell

        target_cell = worksheet.add_cell(row_index, col_index)
        target_cell.style_index = source_cell.style_index
      end
    end
  end

  # 元ファイルの別シートに予め用意された入力規則用データを特定セルに適用
  def set_data_validations(worksheet:, start_row_index:, end_row_index:)
    validations_config = [
      { column: 6, formula: '=Sheet2!$A$1:$A$6' },  # G列
      { column: 7, formula: '=Sheet2!$B$1:$B$99' }, # H列
      { column: 8, formula: '=Sheet2!$C$1:$C$99' }  # I列
    ]

    validations = RubyXL::DataValidations.new

    validations_config.each do |config|
      validations << RubyXL::DataValidation.new(
        sqref: RubyXL::Reference.new(start_row_index, end_row_index, config[:column], config[:column]),
        formula1: RubyXL::Formula.new(expression: config[:formula]),
        type: 'list',
        error_style: 'stop',
        allow_blank: true,
        show_error_message: true
      )
    end

    worksheet.data_validations = validations
  end

  def put_rows_data(worksheet:, start_row_index:)
    @rows_data.each_with_index do |row_data, index|
      row_index = start_row_index + index
      worksheet[row_index][1].change_contents(row_data.nullable_value) if row_data.nullable_value
      worksheet[row_index][2].change_contents(row_data.id)
      worksheet[row_index][3].change_contents("#{row_data.name}さん")
      worksheet[row_index][4].change_contents("#{row_data.age}歳")
    end
  end
end
```
```xx_controller.rb
  def download_template
    template_generator = TemplateGenerateService.new(
      header_data: fetch_header_data(params),
      rows_data: fetch_rows_data(params),
    )

    template_data = template_generator.generate

    file_ext = template_generator.file_extension
    out_file_name = "編集済みファイル#{file_ext}"
    mime_type = Rack::Mime.mime_type(file_ext)

    send_data template_data, type: mime_type, filename: out_file_name, disposition: 'attachment'
  end
```
## フロントエンド
一部コード抜粋
```front.vue
<script setup lang="ts">
const onClickDownload = async () => {
  downloading.value = true;
  const res = await aspida(initAxios(), { timeout: 60000, baseURL: process.env.VUE_APP_API_URL }).template.get({ query });
  const contentDisposition = res.headers["content-disposition"];
  const contentType = res.headers["content-type"];
  const contentFileName = contentDisposition?.match(/filename\*=UTF-8''(.+)/);
  if (!contentFileName) throw new Error("ファイル名が取得できないのは想定外");
  const encodedFileName = contentFileName[1];
  if (!encodedFileName) throw new Error("ファイル名が取得できないのは想定外");
  const fileName = decodeURIComponent(encodedFileName);
  const blob = new Blob([res.body], { type: contentType });
  saveAs(blob, fileName);
  downloading.value = false;
};
</script>
```