1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-04-04

1. はじめに

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

目次

2. excelizeのメソッド

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

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

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

2-1. ファイルの操作

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

f := excelize.NewFile()

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

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

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

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

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

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

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

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

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

defer f.Close()

2-2. ワークシートの操作

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

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

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

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

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

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

2-3. セルの操作

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

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

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

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

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だけで事足りると思います。

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

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

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

2-4. セルのスタイル

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

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

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

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

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

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

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

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

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},
}

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

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

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

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

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

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を使いました。


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

共通化コード
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のキー部分に、ヘッダーなら列名を、テーブル行なら実際に入力された値を設定しているため共通化できています。

3. excel2pdfでpdfに

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

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

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

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

4. pdfファイルをechoで返す

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

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ファイルは生のファイルであるため、もう一段ファイルをバイト列にする操作が必要でした。

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

return pdfData, err

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

5. おわりに

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

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?