【FedEx】AndroidアプリでPDFファイルを作る

ZAICOでは、Android・iOS・Rubyエンジニアを絶賛募集中です! 詳しくは、採用ページをご覧ください。

好きな場所で働こう

こんにちは!モバイルアプリエンジニア兼宴会副部長のいくえもんです。ご無沙汰しています。

先日Fedex Weekが開催されました。Fedex Weekでは、普段の業務から離れ、自由なコンセプトで開発をすることができます。

今回はzaico for Emergencyをテーマに、「災害などの緊急事態が発生した時に、zaicoで管理している在庫情報を誰とでも簡単に共有できる」仕組みとして、在庫データをAndroidアプリでPDFダウンロードできる仕組みを作りました。これにより、ネットに接続しなくても、不足品などの情報を画像やバーコード付きで出力することができるようになりました。

本ブログでは、上記の取り組みの中から、AndroidアプリでPDFファイルを作成する方法についてご紹介します。

最終的に出来上がるPDFはこんな感じです。

zaico_pdf_sample

AndroidでPDFを作成する基礎

AndroidにはPdfDocumentというPDFファイルを作成・編集するためのクラスが用意されています。このクラスを使うと、下のようにPDFファイルを作成したり、文字や画像をPDFファイルに書き込んだりすることができます。

fun generatePdf(context: Context, uri: Uri) {
   // PDFドキュメント生成用のクラスを作成
   val pdfDocument = PdfDocument()
   // 指定の縦横幅(ピクセル)でページを作る
   val pageInfo = PdfDocument.PageInfo.Builder(WIDTH, HEIGHT, 1).create()
   // 作ったページに書き込みを始める
   val page = pdfDocument.startPage(pageInfo)
   // ページに書き込むためのキャンバスを取得する
   val canvas = page.canvas

   // 画像を書き込む
   val paint = Paint()
   canvas.drawBitmap(bitmap, /*開始位置X(ピクセル)*/, /*開始位置Y(ピクセル)*/, paint) // え??X,Y座標指定するの??

   // 文字を書き込む
   val text = Paint()
   text.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
   text.textSize = 14f
   text.color = ContextCompat.getColor(context, R.color.black)
   canvas.drawText("サンプルの文字", /*開始位置X(ピクセル)*/, /*開始位置Y(ピクセル)*/, text) // え??こっちもX,Y座標指定するの???

   // ページを閉じる
   pdfDocument.finishPage(page)

   try {
      // ファイルを開いてPDFを書き込む
      context.contentResolver.openFileDescriptor(uri, "w")?.use {
         pdfDocument.writeTo(FileOutputStream(it.fileDescriptor))
         Toast.makeText(context, "PDF file generated successfully", Toast.LENGTH_SHORT).show()
      }
   } catch (e: IOException) {
      e.printStackTrace()
   }
   pdfDocument.close()
}


このように、画像を書き込むにも、文字を書き込むにも、ピクセルで開始位置のX, Y座標を指定する必要があります。

Androidで自由なフォーマットでPDFを作成する方法

サンプルのPDFを見ていただくとわかりますが、今回はタイトルが中央寄せになっていたり、説明文が任意だったり、画像やQRコードの出力有無が選べたり、そもそも一覧に表示する在庫データの数が変わったり、在庫データごとの1行の高さが変わったりします。

毎回開始位置のX, Y座標計算するとか地獄やん!!!

ということで、地獄の計算をしなくても、自由度の高いデザインのPDFを作成する方法をご紹介します。

その方法とは、

  1. アプリ画面に表示しないViewをPDFファイルと同じ大きさで作成
  2. ViewをBitmapに変換
  3. BitmapをPDFの座標(0, 0)に貼り付ける

という裏技です!最初にViewを作ることで、要素の追加・削除・表示場所の調整がとっても簡単にできます。

まずは、PDFの元となるpdf_template.xmlを作成します。こんな感じです。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="@dimen/pdf_width"
        android:layout_height="@dimen/pdf_height"
        android:paddingHorizontal="@dimen/pdf_margin_horizontal"
        android:paddingVertical="@dimen/pdf_margin_vertical">
        <androidx.appcompat.widget.LinearLayoutCompat
            android:id="@+id/linearLayout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_marginBottom="36dp"
            android:orientation="vertical">
            <!-- 中央寄せのテキスト -->
            <TextView
                android:id="@+id/text"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:gravity="center"
                android:textSize="20sp"
                android:textStyle="bold"
                android:textColor="@color/black" />
            <!-- 左寄せのテキスト -->
            <TextView
                android:id="@+id/description"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="@dimen/margin_vertical_normal"
                android:gravity="start"
                style="@style/PdfTextStyle"
                android:maxLines="5" />
            <!-- テーブル上部の掛け線 -->
            <View
                android:layout_width="match_parent"
                android:layout_height="1dp"
                android:layout_marginTop="@dimen/screen_vertical_margin"
                android:background="@color/black" />
        </androidx.appcompat.widget.LinearLayoutCompat>
        <TextView
            android:id="@+id/pageNumber"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            style="@style/PdfTextStyle"
            android:text="1" />
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            style="@style/SmallTextStyle"
            android:text="created by zaico - https://www.zaico.co.jp" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>


次に、在庫データの1行になるlayoutファイルpdf_item.xmlを作成します。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <androidx.appcompat.widget.LinearLayoutCompat
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">
        <androidx.appcompat.widget.LinearLayoutCompat
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal">
            <View
                android:layout_width="1dp"
                android:layout_height="match_parent"
                android:background="@color/black" />
            <androidx.appcompat.widget.AppCompatImageView
                android:id="@+id/thumbnailImage"
                android:layout_width="@dimen/thumbnail_size"
                android:layout_height="@dimen/thumbnail_size"
                android:layout_marginVertical="@dimen/margin_vertical_small"
                android:layout_gravity="top"
                android:background="@drawable/no_image" />
            <View
                android:id="@+id/thumbnailDivider"
                android:layout_width="1dp"
                android:layout_height="match_parent"
                android:background="@color/black" />
            <TextView
                android:id="@+id/itemText"
                android:layout_width="0dp"
                android:layout_weight="1"
                android:layout_height="wrap_content"
                android:layout_marginHorizontal="@dimen/margin_horizontal_normal"
                android:layout_marginVertical="@dimen/margin_vertical_normal"
                style="@style/PdfTextStyle" />
            <View
                android:layout_width="1dp"
                android:layout_height="match_parent"
                android:background="@color/black" />
            <androidx.appcompat.widget.AppCompatImageView
                android:id="@+id/qrCodeImage"
                android:layout_width="@dimen/pdf_qrcode_size"
                android:layout_height="@dimen/pdf_qrcode_size"
                android:layout_marginHorizontal="@dimen/margin_horizontal_normal"
                android:layout_marginVertical="@dimen/margin_vertical_normal"
                android:layout_gravity="center"
                android:visibility="visible"/>
            <View
                android:id="@+id/codeDivider"
                android:layout_width="1dp"
                android:layout_height="match_parent"
                android:background="@color/black"
                android:visibility="visible"/>
         </androidx.appcompat.widget.LinearLayoutCompat>
        <View
            android:layout_width="match_parent"
            android:layout_height="1dp"
            android:background="@color/black" />
    </androidx.appcompat.widget.LinearLayoutCompat>
</layout>


あとは、ユーザーの入力内容に従って、pdf_template.xmlから作ったViewにデータを追加し、pdf_item.xmlを在庫データ分pdf_template.xmlのlinearLayoutの最下部に追加していきます。

ここで、何も考えずに在庫データをどんどん追加していくと、表の高さがPDFの縦幅を超えて途中で表が切れてしまいます。

このため、在庫データを追加するたびにViewの高さを計算し、必要に応じて次のページのViewを作成します。

fun generatePdfViews(page: Int, fromStockIndex: Int, viewList: MutableList<View>) {
        val inflater = LayoutInflater.from(this)
        // pdf_template.xmlから作ったBinding
        val pdfBinding = PdfTemplateBinding.inflate(inflater)
        // 複数ページにまたがる事があるので、1ページずつViewをviewListに追加する
        viewList.add(pdfBinding.root)

        // こんな感じでpdf_template.xmlの要素を設定
        // タイトルの設定
        if (page == 1 && !binding.title.editText?.text.isNullOrBlank()) {
            pdfBinding.title.text = binding.title.editText?.text
        } else {
            pdfBinding.title.visibility = View.GONE
        }

        // 在庫データの設定
        val stocks = viewModel.selectedStocks.value ?: listOf()
        for(index in fromStockIndex until stocks.size) {
            val stock = stocks[index]
            // pdf_item.xmlから作った在庫データ1件分のBinding
            val itemBinding = PdfItemBinding.inflate(inflater)

            // pdf_template.xmlと同様に在庫データ情報を設定(省略)

            // 在庫データの行をpdf_templateのlinearLayoutの最下部に追加
            pdfBinding.linearLayout.addView(itemBinding.root)

            // Viewのサイズを確認。横幅をPDFの幅にしたときに、縦幅が幾つになるのかを確認するため、Y側はUNSPECIFIEDを指定する。
            pdfBinding.root.measure(
                View.MeasureSpec.makeMeasureSpec(pdfA4Width, View.MeasureSpec.EXACTLY),
                View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
            )
            // A4サイズの高さを超えていた場合、次のページにする
            if (pdfBinding.root.measuredHeight > pdfA4Height) {
                itemBinding.root.visibility = View.GONE // 今のページでは最後に追加した在庫情報を非表示に
                generatePdfViews(page + 1, index, viewList) // 次のページを作成するため、次のページ番号、最後の在庫データのindexを渡して再度呼び出し
                break
            }
        }
    }


ここまで来れば、あとは出来上がったviewListを渡してPDFを1ページずつ作っていけばOKです。

ただし、viewListのViewは一度も描画されたことがないので、Bitmapに変換する前に描画サイズを計算してレイアウトください。

viewListを使って複数ページのPDFを作成する最終メソッドは次のようになります。

fun generatePdf(views: List<View>, context: Context, uri: Uri) {
   val pdfDocument = PdfDocument()
   val width = context.resources.getDimensionPixelSize(R.dimen.pdf_width)
   val height = context.resources.getDimensionPixelSize(R.dimen.pdf_height)

   val pageInfo = PdfDocument.PageInfo.Builder(width, height, 1).create()

   views.forEach { view ->
      // 1ページずつしか書き込めないので、View1つずつstartPageする
      val page = pdfDocument.startPage(pageInfo)
      val canvas = page.canvas

      // 画像を書き込む
      val paint = Paint()
      // Viewのサイズを計測して、描画する。今回はheightにもEXACTLYを指定
      view.measure(
         View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
         View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY)
      )
      view.layout(0, 0, width, height)
      // Viewから作ったBitmapを書き込む
      canvas.drawBitmap(view.drawToBitmap(), 0f, 0f, paint)

      // 次のページを書き込むために今のページは閉じる
      pdfDocument.finishPage(page)
   }

   // ファイルに出力するところは同じ(省略)
}


最後までお付き合いいただきありがとうございました。

こうやってみると、AndroidのView周りは本当に便利関数が用意されていて助かるな〜と実感しました。

みなさんもAndroidでPDFを作る際は、Viewの利用を検討してみてください。

ZAICOでは、新しいテクノロジーの力でモノの状態・流れを把握する仕組みに一緒に取り組む仲間を募集しております。
詳しくは、採用ページをご覧ください。

好きな場所で働こう