POIを使わずJava標準ライブラリでExcelファイルを生成する

某オープンチャットでPOIを使わずにExcelファイルをダウンロードという質問が来ていて、まあそこでは「POI使いましょう」ってなったのだけど、結局XMLファイルなので出力対象が決まってればそんなに難しくないのではと思ったのでやってみました。

流れとしてはこんな感じ。

  • ベースになるExcelファイルを作る
  • ZIP展開してテンプレートにする
  • データを生成してXMLに埋め込む
  • xlsxという拡張しでZIPファイルを作る

まず、出力する形式をExcelで作ります。今回はこんな感じで、名前と数学の点数、英語の点数、合計と平均を出します。

これをtemplate.xlsxで保存します。xlsxはXMLファイルをZIP圧縮したものなので、拡張子をzipにするとこんな感じになっています。

ここで、xl/worksheets/sheet1.xmlにシートデータが入っています。

あと、文字列はxl/sharedStrings.xmlで一括管理されています。

これを展開して、resources/templateに置いておきます。

sharedStrings.xmlで、プログラムで生成したデータで使う文字列を置く部分は%sに置き換えます。

    <si>
        <t xml:space="preserve">合計</t>
    </si>
    <si>
        <t xml:space="preserve">平均</t>
    </si>
    %s
</sst>

sheet1.xmlでデータを生成する部分も%sに変更しておきます。

<sheetData>
    <row r="2" customFormat="false" ht="12.8" hidden="false"
         customHeight="false" outlineLevel="0" collapsed="false">
        <c r="C2" s="0" t="s">
            <v>0</v>
        </c>
        <c r="D2" s="0" t="s">
            <v>1</v>
        </c>
        <c r="E2" s="0" t="s">
            <v>2</v>
        </c>
    </row>        
    %s
</sheetData>

ではコードを書いていきましょう。 ZIPファイルを作るので、ZipOutputStreamを用意。

        try (FileOutputStream fos = new FileOutputStream("temp.xlsx");
             ZipOutputStream zos = new ZipOutputStream(fos)) {

まずは固定ファイルを出力していきます。

var copy = List.of("[Content_Types].xml", "_rels/.rels",
        "docProps/app.xml", "docProps/core.xml",
        "xl/styles.xml", "xl/workbook.xml", "xl/_rels/workbook.xml.rels");

for (var c : copy) {
    try (var in = ExcelWriter.class.getResourceAsStream("/template/" + c)) {
        zos.putNextEntry(new ZipEntry(c));
        in.transferTo(zos);
        zos.closeEntry();
    }
}

そしてデータの出力

record ExamResult(String name, int math, int english) {}
List<ExamResult> results = List.of(
        new ExamResult("田中美香", 72, 84),
        new ExamResult("山田将之", 81, 78),
        new ExamResult("鈴木真理子", 68, 92),
        new ExamResult("田中雄介", 63, 71),
        new ExamResult("佐藤健太郎", 76, 88),
        new ExamResult("伊藤麻衣子", 90, 80));
var row = 3;
var strIndex = 4;
StringBuilder sheet = new StringBuilder();
StringBuilder strs = new StringBuilder();
for (var er : results) {
    sheet.append(rowTemplate.formatted(row++, strIndex++, er.math(), er.english()));
    strs.append(stringTemplate.formatted(er.name()));
}

データ用XMLのテンプレートはこんな感じ。

static String rowTemplate = """
        <row r="%1$d" customFormat="false" ht="12.8" hidden="false"
                            customHeight="false" outlineLevel="0" collapsed="false">
            <c r="B%1$d" s="1" t="s">
                <v>%2$d</v>
            </c>
            <c r="C%1$d" s="0" t="n">
                <v>%3$d</v>
            </c>
            <c r="D%1$d" s="0" t="n">
                <v>%4$d</v>
            </c>
            <c r="E%1$d" s="0" t="n">
                <f aca="false">SUM(C%1$d:D%1$d)</f>
            </c>
        </row>
        """;

文字列用XMLはこんな感じ。

static String stringTemplate = """
        <si>
            <t xml:space="preserve">%s</t>
        </si>
        """;  

あと、今回のテストデータはChatGPTさんに作ってもらっています。

点数も。

ついでにZIPファイルの作り方も聞いてます。

あと、最後にフッターを出力

sheet.append(footer.formatted(row, row-1));
static String footer = """
        <row r="%1$d" customFormat="false" ht="12.8" hidden="false"
                       customHeight="false" outlineLevel="0" collapsed="false">
            <c r="B%1$d" s="0" t="s">
                <v>3</v>
            </c>
            <c r="C%1$d" s="0" t="n">
                <f aca="false">AVERAGE(C3:C%2$d)</f>
            </c>
            <c r="D%1$d" s="2" t="n">
                <f aca="false">AVERAGE(D3:D%2$d)</f>
            </c>
        </row>
        """;

データができたら、sheet1.xmlを出力。

zos.putNextEntry(new ZipEntry("xl/worksheets/sheet1.xml"));
try (var sheetIn = new BufferedReader(new InputStreamReader(
            ExcelWriter.class.getResourceAsStream("/template/xl/worksheets/sheet1.xml")))) {
    var sh = sheetIn.lines().collect(Collectors.joining("\n"));
    zos.write(sh.formatted(sheet).getBytes());
}
zos.closeEntry();

それとsharedStrings.xmlを出力

zos.putNextEntry(new ZipEntry("xl/sharedStrings.xml"));
try (var stringIn = new BufferedReader(new InputStreamReader(
        ExcelWriter.class.getResourceAsStream("/template/xl/sharedStrings.xml")))) {
    var st = stringIn.lines().collect(Collectors.joining("\n"));
    zos.write(st.formatted(strs).getBytes());
}
zos.closeEntry();

無事にExcelファイルができました。

まあ、調整がめちゃめんどいので実際のプロダクトではPOI使いましょうという感じですが、こういうこともできるよということで。 ソースコード全体はこんな感じ。

package excelwriter;

import java.io.BufferedReader;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.List;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

public class ExcelWriter {

    public static void main(String[] args) throws IOException{
        try (FileOutputStream fos = new FileOutputStream("temp.xlsx");
             ZipOutputStream zos = new ZipOutputStream(fos)) {

        var copy = List.of("[Content_Types].xml", "_rels/.rels",
                "docProps/app.xml", "docProps/core.xml",
                "xl/styles.xml", "xl/workbook.xml", "xl/_rels/workbook.xml.rels");

        for (var c : copy) {
            try (var in = ExcelWriter.class.getResourceAsStream("/template/" + c)) {
                zos.putNextEntry(new ZipEntry(c));
                in.transferTo(zos);
                zos.closeEntry();
            }
        }

        record ExamResult(String name, int math, int english) {}
        List<ExamResult> results = List.of(
                new ExamResult("田中美香", 72, 84),
                new ExamResult("山田将之", 81, 78),
                new ExamResult("鈴木真理子", 68, 92),
                new ExamResult("田中雄介", 63, 71),
                new ExamResult("佐藤健太郎", 76, 88),
                new ExamResult("伊藤麻衣子", 90, 80));
        var row = 3;
        var strIndex = 4;
        StringBuilder sheet = new StringBuilder();
        StringBuilder strs = new StringBuilder();
        for (var er : results) {
            sheet.append(rowTemplate.formatted(row++, strIndex++, er.math(), er.english()));
            strs.append(stringTemplate.formatted(er.name()));
        }
        sheet.append(footer.formatted(row, row-1));

        zos.putNextEntry(new ZipEntry("xl/worksheets/sheet1.xml"));
        try (var sheetIn = new BufferedReader(new InputStreamReader(
                    ExcelWriter.class.getResourceAsStream("/template/xl/worksheets/sheet1.xml")))) {
            var sh = sheetIn.lines().collect(Collectors.joining("\n"));
            zos.write(sh.formatted(sheet).getBytes());
        }
        zos.closeEntry();

        zos.putNextEntry(new ZipEntry("xl/sharedStrings.xml"));
        try (var stringIn = new BufferedReader(new InputStreamReader(
                ExcelWriter.class.getResourceAsStream("/template/xl/sharedStrings.xml")))) {
            var st = stringIn.lines().collect(Collectors.joining("\n"));
            zos.write(st.formatted(strs).getBytes());
        }
        zos.closeEntry();
        }
    }
    
    static String rowTemplate = """
            <row r="%1$d" customFormat="false" ht="12.8" hidden="false"
                                customHeight="false" outlineLevel="0" collapsed="false">
                <c r="B%1$d" s="1" t="s">
                    <v>%2$d</v>
                </c>
                <c r="C%1$d" s="0" t="n">
                    <v>%3$d</v>
                </c>
                <c r="D%1$d" s="0" t="n">
                    <v>%4$d</v>
                </c>
                <c r="E%1$d" s="0" t="n">
                    <f aca="false">SUM(C%1$d:D%1$d)</f>
                </c>
            </row>
            """;
    static String footer = """
            <row r="%1$d" customFormat="false" ht="12.8" hidden="false"
                           customHeight="false" outlineLevel="0" collapsed="false">
                <c r="B%1$d" s="0" t="s">
                    <v>3</v>
                </c>
                <c r="C%1$d" s="0" t="n">
                    <f aca="false">AVERAGE(C3:C%2$d)</f>
                </c>
                <c r="D%1$d" s="2" t="n">
                    <f aca="false">AVERAGE(D3:D%2$d)</f>
                </c>
            </row>
            """;
    static String stringTemplate = """
            <si>
                <t xml:space="preserve">%s</t>
            </si>
            """;    
}