【Golang】伝票フォーマットのエクセルファイルを作ってpdfにするまで

# 1. はじめに
今回、開発の要件の関係上Golangでエクセルファイルを扱う機会がありました。既存のファイルを操作するだけでなくファイルを一から作るコードまで書いたので、忘備録も兼ねて概要をまとめておこうと思います。
意外に、一番苦戦したのはpdfにするところです。"excel2pdf"というパッケージは現在（2025/4/5）のところ二つあるのですが、今回は`github.com/user0608/excel2pdf`の方を使いました。

# 目次
- [1. はじめに](#1-はじめに)
- [2. excelizeのメソッド](#2-excelizeのメソッド)
    - [2-1. ファイルの操作](#2-1-ファイルの操作)
    - [2-2. ワークシートの操作](#2-2-ワークシートの操作)
    - [2-3. セルの操作](#2-3-セルの操作)
    - [2-4. セルのスタイル](#2-4-セルのスタイル)
    - [2-5. 実際どんなのを作ったのか](#2-5-実際どんなのを作ったのか)
- [3. excel2pdfでpdfに](#3-excel2pdfでpdfに)
- [4. pdfファイルをechoで返す](#4-pdfファイルをechoで返す)
- [5. おわりに](#5-おわりに)

# 2. excelizeのメソッド
excelizeはメソッドを豊富に備えています。ドキュメントも充実しており、公式ドキュメントの目次を見るだけでも何ができるかなんとなくわかることでしょう。

ファイルを作って一から伝票形式のワークシートを作ったのですが、そこで使ったメソッドでも全体のほんの一部でした。もっと豊富な機能を知りたいという方は、ぜひ公式ドキュメントを探索することをお勧めします。

この記事では今回のプロダクトで使った（そしてもしエクセルファイルを操作するならよく使うであろう）メソッドに絞って整理します。

## 2-1. ファイルの操作

ファイルを作成する際は以下のようにコードを実行します。

```golang
f := excelize.NewFile()
```

もし既存のエクセルファイルがあってそこから読み取る場合には別のメソッドを用います。

```golang
file, err := excelize.OpenFile("../template/format.xlsx")
if err != nil {
	return err
}
```

保存したいときのメソッドは二種類あります。
もし、名前付きで新規に保存したい場合や、既存のファイルを上書きせずに保存したい場合はSaveAsメソッドを用います。

```golang
if err := excelFile.SaveAs("./file.xlsx"); err != nil {
	return err
}
```

既存のファイルがあって、上書き保存したい場合はSaveメソッドを用います。
ローカルのどこかにファイルがある前提なので名前やパスは必要ありません。

```golang
if err := excelFile.Save(); err != nil {
	return err
}
```

バッファに変換してファイルを保持したい場合、その専用のメソッドがあります。

```golang
buf, err := file.WriteToBuffer()
if err != nil {
	return err
}
```

ファイルは使い終わったら確実にリソースが解放されるように、Closeメソッドを実行しておきましょう。

```golang
defer f.Close()
```

## 2-2. ワークシートの操作
ファイルを新規作成した際、デフォルトでSheet1は作成されています。それ以外のワークシートを作成したい場合、ワークシート名前を指定して以下のメソッドを実行します。

```golang
if _, err := f.NewSheet(sheetName); err != nil {
	return err
}
```

このメソッドの一つ目の返り値は、作成されたワークシートのindex(int型)を返します。新しくワークシートを作りたいだけなら特に使うこともないので無視することが多いかなと思います。

もしワークシートを消したかったら以下のメソッドを実行します。

```golang
if err := f.DeleteSheet("Sheet1"); err != nil {
    return err
}
```

このほかにも、ワークシートをコピーしたり動かしたりなど様々な機能がありますが、とりあえずはこの二つのメソッドの紹介にとどめておきます。

## 2-3. セルの操作

セルの値を取得する場合は以下のメソッドを実行します。

```golang
value, err := f.GetCellValue(sheetName, "A1")
```

取得したいセルを指定する際は必ず「列」＋「行」の形で指定してください。確か、Pythonでexcelを扱った時は色々な指定方法があった気もしますが、excelizeでは列+行の形で統一されています。

もし値を設定したい場合は以下のメソッドを実行します。

```golang
if err := f.SetCellValue(SheetName, "A1", "My First Excel Worksheet"); err != nil {
    return err
}
```

string, int, float, bool, []byte, time.Timeなどなど、あらかた値を設定できます。SetCellStr, SetCellInt, SetCellBoolなど型を指定してセルに値を入れられるメソッドもいくつかありますが、おそらくSetCellValueだけで事足りると思います。


セルをマージしたいときは、左上と右下を指定してマージしてあげます。

```golang
if err := f.MergeCell(sheetName, "A1", "C2"); err != nil {
	return err
}
```

上の場合は、A列目からC列目、一行目から二行目までの、計六つのセルをマージしたことになります。

## 2-4. セルのスタイル
スタイルを適用させたいときは以下のようにします。

```golang
if err = f.SetCellStyle(sheetName, "A1", "B1", style); err != nil {
	return
}
```

なお、styleはint型の数字です。
四つ目の引数の値は予めスタイルと数字が対応しているわけではなく、excelizeによって数字で表されるスタイルを作って渡す必要があります。

例えば、下線を引く時を想定しましょう。

```golang
underlineFont := &excelize.Font{
	Underline: "single",
}

underline, err := f.NewStyle(&excelize.Style{Font: underlineFont})
if err != nil {
	return err
}
```

このunderlineがf.MergeCellの第四引数に渡す値になります。
excelize.Style構造体に様々なスタイルの値を、時には複数渡すことができます。

・フォントのサイズや太さを変えるとき

```golang
fontSize := 28
largeBoldFont := &excelize.Font{
	Bold: true,
	Size: float64(fontSize),
}
```

・枠線を作るとき

```golang
border := []excelize.Border{
	{Type: "left", Style: 1},
	{Type: "right", Style: 1},
	{Type: "top", Style: 1},
	{Type: "bottom", Style: 1},
}
```

・値の位置を調整するとき

```golang
alignment := &excelize.Alignment{
	Horizontal: "center",
	Vertical:   "center",
}
```

・複数のスタイルを一緒に適用するとき

```golang
border := []excelize.Border{
	{Type: "left", Style: 1},
	{Type: "right", Style: 1},
	{Type: "top", Style: 1},
	{Type: "bottom", Style: 1},
}
alignment := excelize.Alignment{
	Horizontal: "left",
	Vertical:   "top",
}

style, err := f.NewStyle(&excelize.Style{Border: border, Alignment: &alignment})
if err != nil {
	return err
}
```

# 2-5. 実際どんなのを作ったのか

実際に作ったコードをお見せします。
このコードを動かした時の完成画面はこのようになっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2826164/69f99b38-e92c-416e-b7a6-c8ab590ae7ac.png)

以下がこの見た目をエクセルで作るために実装したコードです。

```golang
func (r *receiptUsecase) GetReceiptPDFByFiscalYear(ctx context.Context, fiscalYear int) (pdfData []byte, errs []error) {
	startDate := time.Date(fiscalYear, 4, 1, 0, 0, 0, 0, time.Local)
	endDate := time.Date(fiscalYear+1, 4, 1, 0, 0, 0, 0, time.Local).Add(-time.Second)
	filter := bson.D{{"date", bson.D{{"$gte", startDate}, {"$lt", endDate}}}}
	opts := options.Find().SetSort(bson.D{{"date", 1}})

	receipts, err := r.receiptRepository.GetReceipts(ctx, filter, opts)
	if err != nil {
		errs = append(errs, err)

		return nil, errs
	}

	excelFile := excelize.NewFile()
	excelFile.DeleteSheet("Sheet1")
	defer func() {
		if err = excelFile.Close(); err != nil {
			errs = append(errs, err)
		}
	}()

	sheetIndex := 1
	var sheetName string
	var rowIndex int
	var date string

	for _, receipt := range receipts {
		if date == "" {
			date = receipt.Date.Format("2006/01/02")

			sheetName, err = createNewSheet(excelFile, date, sheetIndex)
			if err != nil {
				errs = append(errs, err)

				return nil, errs
			}
		}

		if date != receipt.Date.Format("2006/01/02") {
			date = receipt.Date.Format("2006/01/02")

			sheetIndex++
			rowIndex = 0

			sheetName, err = createNewSheet(excelFile, date, sheetIndex)
			if err != nil {
				errs = append(errs, err)

				return nil, errs
			}
		}

		steps := 2
		initialRow := 8
		writtenRow := initialRow + rowIndex*steps

		if err = setTableRow(excelFile, receipt, writtenRow, sheetName); err != nil {
			errs = append(errs, err)

			return nil, errs
		}

		rowIndex++
	}

	buf, err := file.WriteToBuffer()
    if err != nil {
        return nil, err
    }

    pdfData = buf.Bytes()
    
	return pdfData, errs
}

func createNewSheet(file *excelize.File, date string, sheetIndex int) (string, error) {
	sheetName := fmt.Sprintf("Sheet%d", sheetIndex)
	if _, err := file.NewSheet(sheetName); err != nil {
		return "", err
	}

	if err := setOverallLayout(file, sheetIndex, sheetName, date); err != nil {
		return "", err
	}

	return sheetName, nil
}

// contains multiple helper functions
func setOverallLayout(file *excelize.File, index int, sheetName, date string) (err error) {
	// styling
	underlineFont := &excelize.Font{
		Underline: "single",
	}

	alignment := &excelize.Alignment{
		Horizontal: "center",
		Vertical:   "center",
	}

	fontSize := 28
	largeBoldFont := &excelize.Font{
		Bold: true,
		Size: float64(fontSize),
	}

	border := []excelize.Border{
		{Type: "left", Style: 1},
		{Type: "right", Style: 1},
		{Type: "top", Style: 1},
		{Type: "bottom", Style: 1},
	}

	// style apply
	underline, err := file.NewStyle(&excelize.Style{Font: underlineFont})
	if err != nil {
		return err
	}

	title, err := file.NewStyle(&excelize.Style{Alignment: alignment, Font: largeBoldFont})
	if err != nil {
		return err
	}

	header, err := file.NewStyle(&excelize.Style{Alignment: alignment, Border: border})
	if err != nil {
		return err
	}

	// edit cells

	// receipt number
	if err = setIndexCells(file, sheetName, index, underline); err != nil {
		return err
	}

	// date
	if err = setDateCells(file, sheetName, date, underline); err != nil {
		return err
	}

	// title
	if err = setTitleCells(file, sheetName, title); err != nil {
		return err
	}

	// header of table
	if err = setTableHeaderCells(file, sheetName, header); err != nil {
		return err
	}

	return nil
}

func setIndexCells(file *excelize.File, sheetName string, index, underline int) (err error) {
	// receipt number
	if err = file.MergeCell(sheetName, "A1", "B1"); err != nil {
		return
	}
	if err = file.SetCellValue(sheetName, "A1", fmt.Sprintf("伝票No.%d", index)); err != nil {
		return
	}
	if err = file.SetCellStyle(sheetName, "A1", "B1", underline); err != nil {
		return
	}

	return nil
}

func setDateCells(file *excelize.File, sheetName, date string, underline int) (err error) {
	if err = file.MergeCell(sheetName, "G1", "H1"); err != nil {
		return
	}
	if err = file.SetCellValue(sheetName, "H1", date); err != nil {
		return
	}
	if err = file.SetCellStyle(sheetName, "G1", "H1", underline); err != nil {
		return
	}

	return nil
}

func setTitleCells(file *excelize.File, sheetName string, title int) (err error) {
	if err = file.MergeCell(sheetName, "C2", "F4"); err != nil {
		return
	}
	if err = file.SetCellValue(sheetName, "C2", "入金伝票"); err != nil {
		return
	}
	if err = file.SetCellStyle(sheetName, "C2", "F4", title); err != nil {
		return
	}

	return nil
}

func setTableHeaderCells(file *excelize.File, sheetName string, header int) (err error) {
	cellRanges := map[string][2]string{"科目": {"A", "B"}, "": {"C", "C"}, "金額": {"D", "E"}, "摘要": {"F", "H"}}

	for head, cellRange := range cellRanges {
		start, end := cellRange[0], cellRange[1]
		headerRow := 7
		startCell := start + strconv.Itoa(headerRow)
		endCell := end + strconv.Itoa(headerRow)
		if err = file.MergeCell(sheetName, startCell, endCell); err != nil {
			return
		}
		if err = file.SetCellValue(sheetName, startCell, head); err != nil {
			return
		}
		if err = file.SetCellStyle(sheetName, startCell, endCell, header); err != nil {
			return
		}
	}

	return nil
}

// contains functions to assemble table rows
func setTableRow(file *excelize.File, receipt domain.Receipt, writtenRow int, sheetName string) (err error) {
	if err = setTableRowStyles(file, writtenRow, sheetName); err != nil {
		return
	}

	if err = setTableRowValues(file, receipt, sheetName, writtenRow); err != nil {
		return
	}

	return nil
}

func setTableRowStyles(file *excelize.File, writtenRow int, sheetName string) (err error) {
	border := []excelize.Border{
		{Type: "left", Style: 1},
		{Type: "right", Style: 1},
		{Type: "top", Style: 1},
		{Type: "bottom", Style: 1},
	}
	alignment := excelize.Alignment{
		Horizontal: "left",
		Vertical:   "top",
	}

	style, err := file.NewStyle(&excelize.Style{Border: border, Alignment: &alignment})
	if err != nil {
		return err
	}

	cellRanges := [4][2]string{{"A", "B"}, {"C", "C"}, {"D", "E"}, {"F", "H"}}
	for _, cellRange := range cellRanges {
		start, end := cellRange[0], cellRange[1]

		startCell := start + strconv.Itoa(writtenRow)
		endCell := end + strconv.Itoa(writtenRow+1)

		if err = file.MergeCell(sheetName, startCell, endCell); err != nil {
			return err
		}
		if err = file.SetCellStyle(sheetName, startCell, endCell, style); err != nil {
			return err
		}
	}

	return nil
}

func setTableRowValues(file *excelize.File, receipt domain.Receipt, sheetName string, writtenRow int) (err error) {
	var category string
	if receipt.Category != nil {
		category = *receipt.Category
	}

	var sign string
	if *receipt.IsExpense {
		sign = "-"
	} else {
		sign = "+"
	}

	var money int
	if receipt.Money != nil {
		money = *receipt.Money
	}

	var content string
	if receipt.Content != nil {
		content = *receipt.Content
	}

	if err = file.SetCellValue(sheetName, fmt.Sprintf("A%d", writtenRow), category); err != nil {
		return err
	}
	if err = file.SetCellValue(sheetName, fmt.Sprintf("C%d", writtenRow), sign); err != nil {
		return err
	}
	if err = file.SetCellValue(sheetName, fmt.Sprintf("D%d", writtenRow), money); err != nil {
		return err
	}
	if err = file.SetCellValue(sheetName, fmt.Sprintf("F%d", writtenRow), content); err != nil {
		return err
	}

	return nil
}
```

ヘルパー関数が多くかなり大掛かりなメソッドになってしまいましたが、一からファイルを作っているのでこんなもんだと思います。

伝票は一日ごとに一つのワークブックにまとめているため、日付が変わるまで行ごとに値をセットしていき、日付が変わったらまた一から値を行ごとにセットしていくようにしています。
これは値が日付順になっていることを想定しているため、もしそうでない場合は別途日付順でデータをまとめた配列を作っておかないといけないでしょう。

また、GetReceiptPDFByFiscalYearではdeferしたメソッドのエラーの返り値を適切に処理するために名前付き帰り値を利用しています。return文が実行された際、順次deferされていたメソッドが実行されますが、もしそのエラーが発生したら返り値として渡されるerrsがアクセスされ、新しくエラーが追加されるような形です。

なお、このプロダクトではmongodbを使いました。

<br \>

※追記
上のコードは部分的に共通化できます。
セルのスタイルとマージ、値の入力を一個の関数の中にまとめたり、テーブルのヘッダーと行の設定を共通化したりしました。
どんな風にまとめられるかは、実装に依るとは思います。

<details><summary>共通化コード</summary>

```golang
func SetSingleValueToMergedCell(file *excelize.File, sheetName, startCell, endCell string, style int, value any) (err error) {
	if err = file.MergeCell(sheetName, startCell, endCell); err != nil {
		return
	}
	if err = file.SetCellValue(sheetName, startCell, value); err != nil {
		return
	}
	if err = file.SetCellStyle(sheetName, startCell, endCell, style); err != nil {
		return
	}

	return nil
}

func SetTableRow(file *excelize.File, sheetName string, style, rowIndex, rowRange int, rowValueRangeMap map[string][2]string) (err error) {
	for value, cellRange := range rowValueRangeMap {
		start, end := cellRange[0], cellRange[1]
		startCell := start + strconv.Itoa(rowIndex)
		endCell := end + strconv.Itoa(rowIndex+rowRange)
		if err = SetSingleValueToMergedCell(file, sheetName, startCell, endCell, style, value); err != nil {
			return
		}
	}

	return nil
}
```

テーブルヘッダーとテーブル行の値が同じ関数で設定できるのがミソです。これは、rowValueRangeMapのキー部分に、ヘッダーなら列名を、テーブル行なら実際に入力された値を設定しているため共通化できています。

</details>


# 3. excel2pdfでpdfに

pdf化は、パッケージを使えば簡単に行けました。
ただし、このパッケージは最近出たばかりのものでまだ安定板でもないようなので、本格的なプロダクトの開発にはまだ用いない方がよいかもしれません。

```golang
pdfFilePath, err := excel2pdf.ConvertExcelToPdf("./file.xlsx")
if err != nil {
	return err
}
```

なお、pdf化させるメソッドの返り値はファイルへのパスであるため、ファイルを開きたい場合は組み込みのos.Openメソッドを使う必要があります。

```golang
pdfFile, err := os.Open(pdfFilePath)
if err != nil {
	return err
}
```

# 4. pdfファイルをechoで返す
最後に、pdfファイルをどのようにしてechoで返すことができるのかをお示しして終わろうと思います。

```golang
func (c *ReceiptController) GetPdfByFiscalYear(e echo.Context) error {
	ctx := e.Request().Context()
	fiscalYear, err := strconv.Atoi(e.Param("year"))
	if err != nil {
		return e.String(http.StatusBadRequest, "不正なリクエストです")
	}

	pdf, errs := c.receiptUsecase.GetReceiptPDFByFiscalYear(ctx, fiscalYear)
	if len(errs) > 0 {
		err = errors.Join(errs...)
		fmt.Println(err)

		return e.String(http.StatusBadRequest, "PDFを送信できませんでした")
	}

	e.Response().Header().Set(echo.HeaderContentDisposition, "attachment; filename=receipts.pdf")
	e.Response().Header().Set(echo.HeaderContentType, "application/pdf")

	return e.Blob(http.StatusOK, "application/pdf", pdf)
}
```

ここではBlobメソッドを用いています。このため、第三引数はバイト列である必要があります。
excel2pdfで生成し、os.Openメソッドで取得したpdfファイルは生のファイルであるため、もう一段ファイルをバイト列にする操作が必要でした。

```golang
pdfData, err = io.ReadAll(pdfFile)
if err != nil {
	return nil, err
}

return pdfData, err
```

第四節のpdfFileをio.ReadAllメソッドでバイト列にします。
この値を返してインターフェース層で受け取れば、そのままBlobで返せるという寸法です。

# 5. おわりに
今回はエクセルをgolangで扱う方法についてまとめましたが、その周辺で必要となる機能まである程度包括的にまとめられたのでよかったです。

もし、もっと詳しくパッケージの説明を探したい方は、ぜひ公式ドキュメントにあたってみてください。


https://xuri.me/excelize/en/