android pdf_在Android上轻松呈现PDF

android pdf

Developers or not, most of us are familiar with PDFs and are used to work with them all the time, so rendering PDFs may seem like an ordinary task that any platform should be able to perform with minimal effort. Surprisingly, that's not exactly the case on Android. Browsers (including WebViews) struggle to render PDFs there, and the task is usually delegated to a separate specialized app.

是否开发人员,我们大多数人都熟悉PDF,并且一直都在使用PDF,因此呈现PDF似乎是一项普通的任务,任何平台都应该能够以最小的努力来执行。 令人惊讶的是,Android并非如此。 浏览器(包括WebView)难以在其中呈现PDF,并且通常将任务委派给单独的专用应用程序。

For a long time there was no easy way to render a PDF inside our apps and we had to rely on questionable solutions. A few years ago, the popular AndroidPdfViewer took the stage and things got better, but we still had to pay an expensive price: an extra 15 MB in our APK size (which can be reduced, though, especially today with app bundles). There are also many paid options out there, but I'm here to talk about the great PdfRenderer.

长期以来,在我们的应用程序中一直没有简便的方法来呈现PDF,我们不得不依靠可疑的 解决方案 。 几年前,流行的AndroidPdfViewer登台,情况变得更好,但我们仍然要付出昂贵的代价: APK大小额外增加15 MB (不过可以减小,尤其是在今天使用应用程序捆绑包时 )。 也有很多 支付 选择 那里 ,但我在这里要说说伟大PdfRenderer

Starting on API 21, we can use PdfRenderer as an abstraction on top of PDFium — same as AndroidPdfViewer but without the APK size hit or the need of any extra library at all. The documentation around it is good but brief, and there aren't many examples around, so the goal here is to walk through the process of rendering a PDF as a RecyclerView so you can scroll through the pages as you'd expect to whenever interacting with a PDF file in a mobile device.

从API 21开始,我们可以将PdfRenderer用作PdfRenderer的抽象-与AndroidPdfViewer相同,但不影响APK大小或根本不需要任何额外的库。 关于它的文档很好但是很简短,并且周围没有很多示例,因此这里的目标是逐步完成将PDF呈现为RecyclerView的过程,以便您可以在交互时按预期滚动页面。在移动设备中使用PDF文件。

TL; DR: (TL;DR:)

Check this gist with the final version of the most relevant snippets mentioned in the article.

通过本文中提到的最相关的代码段的最终版本来检查要点

基础 (The basics)

Let's assume we have a PDF file available in a given filePath. This is how we can create a PdfRenderer:

假设在给定的filePath有一个PDF文件可用。 这就是我们创建PdfRenderer

val input = ParcelFileDescriptor.open(File(filePath), ParcelFileDescriptor.MODE_READ_ONLY)
val renderer = PdfRenderer(input)

Nothing particularly interesting about the ParcelFileDescriptor there, and that's most likely how you'll want to create it every time. Now that we have the renderer, we can open pages and render them individually. There are a few important things here:

那里的ParcelFileDescriptor没什么特别有趣的,这很可能是您每次想要创建它的方式。 现在有了renderer ,我们可以打开页面并分别渲染它们。 这里有一些重要的事情:

  • We're responsible for closing the renderer and each page we open.

    我们负责关闭renderer和打开的每个页面。

  • We can have only a single page open at any given time.

    在任何给定时间,我们只能打开一个页面。
  • The current page needs to be closed before we can close the renderer.

    在关闭renderer之前,需要关闭当前页面。

With that in mind, this is how we'd render the first page of our PDF file:

考虑到这一点,这就是我们渲染PDF文件首页的方式:

val page = renderer.openPage(0)
val bitmap = Bitmap.createBitmap(someWidth, someHeight, Bitmap.Config.ARGB_8888)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)


// do something with the bitmap, like putting it on an ImageView


page.close()
renderer.close()

First line is easy, whenever we want to open a page we can simply call openPage() and pass the index of the page we want to open.

第一行很容易,每当我们要打开页面时,我们都可以简单地调用openPage()并传递要打开的页面的索引。

The bitmap creation is a bit more interesting. We're passing Bitmap.Config.ARGB_8888 as the Config, and even though there are other configurations available, that's our only option when we're working with the PdfRenderer:

位图的创建更加有趣。 我们将Bitmap.Config.ARGB_8888作为Config传递,即使有其他配置可用,这也是我们在使用 PdfRenderer 的唯一选择:

if (destination.getConfig() != Config.ARGB_8888) {
  throw new IllegalArgumentException("Unsupported pixel format");
}

We also need to define the width and height of our Bitmap. A PdfRenderer.Page has a width and height, but they're measured in points, while the width and height we pass when we create a Bitmap should be in pixels, so we definitely don't want to use our page's width and height when creating our Bitmap. Furthermore, even if the page size was in pixel, that's not the dimension we should use to determine our Bitmap's size. We should instead look at where we'll be displaying it and take the dimensions there so we create a Bitmap that will nicely fit its destination.

我们还需要定义BitmapwidthheightPdfRenderer.Page具有widthheight ,但是它们以点为单位,而当我们创建Bitmap时传递的widthheight应该以像素为单位,因此我们绝对不希望在以下情况下使用页面的width和height:创建我们的Bitmap 。 此外,即使页面大小为像素,这也不是我们确定Bitmap大小时应使用的尺寸。 相反,我们应该查看将在何处显示它并在其中获取尺寸,以便我们创建一个恰好适合其目的地的Bitmap

In our case (and I'd guess in most cases), we want the PDF to fit the whole width of the screen (maybe minus some margins) and the height should be whatever is necessary to maintain the aspect ratio. This is how we could achieve that:

在我们的情况下(大多数情况下,我猜是这样),我们希望PDF可以适合屏幕的整个宽度(可能减去一些边距),并且高度应该是保持纵横比所必需的。 这是我们可以实现的方式:

val bitmap = Bitmap.createBitmap(
  screenWidth, (screenWidth.toFloat() / page.width * page.height).toInt(), Bitmap.Config.ARGB_8888
)

We use the page's original dimensions just to make sure we set a height that will respect the page's aspect ratio. Going back to our example, this is what we have now:

我们使用页面的原始尺寸只是为了确保我们设置的高度会尊重页面的长宽比。 回到我们的例子,这就是我们现在所拥有的:

val page = renderer.openPage(0)
val bitmap = Bitmap.createBitmap(
  screenWidth, (screenWidth.toFloat() / page.width * page.height).toInt(), Bitmap.Config.ARGB_8888
)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)


// do something with the bitmap, like putting it on an ImageView


page.close()
renderer.close()

We've been through the first two statements and the last two are pretty self-explanatory, so let's talk about that render() call. This is where the magic happens and we turn our PDF page into an image that we can display wherever we want. The first parameter there is our bitmap, the second and third are the clip and transformation:

我们已经完成了前两个语句,后两个语句很不言自明,所以让我们来谈谈render()调用。 这就是神奇的地方,我们将PDF页面转换为可以在所需位置显示的图像。 第一个参数是位图,第二个和第三个是剪辑和变换:

The clip and transformation are useful for implementing tile rendering where the destination bitmap contains a portion of the image, for example when zooming. Another useful application is for printing where the size of the bitmap holding the page is too large and a client can render the page in stripes.

剪辑和变换对于在目标位图包含图像一部分的情况下(例如在缩放时)实现图块渲染很有用。 另一个有用的应用程序是用于打印,其中保存页面的位图的大小太大,客户端可以将页面呈现为条纹。

Not particularly interesting for our simple scenario, so those two nulls will do. For the last parameter we have two options: RENDER_MODE_FOR_DISPLAY and RENDER_MODE_FOR_PRINT. If we look at the native code behind this, these two constants are basically translated into two render flags: FPDF_LCD_TEXT and FPDF_PRINTING:

对于我们的简单场景而言,这并不是特别有趣,因此这两个null会起作用。 对于最后一个参数,我们有两个选项: RENDER_MODE_FOR_DISPLAYRENDER_MODE_FOR_PRINT 。 如果我们看一下其背后的本机代码 ,则这两个常量基本上会转换为两个渲染标志: FPDF_LCD_TEXTFPDF_PRINTING

if (renderMode == RENDER_MODE_FOR_DISPLAY) {
  renderFlags |= FPDF_LCD_TEXT;
} else if (renderMode == RENDER_MODE_FOR_PRINT) {
  renderFlags |= FPDF_PRINTING;
}


...


// Set if using text rendering optimized for LCD display.
#define FPDF_LCD_TEXT 0x02
...
// Render for printing.
#define FPDF_PRINTING 0x800

Since we want to display the PDF in our app, we definitely want that LCD rendering optimization, so that's an easy choice. With everything in place, we can take the resulting Bitmap and place it in an ImageView with a simple setImageBitmap() call!

由于我们要在应用程序中显示PDF,因此我们绝对希望进行LCD渲染优化,这是一个简单的选择。 一切就绪后,我们可以通过简单的setImageBitmap()调用获取生成的Bitmap并将其放置在ImageView

We've been neglecting threading so far, but whenever we're dealing with files and Bitmaps it's usually a good idea to offload the work to a background thread.

到目前为止,我们一直在忽略线程,但是每当我们处理文件和Bitmap ,通常最好将工作卸载到后台线程。

Loading bitmaps on the UI thread can degrade your app’s performance, causing slow responsiveness or even ANR messages. It is therefore important to manage threading appropriately when working with bitmaps.

在UI线程上加载位图可能会降低应用程序的性能,从而导致响应速度变慢甚至ANR消息。 因此,在使用位图时,适当地管理线程很重要。

PdfRenderer's docs aren't explicit about this, so I'm relying on the platform's best practices — use your best judgement and benchmark your app. If you're using coroutines, this would be as simple as turning what we have so far into a suspend function and wrapping it in withContext(Dispatchers.IO), but feel free to achieve that with your preferred tool.

PdfRenderer的文档对此并不明确,因此我依赖于平台的最佳实践-使用您的最佳判断力并对应用进行基准测试。 如果您使用的是协程,这就像将我们现有的功能转换为suspend功能并将其包装在withContext ( Dispatchers.IO ) ,但是可以使用您喜欢的工具随意实现。

This covers the basics but is pretty promising. Even though there's very little code involved, this already works really well. But there are a few interesting points that go a little beyond that.

这涵盖了基础知识,但很有希望。 即使所涉及的代码很少,这也已经可以很好地工作了。 但是,还有一些有趣的地方。

透明PDF (Transparent PDFs)

If you run that code and your PDF is transparent (which is way more common than I'd ever suspect), you're gonna have a bad time if you expect to see a white page being rendered. So it's a good idea to play safe and ensure we have a background color set to handle these cases.

如果您运行该代码并且PDF是透明的(这比我以前想像的要普遍得多),那么如果您希望看到呈现的是白页,那将是一段糟糕的时光。 因此,安全播放并确保设置背景色来处理这些情况是个好主意。

The solution here is pretty straightforward:

这里的解决方案非常简单:

val canvas = Canvas(bitmap)
canvas.drawColor(Color.WHITE)
canvas.drawBitmap(bitmap, 0f, 0f, null)

That will ensure that we have a white background in case the PDF is transparent, which is what happens in most (all?) PDF readers. It'd be nice if this was handled by the PdfRenderer itself, but it's a small burden we can carry.

如果PDF是透明的,那将确保我们具有白色背景,这是大多数(全部)PDF阅读器中发生的情况。 如果这是由PdfRenderer本身处理的, PdfRenderer ,但这是我们可以承受的小负担。

变焦支持 (Zoom support)

People expect to be able to pinch to zoom when they're interacting with a PDF file on a mobile device. The easiest way to achieve that is to resort to your favorite ImageView zoom solution — PhotoView is probably the easiest choice. If you place the Bitmap you get from the PdfRenderer into a PhotoView, zooming in and out should work like a charm.

人们希望与移动设备上的PDF文件进行交互时可以捏放大。 实现该目标的最简单方法是诉诸于您最喜欢的ImageView缩放解决方案— PhotoView可能是最简单的选择。 如果将从PdfRenderer获得的Bitmap PdfRendererPhotoView ,则放大和缩小应该像一种魅力。

Since we're working with a Bitmap, we won't get the super crisp result we're used to with PDF readers, but it'll hopefully be good enough for your application. If zooming is really important for your use case, one easy option to improve this is to render larger Bitmaps. Proper zoom support would require some extra effort: we'd have to write our own zoom logic by taking advantage of the clip and transformation parameters that we've ignored so far.

由于我们正在使用Bitmap ,因此无法获得与PDF阅读器一样的超清晰结果,但希望它对您的应用程序足够好。 如果缩放对于您的用例确实很重要,则改善此问题的一个简单方法是渲染更大的Bitmap 。 正确的缩放支持将需要一些额外的工作:我们必须利用到目前为止我们忽略的剪辑和转换参数来编写自己的缩放逻辑。

PDF文件作为RecyclerView (PDF file as a RecyclerView)

Based on what we've seen so far, let's define some extensions to make things easier for us:

根据到目前为止所看到的内容,让我们定义一些扩展以使我们更轻松:

fun PdfRenderer.Page.renderAndClose(width: Int) = use {
  val bitmap = createBitmap(width)
  render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
  bitmap
}


private fun PdfRenderer.Page.createBitmap(bitmapWidth: Int): Bitmap {
  val bitmap = Bitmap.createBitmap(
    bitmapWidth, (bitmapWidth.toFloat() / width * height).toInt(), Bitmap.Config.ARGB_8888
  )


  val canvas = Canvas(bitmap)
  canvas.drawColor(Color.WHITE)
  canvas.drawBitmap(bitmap, 0f, 0f, null)


  return bitmap
}

If we just want to render the first page of the PDF as a preview, we can define this other function to help us out:

如果我们只想将PDF的第一页呈现为预览,则可以定义此其他函数来帮助我们:

suspend fun renderSinglePage(filePath: String, width: Int) = withContext(Dispatchers.IO) {
  PdfRenderer(ParcelFileDescriptor.open(File(filePath), ParcelFileDescriptor.MODE_READ_ONLY)).use { renderer ->
    renderer.openPage(0).renderAndClose(width)
  }
}

But now let's render each page of the PDF as a RecyclerView item. Our item will be a simple ImageView:

但是现在让我们将PDF的每一页呈现为RecyclerView项。 我们的项目将是一个简单的ImageView

<?xml version="1.0" encoding="utf-8"?>
<ImageView
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:id="@+id/imageView"
  android:layout_width="match_parent"
  android:layout_height="wrap_content"
  android:layout_marginVertical="8dp"
  android:importantForAccessibility="no"
  android:scaleType="fitCenter"
  />

And this is how our adapter could look like:

这就是我们的适配器的外观:

class PdfAdapter(
  // this would come from the ViewModel so we don't need to recreate it on config change 
  // and the VM can close it within onCleared()
  private val renderer: PdfRenderer,
  // this would come from the Activity/Fragment based on the display metrics
  private val pageWidth: Int
) : RecyclerView.Adapter<PdfAdapter.ViewHolder>() {


  class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    fun bind(bitmap: Bitmap) = (itemView as ImageView).setImageBitmap(bitmap)
  }


  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
    ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.pdf_page_item, parent, false))


  override fun getItemCount() = renderer.pageCount


  override fun onBindViewHolder(holder: ViewHolder, position: Int) = 
    holder.bind(renderer.openPage(position).renderAndClose(pageWidth))
}

We're receiving a ready to use PdfRenderer and the pageWidth. We could also receive the filePath and create the PdfRenderer ourselves, but it's usually easier to manage it outside the adapter. The ViewHolder is extremely straightforward: it simply receives the rendered Bitmap and places it into its ImageView. And the adapter itself isn't bad either:

我们已经准备好使用PdfRendererpageWidth 。 我们也可以接收filePath并自己创建PdfRenderer ,但是通常可以在适配器外部进行管理。 ViewHolder非常简单:仅接收渲染的Bitmap并将其放入其ImageView 。 适配器本身也不错:

  1. We implement getItemCount() with a simple pageCount call;

    我们通过一个简单的pageCount调用来实现getItemCount()

  2. And for each ViewHolder, we open, render, and close a page.

    对于每个ViewHolder ,我们打开,呈现和关闭页面。

And that's it! ✨

就是这样! ✨

…or is it? What about threading again? It can get really tricky to get threading right here. We can't have a naive implementation in place since we can't ever have two pages open at the same time. If we simply offload the rendering work to a thread pool (e.g. Dispatchers.IO), we run the risk of trying to open a page in one thread while a different thread is still rendering another one, causing this:

…还是? 再次线程化呢? 在这里进行线程穿线真的很棘手。 由于无法同时打开两个页面,因此无法实现幼稚的实现。 如果仅将渲染工作卸载到线程池(例如Dispatchers.IO ),则存在尝试在一个线程中打开页面而另一个线程仍在渲染另一个线程的风险,这可能导致:

java.lang.IllegalStateException: Current page not closed

java.lang.IllegalStateException:当前页面未关闭

An alternative is to offload work to a single background thread (something like an Executors.newSingleThreadExecutor().asCoroutineDispatcher() as suggested here), but we still need to make sure we don't close our PdfRenderer if there are still open pages.

另一种选择是将工作卸载到单个后台线程(类似于此处建议的Executors . newSingleThreadExecutor() . asCoroutineDispatcher() ),但是如果仍然有打开的页面,我们仍然需要确保不关闭PdfRenderer

I'll leave this as an exercise to the reader — I'm actually still experimenting here. The good news is that in many cases you can probably get away with rendering on the main thread, so make sure to profile your app and do the right thing for you here.

我会将其作为练习留给读者-实际上,我仍在这里进行实验。 好消息是,在许多情况下,您可能可以摆脱主线程上的渲染,因此请确保对您的应用程序进行配置并在此处为您做正确的事情。

And what about zooming? It'd be really hard to implement a way to zoom in and out of the whole RecyclerView, unfortunately. So an easy solution is to allow users to click on a page and go to a screen where only that page is shown, so we can use a simple PhotoView to enable zoom support. Make sure to reuse the Bitmap used in the RecyclerView to avoid rendering it again — it might be necessary to avoid navigating to a new fragment/activity since it wouldn't be possible to pass the Bitmap as an Intent extra or fragment argument.

那变焦呢? 不幸的是,要实现一种放大和缩小整个RecyclerView的方法真的很难。 因此,一种简单的解决方案是允许用户单击页面并转到仅显示该页面的屏幕,因此我们可以使用简单的PhotoView来启用缩放支持。 确保重用RecyclerView使用的Bitmap ,以避免再次呈现它-可能有必要避免导航到新的片段/活动,因为不可能将Bitmap作为Intent extra或fragment参数传递。

PdfRenderer限制 (PdfRenderer limitations)

The PdfRenderer isn't meant to be a full blown PDF solution. It'll do a good job on simple cases but that's pretty much it. If you need to cover more advanced cases, it probably won't be enough for you. As an example, it doesn't support annotations and it has issues dealing with password protected and corrupted files. Keep that in mind while you’re working with it, and for more information around those cases, make sure to check Muthu Raj's comment.

PdfRenderer并不是完整的PDF解决方案。 在简单的情况下,它将做得很好,但仅此而已。 如果您需要处理更高级的案例,可能对您来说还不够。 例如,它不支持注释,并且在处理受密码保护和损坏的文件时遇到问题。 使用它时请记住这一点,有关这些情况的更多信息,请确保检查Muthu Raj的评论

附加:下载PDF文件 (Extra: downloading a PDF file)

This is a bit off-topic, but just in case it might be interesting to anyone, I'm dumping a reasonable snippet for downloading a PDF file with Retrofit.

这有点题外话,但万一它可能对任何人都感兴趣,我将转储一个合理的代码段 ,以使用Retrofit下载PDF文件。

This was the first time I had to work with PDF files on Android, so even though what I'm presenting here has been battle tested in production, these are fresh ideas from a PDF apprentice. Let me know if you see any room for improvements and hit me up here or on Twitter!

这是我第一次必须在Android上使用PDF文件,因此,即使我在这里介绍的内容已经在生产中经过了实战测试,这些都是PDF学徒的新鲜想法。 让我知道您是否还有任何改进的余地,请点击此处或在Twitter上与我联系!

翻译自: https://proandroiddev.com/rendering-pdfs-on-android-the-easy-way-c05635b2c3a8

android pdf

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值