【C#】QuestPDFで請求書作ってみたら簡単すぎた!

技術

C#のPDF事情

かつて(15年ほど前の新人時代のことだけど)、C#やVB.NETで帳票周りをよくやっていた時期があります。

当時よく使っていたのは、Crystal ReportsActiveReportsSVFといったあたりのツールでしたが、各ツールにはそれぞれに特徴や癖があり、ツールを変えるたびに学習コストがかかっていて辟易した記憶がありますww

その後、たまたま帳票づくりの機会のない時期がしばらく続いた後、数年前に久しぶりにC#で帳票(PDF)づくりにかかわる機会がありました。

その時に使ったのはPDFSharpというライブラリでした。
なかなか使い勝手が良くて、いいライブラリでしたが、日本語が使えるようになるまでが少し面倒でした。

そんな中「他にいいライブラリがないかな」と探していて見つけたのがQuestPDFというライブラリです。

QuestPDFのインストールから

今回は.NET6でQuestPDFを触ってみようと思います。
例によって、環境はDockerコンテナ上に作ります。

docker run -itd --name questpdf_dev mcr.microsoft.com/dotnet/sdk:6.0

このコマンドで.NET6インストール済みのコンテナができたら、毎度お馴染みのVSCodeのRemote Development拡張でコンテナにアクセスします。

コンテナ内のOSと.NETのバージョンはこんな感じです。

cat /etc/issue
> Debian GNU/Linux 11 \n \l
dotnet --version
> 6.0.401

ということで、さっそく.NETプロジェクトを作成します。
今回はコンソールアプリにしました。

cd /usr/src
dotnet new console -n sample
cd ./sample

私の場合は「/usr/src」ディレクトリにsampleプロジェクトを作成したので、以降のソースコードはこの「/usr/src/sample」をルートディレクトリとして読んでください。

プロジェクトができたら、続いてQuestPDfを参照追加しましょう。

dotnet add package QuestPDF
dotnet add package SkiaSharp.NativeAssets.Linux.NoDependencies
dotnet add package HarfBuzzSharp.NativeAssets.Linux

1行目のコマンドで「QuestPDF」を参照追加しています。
2行目、3行目のコマンドはQuestPDFが参照しているパッケージを追加しています。
ないとエラーになるので追加しました。

サンプルPDFを作成

後はQuestPDFの公式ページからサンプルコードをコピペして実行してみます。

using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;

// code in your main method
Document.Create(container =>
{
    container.Page(page =>
    {
        page.Size(PageSizes.A4);
        page.Margin(2, Unit.Centimetre);
        page.PageColor(Colors.White);
        page.DefaultTextStyle(x => x.FontSize(20));
        
        page.Header()
            .Text("Hello PDF!")
            .SemiBold().FontSize(36).FontColor(Colors.Blue.Medium);
        
        page.Content()
            .PaddingVertical(1, Unit.Centimetre)
            .Column(x =>
            {
                x.Spacing(20);
                
                x.Item().Text(Placeholders.LoremIpsum());
                x.Item().Image(Placeholders.Image(200, 100));
            });
        
        page.Footer()
            .AlignCenter()
            .Text(x =>
            {
                x.Span("Page ");
                x.CurrentPageNumber();
            });
    });
})
.GeneratePdf("hello.pdf");

あっという間にPDFができてしまいました。
もちろんちゃんと公式ページで紹介されている通りのPDFが出来上がりました。

日本語を使うのも簡単!

次は日本語を出力してみましょう。

私が今回利用しているコンテナでは日本語フォントがインストールされていません。
というわけで、フォントをダウンロードしておきます。
源ノ角ゴシックと源ノ明朝というフリーフォントをダウンロードしました。

wget https://github.com/adobe-fonts/source-han-sans/raw/release/Variable/TTF/SourceHanSans-VF.ttf
wget https://github.com/adobe-fonts/source-han-serif/raw/release/Variable/TTF/SourceHanSerif-VF.ttf

ダウンロードしたフォントファイルは下記のようにsample/fontsディレクトリへ移動しておきます。

では先程のサンプルを日本語に編集しましょう。

using QuestPDF.Drawing;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;

FontManager.RegisterFont(File.OpenRead("/usr/src/sample/fonts/SourceHanSans-VF.ttf"));
FontManager.RegisterFont(File.OpenRead("/usr/src/sample/fonts/SourceHanSerif-VF.ttf"));

var loremIpsum = "痛み自体は非常に重要で、その後にアディピシジレットが続きますが、多くの仕事と痛みがあるときに起こります。"
    + "実際、少なくとも、なんらかの利益が得られない限り、いかなる種類の仕事も行うべきではありません。"
    + "疑念を抱くか否認するかは、痛みが快楽に表れていることを彼は望んでいる。"
    + "欲望に目がくらんで出て行かないのでなければ、彼らはソフト、つまり労働であるために義務を放棄するのは過ちです。";

// code in your main method
Document.Create(container =>
{
    container.Page(page =>
    {
        page.Size(PageSizes.A4);
        page.Margin(2, Unit.Centimetre);
        page.PageColor(Colors.White);
        page.DefaultTextStyle(x => {
             return x
                .FontFamily("Source Han Sans VF")
                .FontSize(20);
        });
        
        page.Header()
            .Text("こんにちは PDF!")
            .SemiBold().FontSize(36).FontColor(Colors.Blue.Medium);
        
        page.Content()
            .PaddingVertical(1, Unit.Centimetre)
            .Column(x =>
            {
                x.Spacing(20);
                
                x.Item().Text(loremIpsum).FontFamily("Source Han Serif VF");
                x.Item().Image(Placeholders.Image(200, 100));
            });
        
        page.Footer()
            .AlignCenter()
            .Text(x =>
            {
                x.Span("ページ ");
                x.CurrentPageNumber();
            });
    });
})
.GeneratePdf("hello.pdf");

日本語フォントの導入も簡単にできましたね。

FontManager.RegisterFont(File.OpenRead("/usr/src/sample/fonts/SourceHanSans-VF.ttf"));
FontManager.RegisterFont(File.OpenRead("/usr/src/sample/fonts/SourceHanSerif-VF.ttf"));

この2行でフォントファイルを読み込んでいます。
読み込んだフォントは下記のようにして使います。

// デフォルトフォント(源ノ角ゴシック)
page.DefaultTextStyle(x => {
    return x
        .FontFamily("Source Han Sans VF")
        .FontSize(20);
});
// 源ノ明朝
x.Item().Text(loremIpsum).FontFamily("Source Han Serif VF");

請求書を作ってみました

最後にQuestPDFでこんな感じの請求書を作ってみたので、そちらをご紹介します。

ソースコードはこんな感じです。

using QuestPDF.Drawing;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;

FontManager.RegisterFont(File.OpenRead("/usr/src/sample/fonts/SourceHanSans-VF.ttf"));
FontManager.RegisterFont(File.OpenRead("/usr/src/sample/fonts/SourceHanSerif-VF.ttf"));

// 請求書データ生成
var billingData = DateTime.Now.AddDays(-DateTime.Today.Day);
var payDate = DateTime.Now.AddMonths(1);
payDate = payDate.AddDays(-payDate.Day);

var docNumber = "220934";

var postCode = "540-0002";
var address = "大阪府大阪市中央区大阪城 88-88 大阪テキトウビル 12F";
var company = "株式会社ああいいううええおお";

var sPostCode = "540-0002";
var sAddress1 = "大阪府大阪市中央区大阪城 99-99";
var sAddress2 = "大阪城ビル 2F";
var sCompany = "システムクラフト";
var sPayee = "しすくら銀行(000x) 大阪支店(123) 普通 1234789\r\n口座名義 システム タロウ";

var data = new List<(string name, int count, string unit, int unitAmount, int tax, bool expenses)> {
    ("システム開発支援作業", 1, "式", 240000, 10, false),
    ("システム開発支援作業", 1, "式", 100000, 8, false),
    ("出張旅費", 1, "日", 52000, 10, true)
};
// 金額を計算しておく
var totalTax10 = data.Where(d => d.tax == 10).Sum(d => d.unitAmount * d.count);
var totalTax8 = data.Where(d => d.tax == 8).Sum(d => d.unitAmount * d.count);
var totalExp = data.Where(d => d.expenses).Sum(d => d.unitAmount * d.count);
var totalExpTax = data.Where(d => d.expenses).Sum(d => d.unitAmount * d.count * (d.tax * 0.01 + 1));
var toalAll = totalTax10 * 1.1 + totalTax8 * 1.08; 

// ドキュメント生成
Document.Create(container => {
    container.Page(page => {
        page.Size(PageSizes.A4);
        page.MarginHorizontal(2f, Unit.Centimetre);
        page.MarginVertical(1f, Unit.Centimetre);
        page.DefaultTextStyle(x => {
             x = x.FontFamily("Source Han Sans VF");
             x = x.FontSize(10);
             return x;
        });

        page.Header()
            .Column(column1 => {
                column1.Item().Row(row => {
                    row.RelativeItem().Column(column => {
                        column.Item().Text($"〒{postCode}");
                        column.Item().Text(address).Bold();
                    });
                    row.ConstantItem(100).Column(column => {
                        column.Item().AlignCenter().Text($"No.{docNumber}");
                        column.Item().LineHorizontal(0.5f);
                        column.Item().AlignCenter().Text(billingData.ToString("yyyy年MM月dd日"));
                    });
                });
                column1.Item().Row(row => {
                    row.RelativeItem().PaddingTop(0.3f, Unit.Centimetre).Column(column => {
                        column.Item().Text(company).FontFamily("Source Han Serif VF").FontSize(15f);
                        column.Item().LineHorizontal(1f);
                    });
                    row.ConstantItem(220).PaddingTop(0.5f, Unit.Centimetre).Column(column => {
                        column.Item().PaddingLeft(0.2f, Unit.Centimetre).Text("御中");
                    });
                });
                column1.Item().Row(row => {
                    row.RelativeItem();
                    row.ConstantItem(180).Column(column => {
                        column.Item().Text($"〒{sPostCode}").FontSize(10);
                        column.Item().Text(sAddress1).FontSize(10);
                        column.Item().Text(sAddress2).FontSize(10);
                        column.Item().Text(sCompany).FontSize(10);
                    });
                });
                column1.Item().PaddingTop(0.5f, Unit.Centimetre).Row(row => {
                    row.RelativeItem();
                    row.ConstantItem(250).Background(Colors.Grey.Medium).Column(column => {
                        column.Item().AlignCenter().Text("御 請 求 書").Bold().FontSize(18).FontColor(Colors.White);
                    });
                    row.RelativeItem();
                });
                column1.Item().PaddingTop(2).Row(row => {
                    row.RelativeItem().Text("下記の通りご請求申し上げます。");
                });
                column1.Item().PaddingTop(-3).Row(row => {
                    row.RelativeItem().AlignBottom().Column(column => {
                        column.Item().Text("合計金額");
                    });
                    row.RelativeItem().AlignRight().AlignBottom().Text($"¥{toalAll.ToString("n0")}-").FontSize(18).FontFamily("Source Han Serif VF");
                    row.ConstantItem(50);
                    row.ConstantItem(150).PaddingLeft(1, Unit.Centimetre).Column(column => {
                        column.Item().Row(row1 => {
                            row1.ConstantItem(40).Border(1).AlignCenter().PaddingVertical(2).Text("締日").FontSize(10);
                            row1.RelativeItem().Border(1).AlignCenter().Text("お支払期限").FontSize(10);
                        });
                        column.Item().Row(row1 => {
                            row1.ConstantItem(40).Border(1).AlignCenter().PaddingVertical(2).Text("末日").FontSize(10);
                            row1.RelativeItem().Border(1).AlignCenter().Text(payDate.ToString("yyyy/MM/dd")).FontSize(10);
                        });
                    });
                });
                column1.Item().PaddingTop(1).Row(row => { row.ConstantItem(280).LineHorizontal(2); });
                column1.Item().PaddingTop(2).Row(row => {
                    row.ConstantItem(30).PaddingTop(10).Text("振込先").FontSize(9);
                    row.RelativeItem().PaddingTop(10).Border(1).Column(column => {
                        column.Item().Row(r => r.RelativeItem().Height(28).AlignMiddle().PaddingLeft(2).Text(sPayee).FontSize(9));
                    });
                    row.ConstantItem(30);
                    row.ConstantItem(45).Height(48).Border(1);
                    row.ConstantItem(45).Height(48).Border(1);
                    row.ConstantItem(45).Height(48).Border(1);
                });
                // 明細部
                column1.Item().PaddingTop(2).Row(row => {
                    row.RelativeItem().Border(1).Height(20).AlignMiddle().AlignCenter().Text("件名").FontSize(10);
                    row.ConstantItem(35).Border(1).AlignMiddle().AlignCenter().Text("数量").FontSize(10);
                    row.ConstantItem(30).Border(1).AlignMiddle().AlignCenter().Text("単位").FontSize(10);
                    row.ConstantItem(60).Border(1).AlignMiddle().AlignCenter().Text("単価").FontSize(10);
                    row.ConstantItem(60).Border(1).AlignMiddle().AlignCenter().Text("金額").FontSize(10);
                    row.ConstantItem(60).Border(1).AlignMiddle().AlignCenter().Text("消費税").FontSize(10);
                });
                column1.Item().PaddingTop(2).Row(row => { row.RelativeItem().LineHorizontal(1); });
                for (var i = 0; i < 13; i++) {
                    (string name, int count, string unit, int unitAmount, int tax, bool expenses)? record = data.Count > i ? data[i] : null;
                    column1.Item().Row(row => {
                        row.RelativeItem().Border(1).BorderHorizontal(0.5f).Column(column => {
                            column.Item().Row(r => r.RelativeItem().Height(28).AlignMiddle().PaddingLeft(2).Text($"{record?.name}").FontSize(10));
                        });
                        row.ConstantItem(35).Border(1).BorderHorizontal(0.5f).AlignMiddle().AlignCenter().Text($"{record?.count}").FontSize(10);
                        row.ConstantItem(30).Border(1).BorderHorizontal(0.5f).AlignMiddle().AlignCenter().Text($"{record?.unit}").FontSize(10);
                        row.ConstantItem(60).Border(1).BorderHorizontal(0.5f).Column(column => {
                            column.Item().Row(r => r.RelativeItem().Height(28).AlignMiddle().AlignRight().PaddingRight(2).Text(record == null ? "" : $"¥{record?.unitAmount.ToString("n0")}-").FontSize(10));
                        });
                        row.ConstantItem(60).Border(1).BorderHorizontal(0.5f).Column(column => {
                            column.Item().Row(r => r.RelativeItem().Height(28).AlignMiddle().AlignRight().PaddingRight(2).Text(record == null ? "" : $"¥{(record?.unitAmount * record?.count)?.ToString("n0")}-").FontSize(10));
                        });
                        row.ConstantItem(60).Border(1).BorderHorizontal(0.5f).AlignMiddle().AlignCenter().Text(record == null ? "" : $"対象({record?.tax}%)").FontSize(10);
                    });
                }
                column1.Item().Row(row => {
                    row.RelativeItem().BorderTop(1).Height(28);
                    row.ConstantItem(125).Border(1).AlignMiddle().AlignCenter().Text("(10%対象)").FontSize(10);
                    row.ConstantItem(60).Border(1).Column(column => {
                        column.Item().Row(r => r.RelativeItem().Height(28).AlignMiddle().AlignRight().PaddingRight(2).Text($"¥{totalTax10.ToString("n0")}-").FontSize(10));
                    });
                    row.ConstantItem(60).Border(1).Column(column => {
                        column.Item().Row(r => r.RelativeItem().Height(28).AlignMiddle().AlignRight().PaddingRight(2).Text($"¥{(totalTax10 * 1.1).ToString("n0")}-").FontSize(10));
                    });
                });
                column1.Item().Row(row => {
                    row.RelativeItem().Height(28);
                    row.ConstantItem(125).Border(1).AlignMiddle().AlignCenter().Text("(8%対象)").FontSize(10);
                    row.ConstantItem(60).Border(1).Column(column => {
                        column.Item().Row(r => r.RelativeItem().Height(28).AlignMiddle().AlignRight().PaddingRight(2).Text($"¥{totalTax8.ToString("n0")}-").FontSize(10));
                    });
                    row.ConstantItem(60).Border(1).Column(column => {
                        column.Item().Row(r => r.RelativeItem().Height(28).AlignMiddle().AlignRight().PaddingRight(2).Text($"¥{(totalTax8 * 1.08).ToString("n0")}-").FontSize(10));
                    });
                });
                column1.Item().Row(row => {
                    row.RelativeItem().Height(28);
                    row.ConstantItem(125).Border(1).AlignMiddle().AlignCenter().Text("経費等計").FontSize(10);
                    row.ConstantItem(60).Border(1).Column(column => {
                        column.Item().Row(r => r.RelativeItem().Height(28).AlignMiddle().AlignRight().PaddingRight(2).Text($"¥{totalExp.ToString("n0")}-").FontSize(10));
                    });
                    row.ConstantItem(60).Border(1).Column(column => {
                        column.Item().Row(r => r.RelativeItem().Height(28).AlignMiddle().AlignRight().PaddingRight(2).Text($"¥{totalExpTax.ToString("n0")}-").FontSize(10));
                    });
                });
                column1.Item().PaddingTop(2).Row(row => {
                    row.RelativeItem();
                    row.ConstantItem(245).LineHorizontal(1);
                });
                column1.Item().Row(row => {
                    row.RelativeItem().Height(28);
                    row.ConstantItem(125).Border(1).Background(Colors.Grey.Lighten3).AlignMiddle().AlignCenter().Text("合計").FontSize(10);
                    row.ConstantItem(120).Border(1).Background(Colors.Grey.Lighten3).AlignMiddle().AlignCenter().Text($"¥{toalAll.ToString("n0")}-").FontSize(10);
                });
            });
    });
}).GeneratePdf("invoice.pdf");

正直なところ、請求書発行システムの仕様としてはビミョーに足りてなかったり、間違っている部分がある気もしてます。
でも、割りとシンプルに実装できるので、修正やレイアウトの変更なんかも比較的簡単に出来そうです。
(もうすぐ始まるインボイスも対応しなきゃ!!ですね)

まとめ

ということで、今回はC#のPDF出力が簡単にできるQuestPDFを紹介してみました。

ライセンス的にもMIT Licenseらしいので、使いやすいですね。

C#でPDFを作りたいと思っている方は是非試してみてください。

コメント

タイトルとURLをコピーしました