ExcelテンプレートからPDF帳票出力

PDFで帳票出力となると苦手意識しかありませんでした。
コントローラにView側の事情をたくさん書き込むイメージがあったので。

普段Spring-MVC使いとしてはView側の事情をあまりコントローラに書きたくない。

どうにかして、出力がHTMLであろうがPDFであろうが、表示に必要なデータを`Model`に詰め込んでコントローラの役割は終わりにしたい。

ということで

- **手軽に**PDFをテンプレート管理できる
- コントローラが汚れない
- Spring-MVCと相性が良い

方法が無いか模索してみました。

## PDF出力用ライブラリを使用する

`spring-webmvc`に`JasperReportsPdfView.java`があったので試してみる。

### JasperReports

最初に言っておきますけど、これは解決策としては選びませんでした。
[JasperReportsで帳票出力してみた](http://dev.classmethod.jp/server-side/java/jasperreports-tutorial/)を参考に一応試してみたのですが、次の理由から自分には合っていませんでした。

- データが追加になったらデータアダプタから作ってテンプレートに埋め込む必要がある
- Jaspersoft Studioが重いからEclipseとかと同時に開いているとPCが重い
- ならばとEclipseにJaspersoft StudioのPlugin入れたけどPluginで細かい設定ができない  
（何故かJaspersoft Studio単体で表示されるViewがEclipseからだと表示できない）
- 細かい設定用のViewから色々値いじったりするのが大変で、直接XML編集しようとしたら思うようにいかない  
（勝手に発行される要素ごとのUUIDが邪魔してコピペしにくいし、マークアップ苦手。。）

一応、Bean形式も試してみたけど別プロジェクトのクラスパスが通らなくて断念。（EclipseのPluginだとクラスパス通った）
クラスパス通さなくても良いCSV形式も試したけど、アダプタの生成がめんどくさいから断念。

そしてJasperReportsを諦めた最大の理由が
**時間経ったらJasperReportsの使い方完全に忘れた＼(^o^)／**

・・・別の方法考えよ。

## ExcelからPDFに変換

以前、Excelに関しては[jXLS@1.x](http://jxls.sourceforge.net/1.x/)でテンプレート管理した経験があった。
これに関してはダウンロード／アップロードどちらも優秀でExcelへのデータ展開／抽出が簡単に書ける。
Excelだったら良かったのに。。
と考えたら思った。
一回Excelに展開して、それをPDFに変換する方法は無いかと。

### JodConverter

見つけた。OpenOfficeかLibreOfficeさえあればあらゆるドキュメントを変換可能らしい。
まぁ、今回はとりあえずExcelをPDFに変換したい。
ということでネットで調べるとLibreOfficeの方が使いやすいということだった、かつ普段CSVファイルのViewerとして使っているのでこちらで試してみることにした。

#### 事前準備

まずは必要なライブラリをpom.xmlに追加する。
JodConverterをMavenセントラルで検索してみる。
すると開発が2.2.1で止まっている。
あれ？ネットでは3.x系のことが書かれていたのに。
[公式](http://www.artofsolving.com/opensource/jodconverter.html)を見てみると、
> These pages are for JODConverter v2.x; for the new v3.0 please see http://code.google.com/p/jodconverter/.

と、3系はこっちを見てね。と書かれていた。
3系を使いたい。
何故かと言うと2系ではopenofficeのサービスはアプリケーションで管理できないからだ。
デーモン化するためのスクリプトとかめんどくさいから書きたくないし、管理対象に入れたくない。
だったら、アプリケーション起動したら同時にopenofficeのサービス起動して、アプリケーション落としたら同時にサービス落としてくれる3系の方が絶対に良い。
サーバでGUIのopenofficeとか絶対に使わないし。

code.google.comで公開されているソースのコミッターのGithubリポジトリを見つけた。
https://github.com/mirkonasato/jodconverter

3.0-beta-4リリースコミットがあったから、そこからローカルリポジトリにインストールしてみる。

```sh
$ git clone https://github.com/mirkonasato/jodconverter
$ git reset --hard 5bc0fdc
$ git log --oneline -n 1
5bc0fdc release 3.0-beta-4
$ cd jodconverter-core
$ mvn install -DskipTests=true
```

これでローカルリポジトリにインストール完了。
他の必要なjarも入ったはず。

で、テストコードのpom.xmlにインストールしたjarを追加。

```pom.xml
<dependencies>
  <dependency>
    <groupId>org.artofsolving.jodconverter</groupId>
    <artifactId>jodconverter-core</artifactId>
    <version>3.0-beta-4</version>
  </dependency>
</dependencies>
```

テストコードで動作確認してみる。

Excelはこんな感じのを用意。
LibreOfficeで変換するから一応Calcで生成してExcel形式で保存した。

![スクリーンショット 2016-11-05 18.22.54.png](https://qiita-image-store.s3.amazonaws.com/0/66760/551b1bad-c9f6-a060-5a67-cd3386c7f444.png)


みんなの味方！Excel方眼紙！w!


```PdfConvertConfig.java
// これでopenofficeのサービスの起動と停止をやってくれる
@Bean(initMethod = "start", destroyMethod = "stop")
public OfficeManager officeManager() {
  return new DefaultOfficeManagerConfiguration().buildOfficeManager()
}

@Bean
public OfficeDocumentConverter converter() {
  return new OfficeDocumentConverter(officeManager());
}
```
```PdfConvertTest.java
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = PdfConvertConfig.java)
public class PdfConvertTest {

  @Autowired
  private OfficeDocumentConverter converter;

  @Test
  public void testConvertPdf() {
    File excelFile = new File("***.xlsx");
    assertThat(excelFile.exists(), is(true)); // 存在する

    File pdfFile = new File("***.pdf");
    assertThat(pdfFile.exists(), is(false)); //　存在しない

    // 変換！
    converter.convert(excelFile, pdfFile);

    assertThat(pdfFile.exists(), is(true)); // PDFが出来た
  }
```
実行！
したらエラーになった。。
> Caused by: java.lang.IllegalStateException: officeHome not set and could not be auto-detected
	at org.artofsolving.jodconverter.office.DefaultOfficeManagerConfiguration.buildOfficeManager(DefaultOfficeManagerConfiguration.java:163)

うーん、どうもLibreOfficeがインストールされている場所を自動特定しようとして失敗したみたいですね。
どこを見て何が無いと判断したのだろう。

```DefaultOfficeManagerConfiguration.java
private File officeHome = OfficeUtils.getDefaultOfficeHome();
```
とあったので[OfficeUtils.java](https://github.com/Jahia/jodconverter/blob/5bc0fdcfc48f7e85b7be654b8355e282bd1fd99c/jodconverter-core/src/main/java/org/artofsolving/jodconverter/office/OfficeUtils.java)を見てみる。
ん？ `soffice.bin`なんてあるのか？と自分のPCを探ってみる。

```sh
$ pwd
/Applications/LibreOffice.app/Contents/MacOS
$ ls
gengal		regmerge	soffice		unoinfo		uri-encode
gengal.bin	regview		ui-previewer	unopkg		xpdfimport
python		senddoc		uno		urelibs
```
`soffice`はあるから、とりあえずコピって名前変えてみるか。

```sh
$ cp soffice soffice.bin
$ ls
gengal		regmerge	soffice		uno		urelibs
gengal.bin	regview		soffice.bin	unoinfo		uri-encode
python		senddoc		ui-previewer	unopkg		xpdfimport
```

JodConverterはどうもLibreOffice3系を前提に作られているみたい。
その時は`soffice.bin`の名前だったようだ。
ということはこれも開発ストップしているのかな。

ちなみに今のLibreOfficeの最新版は5.2.3。
で実行環境は

```sh
$ ./soffice --version
LibreOffice 5.1.4.2 f99d75f39f1c57ebdd7ffc5f42867c12031db97a
```

ともかく、もう一回実行。

・・・成功！

PDF見てみる。

![スクリーンショット 2016-11-05 18.24.30.png](https://qiita-image-store.s3.amazonaws.com/0/66760/406c1f55-004c-8625-ef87-5628a4b0f004.png)

![スクリーンショット 2016-11-05 18.24.59.png](https://qiita-image-store.s3.amazonaws.com/0/66760/ffb3e291-e13e-a9c4-e3e8-fc939bef2878.png)


まぁ、印刷設定とかしてないからこんなもんかな。
LibreOfficeのCalcの方で[書式] -> [ページ]
でヘッダー／フッター外して配置を真ん中にして、縮尺をページの横幅に合わせる。

で、実行するとこんな感じになります。

![スクリーンショット 2016-11-05 18.27.43.png](https://qiita-image-store.s3.amazonaws.com/0/66760/919f8dae-752e-75f1-b0f9-100733524ac0.png)

枠線がうまく表示されない場合は一度解除してから再度引き直せばうまく表示されるみたいです。

PDFの実験はこれで終わり。

### jXLS

先程出てきたjXLSを使って次はテンプレートを作りたいと思います。
理想はFreeMarkerのように書けること。
jXLSは以前使っていた1.x系ではなく、現在の2.x系を使います。
実は1.x系はExcel2003フォーマットまでしか対応していないのです。

#### 事前準備

まずは動作確認用のテストコードを作ります。

```pom.xml
<dependency>
  <groupId>org.jxls</groupId>
  <artifactId>jxls</artifactId>
  <version>2.3.0</version>
</dependency>
<dependency>
  <groupId>org.jxls</groupId>
  <artifactId>jxls-poi</artifactId>
  <version>1.0.9</version>
</dependency>
```

```ExcelRenderTest.java
@Value
@Builder
public static class Company {
  String name;
  String address;
  String tel;
}

@Value
@Builder
public static class OrderItem {
  String name;
  Integer price;
  Integer quantity;
}

@Test
@SneakyThrows
public void test() {
  File templateFile = new File("/****/test_template.xlsx");
  File outputFile = new File("/****/test_out.xlsx");

  Company orderCompany = Company.builder()
    .name("株式会社 zzzzzzzzz")
    .build();

  Company dealerCompany = Company.builder()
    .name("有限会社yyyyy")
    .address("東京都品川区xxxxxxxxxx 1-1-1")
    .tel("03-aaaa-bbbb")
    .build();

  List<OrderItem> orderItems = ImmutableList.<OrderItem> builder()
    .add(OrderItem.builder()
      .name("はじめてのSpring")
      .price(3421)
      .quantity(18)
      .build())
    .add(OrderItem.builder()
      .name("はじめてのJava")
      .price(4123)
      .quantity(10)
      .build())
    .build();

  Context context = new Context();
  context.putVar("orderCompany", orderCompany);
  context.putVar("dealerCompany", dealerCompany);
  context.putVar("orderItems", orderItems);

  try (InputStream in = new FileInputStream(templateFile);
    OutputStream out = new FileOutputStream(outputFile)) {
    JxlsHelper.getInstance().processTemplate(in, out, context);
  }
}
```

#### Excelテンプレートの用意

[jXLSの公式サイト](http://jxls.sourceforge.net/)を参考に先程のExcelファイルに変数やループのロジックを埋め込んでいきます。

![スクリーンショット 2016-11-06 0.41.51.png](https://qiita-image-store.s3.amazonaws.com/0/66760/3398b16a-4b25-4d3e-babb-0b14209f0ea3.png)

※ 計算式のところはわかりやすいように形式を「文字列」にしていますが、「通貨」にしておきます
※ 「No」列は「`${orderItems.indexOf(item)+1}`」としています。 `=row()-11`が評価されなかったため

うまくいったー！

![スクリーンショット 2016-11-06 0.47.54.png](https://qiita-image-store.s3.amazonaws.com/0/66760/c0e262fb-6f76-7b8d-9628-d5dd1bae9bba.png)

と、書いていますが、実は四苦八苦しまして、、
というのもArea情報をうまく読み取ってくれない状況に陥りました。
デバッグしているとどうも、行の終わりを空白行で判断しているみたいで２行目が邪魔してうまくArea情報を判定してくれませんでした。
なので、２行目を消しました。

### Spring-MVCへの適用

さて、ここまで来たら簡単。
あとはSpring-MVCに適用させるために`ViewResolver`と`View`を実装するだけ！

```OpenOfficePdfView.java
@Slf4j
public class OpenOfficePdfView extends AbstractTemplateView {

  /** PDFファイル拡張子 */
  public static final String PDF_EXTENTION = ".pdf";

  private OfficeDocumentConverter officeDocumentConverter;

  public OpenOfficePdfView() {
    setContentType("application/pdf");
  }

  @Override
  protected void initServletContext(ServletContext servletContext) {
    super.initServletContext(servletContext);

    this.officeDocumentConverter = BeanFactoryUtils.beanOfTypeIncludingAncestors(getApplicationContext(),
      OfficeDocumentConverter.class);
  }

  @Override
  public boolean checkResource(Locale locale) {
    return getTemplateResource(locale).exists();
  }

  @Override
  protected void renderMergedTemplateModel(Map<String, Object> model, HttpServletRequest request,
    HttpServletResponse response) throws Exception {
    // テンプレート
    Resource templateResource = getTemplateResource(RequestContextUtils.getLocale(request));

    Resource excelResource = null;
    Resource pdfResource = null;

    try {
      // Excel展開用ファイル
      excelResource = createTemporaryResource(getTemplateExtension());

      // Modelを展開
      processExcelTemplate(model, templateResource, excelResource);

      // PDFファイルリソース
      pdfResource = createTemporaryResource(PDF_EXTENTION);

      // ExcelファイルをPDFに変換
      this.officeDocumentConverter.convert(excelResource.getFile(), pdfResource.getFile());

      try (InputStream pdfFileInputStream = pdfResource.getInputStream()) {
        // PDFファイルをレスポンスにコピー
        IOUtils.copy(pdfFileInputStream, response.getOutputStream());
      }

    } finally {
      if (excelResource != null) {
        if (FileUtils.deleteQuietly(excelResource.getFile()) == false) {
          log.warn("Failed to remove temporary excel file. [resource:[{}]]", excelResource);
        }
      }
      if (pdfResource != null) {
        if (FileUtils.deleteQuietly(pdfResource.getFile()) == false) {
          log.warn("Failed to remove temporary pdf file. [resource:[{}]]", pdfResource);
        }
      }
    }
  }

  protected Resource getTemplateResource(Locale locale) {
    LocalizedResourceHelper helper = new LocalizedResourceHelper(getApplicationContext());
    String extension = getTemplateExtension();
    String fileName = StringUtils.removeEnd(getUrl(), extension);

    return helper.findLocalizedResource(fileName, extension, locale);
  }

  protected void processExcelTemplate(Map<String, Object> model, Resource templateResource,
    Resource outputResource)
    throws IOException {
    try (InputStream templateInputStream = templateResource.getInputStream();
      OutputStream outputStream = new FileOutputStream(outputResource.getFile())) {
      JxlsHelper.getInstance().processTemplate(templateInputStream, outputStream, new Context(model));
    }
  }

  protected String getTemplateExtension() {
    return FilenameUtils.getExtension(getUrl());
  }

  private static FileSystemResource createTemporaryResource(String extention) {
    return new FileSystemResource(
      FileUtils.getTempDirectoryPath() + File.separator + UUID.randomUUID() + extention);
  }
}
```
```OpenOfficePdfViewResolver.java
public class OpenOfficePdfViewResolver extends AbstractTemplateViewResolver {

  public OpenOfficePdfViewResolver() {
    setViewClass(requiredViewClass());
  }

  @Override
  protected Class<OpenOfficePdfView> requiredViewClass() {
    return OpenOfficePdfView.class;
  }
}
```

```ViewConfig.java

@Bean
public OpenOfficePdfViewResolver pdfViewResolver() {
  OpenOfficePdfViewResolver viewResolver = new OpenOfficePdfViewResolver();
  viewResolver.setPrefix("classpath:templates/");
  viewResolver.setSuffix(".xlsx");
  viewResolver.setOrder(-1); // 他のViewResolverより優先する
}

// 複数の`ViewResolver`でも大丈夫か確認する。
@Bean
public FreeMarkerViewResolver ftlViewResolver() {
 FreeMarkerViewResolver viewResolver = new FreeMarkerViewResolver();
  viewResolver.setPrefix("classpath:templates/");
  viewResolver.setSuffix(".ftl");
}
```

```OrderController.java
@Controller
public class OrderController {

  @Autowired
  OrderService orderService;

  // application/pdf
  @GetMapping(path = "/order.pdf", produces = "application/pdf")
  public String viewPdf(Model model) {
    loadAndBindData(model);
    return "order"; // src/main/resources/templates/order.xlsx
  }

  // text/html
  @GetMapping("/order")
  public String viewHtml(Model model) {
    loadAndBindData(model);
    return "order"; // src/main/resources/templates/order.ftl
  }

  void loadAndBindData(Model model) {
    model.addAttribute("orderCompany", orderService.getOrderCompany());
    model.addAttribute("dealerCompany", orderService.getDealerCompany());
    model.addAttribute("orderItems", orderService.getOrderItems());
  }
}
```

これでPDFがブラウザで表示されます。

![スクリーンショット 2016-11-06 4.57.47.png](https://qiita-image-store.s3.amazonaws.com/0/66760/cdfd22ef-8ea9-448a-dc40-0e44e7c6b964.png)

`/order` は FTLを用意していたら `text/html` の形式で表示されます。

![スクリーンショット 2016-11-06 4.57.57.png](https://qiita-image-store.s3.amazonaws.com/0/66760/fffb9522-d897-c258-b222-ccd92acec825.png)

コントローラの中身はViewの生成ロジックに依存せず、リクエストを受け付けてデータをセットするだけになりました。

## まとめ

今回使用したのは次の通り。

ライブラリ

- jodconverter-core＠3.0-beta-4
- jxls＠2.3.0
- jxls-poi＠1.0.9
- spring-xxx＠4.3.3  
  (実際はspring-boot＠1.4.1を使いましたがコード例はbootを意識してません)
- lombok＠1.16.10
- guava＠19.0
- commons-lang3＠3.4
- commons-io＠2.5

ソフトウェア

- LibreOffice＠5.1.4.2

ちなみにLibreOffice5系で作ったテンプレートを4系でコンバートすると枠線が太くなるので要注意です。

ちなみにJasperReportsは現在も開発が継続していて、今回の例もJasperReportsを使用すると、こんな色々ライブラリやソフトウェアを追加する必要は無いです。
一長一短ですね。

## 参考

- [JODConverterとOpenOfficeでExcelをPDFへ変換する](http://kikutaro777.hatenablog.com/entry/2013/06/26/194035)
- [JavaでPDF生成する方法(LibreOffice, jodConverterによる方法）](http://www.susumuis.info/entry/20110520/p1)
- [OpenOfficeとLibreOfficeを使ってJODConverterのPDF変換結果を比べてみる](http://blog.ko31.com/201303/openoffice-libreoffice-pdf-convert/)