🐾

CMPデスクトップ開発におけるCompose UIの印刷方法

に公開

背景

デスクトップ開発での印刷機能は社内システム等の作成でも多く使用されることがあると思いますが、その中でも個人的に実装していたCompose UIの印刷の実装に結構戸惑ったこともあったので、知っておくと良いかな程度に軽く紹介していこうと思います

実現方法

CMPには直接の印刷方法はもちろんなく、

  1. Composeの画像データ化
  2. 印刷(AWT)

の2工程を行っていく必要があります

Composeの画像データ化

Composeを画像データ化するには以下を利用します

val image = renderComposeScene(
    width = width,     // 横幅px
    height = height,   // 縦幅px
    density = density, // 1dpが何pxか
) {
    // ここにComposable
}

返り値としてはskiaImageが帰ります
https://api.skia.org/classSkImage.html
また、close()で解放もできるのでできるだけ行いましょう

image.close()

ちなみにComposeで使われるImageBitmap型やAwtで使用されるBufferedImageへの変換関数もあるので簡単に変換できます。

val imageBitmap = image.toComposeImageBitmap()
val awtImage = imageBitmap.toAwtImage() // skiaのImageから直接は変換できない

今回印刷はawtを利用するのでBufferedImageが必要となってきます

印刷

印刷にはAWTの印刷機能を利用します
ComposeはSwingでできており、SwingはAWTでできているのでかなり低レベルのGUI機能を扱うことができます
また、AWT自体javaのものですが互換性のあるKotlinなので印刷等の機能ももちろん問題なく使用することができます
https://www.tohoho-web.com/java/awt.htm

ここからはAWTを使ってComposeを印刷していきます

AWT印刷の基礎

早速ですが、ざっくり印刷は以下のようにできます

val printerJob = PrinterJob.getPrinterJob()
printerJob.setPrintable(customPrintable)  // 印刷情報をセット
printerJob.setPageable(customPageable)    // もしくは複数ページの印刷情報をセット

val result = printerJob.printDialog()  // 印刷ダイアログを表示
if (result) {
    printerJob.print()  // 印刷
}

公式リファレンス
https://docs.oracle.com/javase/jp/7/api/java/awt/print/package-summary.html

しっかりと実装するならば以下のようになると思います(あくまで一例)

val printerJob = withContext(Dispatchers.IO) {  // スレッドの指定
    PrinterJob.getPrinterJob()
}
printerJob.setPrintable(customPrintable)  // 印刷情報をセット
printerJob.setPageable(customPageable)    // もしくは複数ページの印刷情報をセット


val result = withContext(Dispatchers.Main) {
    try {
        printerJob.printDialog()  // 印刷ダイアログを表示
    } catch (e: HeadlessException) { // エラーハンドリング
        // 印刷ダイアログが表示できない場合
    }
}

if (result) {
    withContext(Dispatchers.IO) {
        try {
            printerJob.print()  // 印刷
        } catch (e: PrinterException) {
            // 印刷時エラーの場合
        }
    }
} else {
    // 印刷がキャンセルされた場合
}

また印刷の内容はPrintable,Pageableで指定できます(Bookというのもあるらしいですが使った事ないので省略)

Printable

Printableは1ページ、もしくは数ページにわたる印刷を設定するクラスです。ページ数は指定できないので単体の印刷物等に利用できます

画像の印刷であれば以下のようにできます

class ImagePrintable(private val imageList: List<BufferedImage>) : Printable {
    @Throws(PrinterException::class)
    override fun print(g: Graphics, pageFormat: PageFormat, pageIndex: Int): Int {
        val image = imageList.getOrNull(pageIndex)
        if (image != null) {
            // 引き伸ばしてそのまま印刷している
            g.drawImage(
                image,
                pageFormat.imageableX.toInt(),
                pageFormat.imageableY.toInt(),
                pageFormat.imageableWidth.toInt(),
                pageFormat.imageableHeight.toInt(),
                null
            )
            return Printable.PAGE_EXISTS
        } else {
            return Printable.NO_SUCH_PAGE
        }
    }
}

Printableでは印刷時にprint()が呼ばれ、その中で印刷処理を書く必要があります
また各引数は以下のようになります

引数名 解説
g Graphics 印刷物のGraphicsクラス。これを利用して描画する
pageFormat PageFormat ページの情報。縦横やサイズを持つ
pageIndex Int 印刷対象のページ番号

今回はComposeから生成した画像データを印刷したいので、Graphics.drawImage(BufferedImage)を使うことで描画できます
また、pageFormatにてサイズ等の情報もしっかりと与える必要があります(一応デフォルト値はある模様ですがおそらく端末依存になります)

ちなみにですがA4のフォーマットをしっかり作るとすると以下のようになります

object A4Page {
    const val PAGER_WIDTH = 595.0
    const val PAGER_HEIGHT = 842.0

    // 印刷可能領域を設定 (例: 上下左右に1cmのマージン)
    // 1cm = 約 0.3937 インチ
    // 0.3937 インチ * 72 = 約 28.35 ポイント
    const val MARGIN = 28.35
}

fun getA4PageFormat(
    orientation: Int = PageFormat.PORTRAIT
) = PageFormat().apply {
    this.orientation = orientation
    val paperInUse = Paper()
    val paperWidth = A4Page.PAGER_WIDTH
    val paperHeight = A4Page.PAGER_HEIGHT
    paperInUse.setSize(paperWidth, paperHeight)

    val margin = A4Page.MARGIN
    paperInUse.setImageableArea(
        margin, // 印刷可能領域のX座標 (左マージン)
        margin, // 印刷可能領域のY座標 (上マージン)
        paperWidth - 2 * margin, // 印刷可能領域の幅
        paperHeight - 2 * margin // 印刷可能領域の高さ
    )
    this.paper = paperInUse
}

Pageable

Printableの他にPageableというものもあり、こちらはページ数が印刷時に確認できるなど、複数ページ印刷に適したクラスとなります

複数ページの画像印刷であれば以下のようになります(実はこのPageableだと印刷にCompose印刷で問題があるのですが、後に紹介します)

/**
 * 複数のBufferedImageを印刷するためのPageable実装
 */
class MultiImagePageable(
    private val images: List<BufferedImage>,
    private val pageFormat: PageFormat,
    private val printable: Printable = ImagePrintable(images),
) : Pageable {

    override fun getNumberOfPages(): Int = images.size

    override fun getPageFormat(pageIndex: Int): PageFormat {
        return pageFormat
    }

    @Throws(PrinterException::class)
    override fun getPrintable(pageIndex: Int): Printable {
        return if (pageIndex in images.indices) {
            printable
        } else {
            throw IndexOutOfBoundsException("Page index out of bounds: $pageIndex")
        }
    }
}

Pageableでは内部的にPrintableを利用しており、getPrintable()で返したものが印刷時に利用されます。また、getPageFormat()getNumberOfPages()overrideしてそれぞれえ指定してあげると印刷時に利用されます

Printableよりも複雑ですが、複数ページを印刷するには利用するものとなっており、より扱いやすくなったBookというクラスもあるようです(使い方がわからないので省略)

遅延描画型Pageable

先ほど紹介したPageableでは一点問題があり、引数を見てもらうとわかるのですが印刷するためのBufferdImageリストを事前にすべて用意しなくてはいけません
大量のページ画像を事前に生成して印刷となるとメモリ消費がかなり激しくなってしまいますし、Compose UIを印刷する場合は描画がメインスレッドになるため事前にすべて描画するとかなりのブロッキングとなってしまいます

その対策として「印刷時に生成する方法」があります
以下はその一例です(print()がIOスレッドから呼ばれる前提)

Printable

/**
 * 動的レンダリング印刷用Printable
 * print()メソッドが呼ばれた時に動的にComposeコンテンツをレンダリングする
 */
class DynamicComposePrintable(
    private val pageIndex: Int,
    private val width: Int,
    private val height: Int,
    private val composable: @Composable (Int) -> Unit,
    private val density: Density,
    private val totalPages: Int
) : Printable {

    @Throws(PrinterException::class)
    override fun print(g: Graphics, pageFormat: PageFormat, pageIndex: Int): Int {
        return if (pageIndex == this.pageIndex) {
            // ComposeコンテンツをBufferedImageに変換
            // Composeを描画するため名スレッドでのレンダリング
            val image = renderComposeToBufferedImage()

            // 画像を印刷
            g.drawImage(
                image,
                pageFormat.imageableX.toInt(),
                pageFormat.imageableY.toInt(),
                pageFormat.imageableWidth.toInt(),
                pageFormat.imageableHeight.toInt(),
                null
            )

            Printable.PAGE_EXISTS
        } else {
            Printable.NO_SUCH_PAGE
        }
    }

    private fun renderComposeToBufferedImage(): BufferedImage {
        return try {
            // ComposeコンテンツをBufferedImageに変換
            // レンダリングを待機するためrunBlockingを使用
            runBlocking {
                val scene = withContext(Dispatchers.Main) {
                    renderComposeScene(
                        width = width,
                        height = height,
                        density = density,
                    ) {
                        composable(pageIndex)
                    }
                }

                val composeBitmap = scene.toComposeImageBitmap()
                val bufferedImage = composeBitmap.toAwtImage()

                // リソースを解放
                scene.close()

                bufferedImage
            }
        } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
            // TODO: Suppressが必要かどうか考える
            Logger.Companion.e(e) { "Failed to render compose content for page $pageIndex" }
            // エラー時はダミー画像を返す
            BufferedImage(width, height, BufferedImage.TYPE_INT_RGB)
        }
    }
}

上記Printableを元にPageableを作るとすると以下のようになります
Pageable

/**
 * 動的レンダリング印刷用Pageable
 * ページが要求されるたびにComposeコンテンツをレンダリングして印刷する
 */
class DynamicComposePageable(
    private val width: Int,
    private val height: Int,
    private val composable: @Composable (Int) -> Unit,
    private val density: Density,
    private val pageCount: Int,
    private val pageFormat: PageFormat,
) : Pageable {

    override fun getNumberOfPages(): Int = pageCount

    override fun getPageFormat(pageIndex: Int): PageFormat = pageFormat

    @Throws(PrinterException::class)
    override fun getPrintable(pageIndex: Int): Printable {
        return if (pageIndex in 0 until pageCount) {
            DynamicComposePrintable(
                pageIndex = pageIndex,
                width = width,
                height = height,
                composable = composable,
                density = density,
                onProgress = onProgress,
                totalPages = pageCount
            )
        } else {
            throw IndexOutOfBoundsException("Page index out of bounds: $pageIndex")
        }
    }
}

これにより、メモリ効率の良い印刷になります

まとめ・今後

以上がComposeでの印刷となりますが、AWT自体が古くJavaのものであるのでKotlinでは扱いにくいということもあり多少工夫が必要となってきます
理解できればそこまで難しいものではなくAWTもしっかりと動いてくれるので、覚えておけば印刷もCompose Multiplatformでしやすくなるかなと思います

今後としてはプレビューなど印刷時のほかの設定も触ってより高機能な印刷を作ってみたいかなと思っています
また、Swingにも印刷機能はあるらしくそっちのほうが使いやすいかどうかも気になってるのでやってみたいですね

Discussion