Blog

Back to all articles

Android PDF Decoder Using Coil

Sometimes you need to preview PDF files in your Android app, but you don't need advanced features like pagination, text search, or pinch-to-zoom. In these cases, showing the first page as a simple image is often sufficient.

In modern Android apps built using Jetpack Compose, Coil is a solid option for loading images from multiple sources, including network locations as well as content: and file: URIs.

In this post, we'll demonstrate how to create a custom Coil image decoder for PDF files.

We can divide the task into two subtasks:

  1. Generating an image (Android Bitmap) from a PDF file.
  2. Creating and registering the PDF decoder in Coil.

Generate image from PDF file

For this task, we'll use the official PdfRenderer API to generate a Bitmap from the PDF file. We'll need to open the file using a ParcelFileDescriptor like this:

/**
 * Converts single page of PDF file into a [Bitmap].
 *
 * @param file The input file.
 * @param displayMetrics The display metrics to use to calculate output bitmap size.
 * @param pageIndex The page index to open, starting from index 0.
 */
internal fun getPdfPageAsBitmap(
  file: File,
  displayMetrics: DisplayMetrics,
  pageIndex: Int = 0
): Bitmap {
  return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
    .use { fileDescriptor ->
      PdfRenderer(fileDescriptor).use { pdfRenderer ->
        pdfRenderer.openPage(pageIndex).use { page ->
          page.toBitmap(displayMetrics)
        }
      }
    }
}

The toBitmap on PdfRenderer.Page type is extension, that does the job:

/**
 * Converts this page into a [Bitmap].
 * The image size taken from the page size itself, but it's upmost the display size.
 */
private fun PdfRenderer.Page.toBitmap(displayMetrics: DisplayMetrics): Bitmap {
  val pageWidth = displayMetrics.pointsToPx(width)
  val pageHeight = displayMetrics.pointsToPx(height)
  val scale: Float = minOf(
    displayMetrics.widthPixels.toFloat() / pageWidth,
    displayMetrics.heightPixels.toFloat() / pageHeight,
  )
  val width = (pageWidth * scale).roundToInt()
  val height = (pageHeight * scale).roundToInt()

  return createBitmap(width, height).also { bitmap ->
    render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
  }
}

/** Converts point (1/72") into a pixel. */
@Px
private fun DisplayMetrics.pointsToPx(value: Int): Float {
  return value * (densityDpi / 72f)
}

In this code snippet, the final image size is calculated from PDF page size in points, but limited to maximum display size in pixels.

When we have the helper for converting single PDF page into the Bitmap, let's create the Coil decoder.

Coil PDF Decoder

Now, let's implement the Coil Decoder that uses the conversion function shown above:

/**
 * Decoder for PDF files.
 * Note that, only 1st page is converted into the image by calling [getPdfPageAsBitmap].
 */
class PdfPageDecoder(
  private val file: File,
  private val resources: Resources,
) : Decoder {

  override suspend fun decode(): DecodeResult {
    val bitmap = withContext(Dispatchers.IO) {
      getPdfPageAsBitmap(file, resources.displayMetrics)
    }
    val drawable = bitmap.toDrawable(resources)

    return DecodeResult(image = drawable.asImage(), isSampled = false)
  }

  class Factory : Decoder.Factory {
    override fun create(
      result: SourceFetchResult,
      options: Options,
      imageLoader: ImageLoader,
    ): Decoder? {
      if (isApplicable(result)) {
        val path = result.source.file()
        val resources = options.context.resources

        return PdfPageDecoder(
          file = path.toFile(),
          resources = resources,
        )
      }

      return null
    }
  }

  companion object {
    fun isApplicable(result: SourceFetchResult): Boolean {
      return result.mimeType == "application/pdf"
    }
  }
}

The isApplicable check ensures this decoder is only used when the MIME type is "application/pdf"; otherwise, Coil will proceed to the next available decoder.

And finally, to test this, let's create sample composable and preview:

/**
 * Displays PDF file first page as image.
 *
 * @param url The image URL or `null`.
 * @param modifier Modifier used to adjust the layout algorithm or draw decoration content.
 */
@Composable
fun PdfView(
  url: String?,
  modifier: Modifier = Modifier,
) {
  val context = LocalContext.current
  val imageRequest = ImageRequest.Builder(context)
    .data(url)
    .crossfade(true)
    .fallback(R.drawable.ic_pdf_24)
    .placeholder(R.drawable.ic_pdf_24)
    .error(R.drawable.ic_broken_image_24)
    .decoderFactory(PdfPageDecoder.Factory())
    .build()

  AsyncImage(
    model = imageRequest,
    contentDescription = null,
    modifier = modifier,
    filterQuality = FilterQuality.High,
  )
}

@Preview(showBackground = true)
@Composable
private fun PdfViewPreview(@PreviewParameter(PdfFilePreviewParameterProvider::class) url: String?) {
  val name = remember(url) { url?.toUri()?.let { it.lastPathSegment ?: it.scheme } }

  Column(
    modifier = Modifier
      .fillMaxSize()
      .systemBarsPadding(),
    horizontalAlignment = Alignment.CenterHorizontally,
    verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
  ) {
    PdfView(
      url = url,
      modifier = Modifier
        .fillMaxWidth()
        .weight(1f),
    )

    BasicText("$name")
  }
}

class PdfFilePreviewParameterProvider : PreviewParameterProvider<String?> {

  override val values: Sequence<String?>
    get() = sequenceOf(
      null,
      "https://ontheline.trincoll.edu/images/bookdown/sample-local-pdf.pdf", // 50kB / 3 pages
      "https://pdfobject.com/pdf/sample.pdf", // 18 kB / 1 page
      "https://www.sldttc.org/allpdf/21583473018.pdf", // 1,5 MB / 4 pages
      "https://www.ncufc.org/uploads/large%20pdf%208MB.pdf", // 8 MB / 6 pages
      "https://research.nhm.org/pdfs/32460/32460-001.pdf", // 10 MB / 6 pages
      "https://www.learningcontainer.com/download/sample-50-mb-pdf-file/?wpdmdl=3675&refresh=69f46524326df1777624356", // 50 MB
    )
}

Snippet below shows the code how to preview the PDF from picked file using ActivityResultContracts.OpenDocument:

@Preview(showBackground = true)
@Composable
private fun PdfViewFromFilePickerPreview() {
  val fileTypes = remember { arrayOf("application/pdf") }
  var file: Uri? by rememberSaveable { mutableStateOf(null) }
  val launcher = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.OpenDocument(),
    onResult = { uri ->
      if (uri != null) {
        file = uri
      }
    }
  )
  LaunchedEffect(Unit) {
    launcher.launch(fileTypes)
  }

  PdfView(
    url = file.toString(),
    modifier = Modifier
      .fillMaxSize()
      .systemBarsPadding()
      .clickable {
        launcher.launch(fileTypes)
      },
  )
}

In this post, we've implemented a custom Coil decoder for PDF files, allowing us to easily display the first page of a PDF as a standard image in Jetpack Compose. This approach is lightweight and perfect for simple previews where full PDF viewer functionality is not required.

By leveraging PdfRenderer and Coil's extensible architecture, we can integrate PDF support into our existing image loading pipeline with minimal effort.

For the more advanced scenarios mentioned earlier, it’s worth integrating the Android PDF viewer from Google or alternatively exploring the Pdf-Viewer from Rajat Mittal.

Author: Matej Hlatký

Are you planning a new app?

Let's build something great together.

Wesley Brewer