官方组件
主要依赖PdfRenderer来进行读取,读取到会转换位bitmap,就可以展示到页面中去。然后使用PdfDocument来进行写回,这里主要是canvas的绘制。
简单制作一个绘制,然后将结果保存到文件的功能
PDF读取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
val resources = this.resources
val dm = resources.displayMetrics
val screenWidth = dm.widthPixels
val pdfFile = ParcelFileDescriptor.open(
File(filesDir.path + "/api.pdf"),
ParcelFileDescriptor.MODE_READ_ONLY
)
// pdf 总的高度
var totalHeight = 0F
// 存储pdf的页面bitmap
val pages = mutableStateListOf<Bitmap>()
// 构建pdf读取对象
val renderer = PdfRenderer(pdfFile)
// 记录每个页面的起始位置,方便后续会写
val itemOffset = mutableListOf<Float>()
itemOffset.add(0F)
repeat(renderer.pageCount) {
// 获取单页的pdf
val openPage = renderer.openPage(it)
// 计算pdf页面高度,因为这里使宽度等于屏幕宽度,所以长度也需要等比例变化
val height = screenWidth.toFloat() / openPage.width * openPage.height
totalHeight += height
itemOffset.add(totalHeight)
// 生成bitmap
val bitmap = Bitmap.createBitmap(screenWidth, height.toInt(), Bitmap.Config.ARGB_8888)
// 把pdf拷贝到bitmap上去
openPage.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
pages.add(bitmap)
openPage.close()
}
以上代码对pdf进行了简单的读取,作为演示demo,存在几个问题
- 一次性读取了所有页面,页面较多时,内存压力大,读取也较慢
- 只对比屏幕进行了对应大小的读取,所以没有缩放逻辑
绘制手势
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
var animationJob: Job? = null
setContent {
Pdf_testTheme {
// A surface container using the 'background' color from the theme
val lazyListState = rememberLazyListState()
var pointY by remember {
mutableFloatStateOf(0F)
}
val coroutineScope = rememberCoroutineScope()
val velocityTracker = remember { VelocityTracker.obtain() }
val path = remember { mutableStateListOf<Pair<Float, Float>>() }
val paths = remember { mutableStateListOf<List<Pair<Float, Float>>>() }
var isMove = remember { false }
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.pointerInteropFilter { event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
isMove = false
pointY = event.y
animationJob?.cancel()
animationJob = null
velocityTracker.clear()
path.clear()
path.add(event.x to (itemOffset[lazyListState.firstVisibleItemIndex] + lazyListState.firstVisibleItemScrollOffset) + event.y)
}
MotionEvent.ACTION_POINTER_DOWN -> {
isMove = true
velocityTracker.addMovement(event)
}
MotionEvent.ACTION_MOVE -> {
if (event.pointerCount > 1) {
val move = pointY - event.y
coroutineScope.launch {
lazyListState.scrollBy(move)
}
pointY = event.y
velocityTracker.addMovement(event)
} else {
if (isMove.not()) {
path.add(event.x to (itemOffset[lazyListState.firstVisibleItemIndex] + lazyListState.firstVisibleItemScrollOffset) + event.y)
}
}
}
MotionEvent.ACTION_UP -> {
if (isMove.not()) {
path.add(event.x to (itemOffset[lazyListState.firstVisibleItemIndex] + lazyListState.firstVisibleItemScrollOffset) + event.y)
paths.add(path.toList())
}
}
MotionEvent.ACTION_POINTER_UP -> {
velocityTracker.addMovement(event)
velocityTracker.computeCurrentVelocity(1000)
println(velocityTracker.yVelocity)
animationJob = coroutineScope.launch {
val decayAnimation = AnimationState(
initialValue = 0F,
initialVelocity = velocityTracker.yVelocity
)
val duration =
(abs(velocityTracker.yVelocity) / 15).roundToInt()
if (duration < 150) return@launch
var lastOffset = 0F
decayAnimation.animateTo(
velocityTracker.yVelocity,
animationSpec = tween(
maxOf(500, duration),
0,
LinearOutSlowInEasing
)
) {
val off = lastOffset - (decayAnimation.value)
coroutineScope.launch {
lazyListState.scrollBy(off)
}
lastOffset = decayAnimation.value
}
}
}
}
true
},
state = lazyListState,
userScrollEnabled = false
) {
items(pages.size) {
Image(
bitmap = pages[it].asImageBitmap(),
contentDescription = ""
)
}
}
Canvas(modifier = Modifier
.fillMaxSize(), onDraw = {
paths.forEach { item ->
if (item.size > 1) {
val offset =
(itemOffset[lazyListState.firstVisibleItemIndex] + lazyListState.firstVisibleItemScrollOffset)
val drawPath = Path()
drawPath.moveTo(item[0].first, item[0].second - offset)
for (i in 1 until item.size) {
drawPath.lineTo(item[i].first, item[i].second - offset)
}
drawPath(
drawPath,
Color.Red,
style = Stroke(width = 5F)
)
}
}
if (path.size > 1) {
val offset =
(itemOffset[lazyListState.firstVisibleItemIndex] + lazyListState.firstVisibleItemScrollOffset)
val drawPath = Path()
drawPath.moveTo(path[0].first, path[0].second - offset)
for (i in 1 until path.size) {
drawPath.lineTo(path[i].first, path[i].second - offset)
}
drawPath(
drawPath,
Color.Red,
style = Stroke(width = 5F)
)
}
})
}
}
}
使用compose ui来实现的,底部用LazyColumn来控制页面的展示,上面一层用Canvas来展示一下绘制的UI,自定义了手势,因为需要支持双指滑动,单手指是绘画。上面代码写的也比较啰嗦,如果用pointerInput来实现应该会更简单,主要记录一下pointerInteropFilter的代码,可以看到和Android View那套比较接近。
所以如果要直接使用,大概有一部分的优化
-
pointerInteropFilter替换成pointerInput来实现。 - 绘制部分的优化,因为这里是不管显示与否,都进行了轨迹的绘制,不太有必要。
写回PDF
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
val pdfDocument = PdfDocument()
repeat(renderer.pageCount) {
val openPage = renderer.openPage(it)
val pageInfo =
PdfDocument.PageInfo.Builder(openPage.width, openPage.height, it)
.create()
val page = pdfDocument.startPage(pageInfo)
val scale = openPage.width.toFloat() / screenWidth.toFloat()
val canvas = page.canvas
canvas.scale(scale, scale)
canvas.drawBitmap(pages[it], 0F, 0F, Paint())
paths.forEach { item ->
if (item.size > 1) {
val offset = (itemOffset[it])
val drawPath = android.graphics.Path()
drawPath.moveTo(item[0].first, item[0].second - offset)
for (i in 1 until item.size) {
drawPath.lineTo(item[i].first, item[i].second - offset)
}
canvas.drawPath(
drawPath,
Paint().apply {
this.color = 0xFFFF0000.toInt()
style = Paint.Style.STROKE
strokeWidth = 5F
}
)
}
}
pdfDocument.finishPage(page)
openPage.close()
}
FileOutputStream(filesDir.path + "/api_result.pdf").use {
pdfDocument.writeTo(it)
}
写回去的代码比较简单,和打开以及Canvas绘制那部分很像。
小总结
官方的用起来比较方便,但是功能支持很少,不支持密码打开,打开有密码的文件会直接抛出异常
1
2
3
4
5
6
/**
* @throws java.io.IOException If an error occurs while reading the file.
* @throws java.lang.SecurityException If the file requires a password or
* the security scheme is not supported.
*/
public PdfRenderer(@NonNull ParcelFileDescriptor input) throws IOException
另外就是文件写入只能使用canvas,pdf对拷贝过后,会变的很大,而且失去了文本格式的pdf支持的无损放大能力。

AndroidPdfViewer
DImuthuUpe/AndroidPdfViewer - github
这个主要是对PDF阅读进行支持,支持pdf的滚动,缩放,以及对内存也有优化,不是一次性加载的。
implementation("com.github.barteksc:android-pdf-viewer:2.8.2")
itextpdf
对PDF进行写入,这个的PDF拷贝还会对格式进行优化,使体积更小,也支持PDF的绘制,也是基于Bitmap的
implementation("com.itextpdf:kernel:8.0.5")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
data class PathItem(
val positions: List<Pair<Float, Float>>
) {
val top: Float = positions.minOf { it.second }
val bottom: Float = positions.maxOf { it.second }
val start: Float = positions.minOf { it.first }
val end: Float = positions.maxOf { it.first }
}
fun write(basePath: String, disWidth: Float, paths: List<PathItem>) {
val pdfReader = PdfReader("$basePath/api.pdf")
val pdfWriter = PdfWriter("$basePath/api_tmp.pdf")
val pdfDeaderDoc = PdfDocument(pdfReader)
val pdfWriterDoc = PdfDocument(pdfWriter)
var totalHeight = 0.0
for (i in 1..pdfDeaderDoc.numberOfPages) {
val pdfPage = pdfDeaderDoc.getPage(i)
val addPage = pdfWriterDoc.addNewPage()
// 页面拷贝
val pageCopy: PdfFormXObject = pdfPage.copyAsFormXObject(pdfWriterDoc)
val canvas = PdfCanvas(addPage)
canvas.addXObject(pageCopy)
// canvas绘制
val pageHeight = pageCopy.height
val scale = pageCopy.width / disWidth
canvas.setColor(
Color.convertRgbToCmyk(DeviceRgb(255, 0, 0)),
false
)
canvas.setLineWidth(5F * scale)
paths.forEach { item ->
if (item.bottom * scale - totalHeight >= 0 && item.top * scale - totalHeight <= pageHeight)
if (item.positions.isNotEmpty()) {
canvas.moveTo(
(item.positions[0].first.toDouble() * scale),
(pageHeight - (item.positions[0].second * scale - totalHeight))
)
for (y in 1 until item.positions.size) {
canvas.lineTo(
(item.positions[y].first.toDouble() * scale),
(pageHeight - (item.positions[y].second * scale - totalHeight))
)
}
}
}
totalHeight += pageHeight
canvas.stroke()
canvas.closePath()
}
pdfDeaderDoc.close()
pdfWriterDoc.close()
}
PathItem用来处理不必要绘制的问题,这里write方法主要也是处理宽高比例,以及缩放,值得注意的是在itextpdf中坐标系原点为左下角,和一般canvas中的左上角有出入,需要用高减一下。可以看到体积还缩小了和使用wps的pdf优化差不多。
