添加搜索文字测试代码,后续看是否使用。(未完成)
This commit is contained in:
parent
d9dfa75b9b
commit
8e4c25d54f
@ -105,13 +105,18 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:screenOrientation="portrait" />
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.act.PdfToImageActivity"
|
android:name=".ui.act.PdfToImageActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:screenOrientation="portrait" />
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".ui.act.PdfTextSearchTestActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
@ -0,0 +1,226 @@
|
|||||||
|
package com.all.pdfreader.pro.app.ui.act
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.all.pdfreader.pro.app.databinding.ActivityPdfViewerTestBinding
|
||||||
|
import com.all.pdfreader.pro.app.ui.adapter.SearchResultsAdapter
|
||||||
|
import com.all.pdfreader.pro.app.util.PDFHighlighter
|
||||||
|
import com.all.pdfreader.pro.app.util.PDFSearchManager
|
||||||
|
import com.github.barteksc.pdfviewer.listener.OnPageChangeListener
|
||||||
|
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class PdfTextSearchTestActivity : BaseActivity() {
|
||||||
|
override val TAG: String = "PdfTextSearchTestActivity"
|
||||||
|
private lateinit var binding: ActivityPdfViewerTestBinding
|
||||||
|
private lateinit var searchManager: PDFSearchManager
|
||||||
|
private lateinit var highlighter: PDFHighlighter
|
||||||
|
|
||||||
|
private var pdfFile: File? = null
|
||||||
|
private val searchResults = mutableListOf<PDFSearchManager.TextSearchResult>()
|
||||||
|
private lateinit var adapter: SearchResultsAdapter
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
binding = ActivityPdfViewerTestBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding.root)
|
||||||
|
val pdfPath = intent.getStringExtra("pdf_path") ?: ""
|
||||||
|
if (pdfPath.isEmpty()) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pdfFile = File(pdfPath)
|
||||||
|
initViews()
|
||||||
|
setupPDFViewer()
|
||||||
|
loadPDF()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initViews() {
|
||||||
|
adapter = SearchResultsAdapter(searchResults) { result ->
|
||||||
|
onSearchResultClicked(result, true)
|
||||||
|
}
|
||||||
|
binding.searchResultsRecyclerView.adapter = adapter
|
||||||
|
binding.searchResultsRecyclerView.layoutManager = LinearLayoutManager(this)
|
||||||
|
|
||||||
|
binding.searchButton.alpha = 0.5f
|
||||||
|
binding.searchButton.isEnabled = false
|
||||||
|
binding.searchButton.setOnClickListener {
|
||||||
|
performSearch()
|
||||||
|
}
|
||||||
|
binding.clearButton.setOnClickListener {
|
||||||
|
clearHighlights()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupPDFViewer() {
|
||||||
|
searchManager = PDFSearchManager(binding.pdfView)
|
||||||
|
highlighter = PDFHighlighter(binding.pdfView, searchManager)
|
||||||
|
|
||||||
|
initSearchDocument()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initSearchDocument() {
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
searchManager.getDocument(
|
||||||
|
pdfFile!!,
|
||||||
|
onLoaded = { doc ->
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
checkPageHasText(doc, binding.pdfView.currentPage)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError = { e ->
|
||||||
|
runOnUiThread {
|
||||||
|
binding.searchButton.alpha = 0.5f
|
||||||
|
binding.searchButton.isEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步检查指定页是否有文字并更新按钮
|
||||||
|
*/
|
||||||
|
private suspend fun checkPageHasText(doc: PDDocument, pageIndex: Int): Boolean {
|
||||||
|
val hasText = searchManager.pageHasText(doc, pageIndex, minChars = 8) // 可调整阈值
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (hasText) {
|
||||||
|
binding.searchButton.alpha = 1f
|
||||||
|
binding.searchButton.isEnabled = true
|
||||||
|
} else {
|
||||||
|
binding.searchButton.alpha = 0.5f
|
||||||
|
binding.searchButton.isEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasText
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pageCheckJob: Job? = null
|
||||||
|
|
||||||
|
private fun loadPDF() {
|
||||||
|
binding.pdfView.fromFile(pdfFile)
|
||||||
|
.enableSwipe(true)
|
||||||
|
.swipeHorizontal(true)
|
||||||
|
.enableDoubletap(true)
|
||||||
|
.defaultPage(0)
|
||||||
|
.onDraw(highlighter) // 设置绘制监听器
|
||||||
|
.enableAnnotationRendering(true)
|
||||||
|
.password(null)
|
||||||
|
.scrollHandle(null)
|
||||||
|
.swipeHorizontal(false)
|
||||||
|
.enableAntialiasing(true)
|
||||||
|
.onPageChange(object : OnPageChangeListener {
|
||||||
|
override fun onPageChanged(page: Int, pageCount: Int) {
|
||||||
|
logDebug("page->$page")
|
||||||
|
logDebug("pageCount->$pageCount")
|
||||||
|
val doc = searchManager.getCachedDoc()
|
||||||
|
if (doc != null) {
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
pageCheckJob?.cancel()
|
||||||
|
pageCheckJob = lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
delay(50) // 防抖
|
||||||
|
val hasText = checkPageHasText(doc, page)
|
||||||
|
//确定这页有文字,是否进行搜索过,当前搜索的文字
|
||||||
|
if (hasText && searchManager.hasSearched && searchManager.currentSearchText != null) {
|
||||||
|
searchTextIng(
|
||||||
|
pdfFile!!,
|
||||||
|
searchManager.currentSearchText ?: "",
|
||||||
|
binding.pdfView.currentPage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.spacing(0)
|
||||||
|
.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performSearch() {
|
||||||
|
val searchText = binding.searchEditText.text.toString().trim()
|
||||||
|
if (searchText.isEmpty()) {
|
||||||
|
Toast.makeText(this, "请输入搜索关键词", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val file = pdfFile ?: return
|
||||||
|
|
||||||
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
searchTextIng(file, searchText, binding.pdfView.currentPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchTextIng(pdfFile: File, key: String, targetPage: Int? = null) {
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val results = searchManager.searchText(pdfFile, key, targetPage)
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
searchResults.clear()
|
||||||
|
searchResults.addAll(results)
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
|
||||||
|
if (!results.isEmpty()) {
|
||||||
|
results.firstOrNull()?.let { firstResult ->
|
||||||
|
onSearchResultClicked(firstResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onSearchResultClicked(
|
||||||
|
result: PDFSearchManager.TextSearchResult,
|
||||||
|
isJumpTo: Boolean = false
|
||||||
|
) {
|
||||||
|
if (isJumpTo) {
|
||||||
|
// 跳转到对应页面 (PDFView 页面索引从0开始)
|
||||||
|
binding.pdfView.jumpTo(result.pageNumber - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置高亮
|
||||||
|
highlighter.setCurrentPageHighlights(result.pageNumber)
|
||||||
|
|
||||||
|
// 标记已搜索高亮
|
||||||
|
searchManager.currentSearchText = result.text
|
||||||
|
searchManager.hasSearched = true
|
||||||
|
|
||||||
|
// 可选:滚动到搜索结果列表中的对应项
|
||||||
|
val index = searchResults.indexOf(result)
|
||||||
|
if (index != -1) {
|
||||||
|
binding.searchResultsRecyclerView.scrollToPosition(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearHighlights() {
|
||||||
|
searchManager.clearHighlights()
|
||||||
|
highlighter.clearCurrentHighlights()
|
||||||
|
searchResults.clear()
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
searchManager.closeCachedDocument()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -276,7 +276,9 @@ class PdfViewActivity : BaseActivity(), OnLoadCompleteListener, OnPageChangeList
|
|||||||
onTap(this@PdfViewActivity) // 单击回调
|
onTap(this@PdfViewActivity) // 单击回调
|
||||||
onPageChange(this@PdfViewActivity) // 页面改变回调
|
onPageChange(this@PdfViewActivity) // 页面改变回调
|
||||||
scrollHandle(CustomScrollHandle(this@PdfViewActivity)) // 自定义的页数展示
|
scrollHandle(CustomScrollHandle(this@PdfViewActivity)) // 自定义的页数展示
|
||||||
|
onDraw { canvas, pageWidth, pageHeight, displayedPage ->
|
||||||
|
|
||||||
|
}
|
||||||
if (appStore.isPageFling) {
|
if (appStore.isPageFling) {
|
||||||
pageSnap(true)//页面可在逐页中,可居中展示,非逐页模式,滑动停止后可自动定格在居中位置。
|
pageSnap(true)//页面可在逐页中,可居中展示,非逐页模式,滑动停止后可自动定格在居中位置。
|
||||||
pageFling(true)//逐页
|
pageFling(true)//逐页
|
||||||
|
|||||||
@ -0,0 +1,41 @@
|
|||||||
|
package com.all.pdfreader.pro.app.ui.adapter
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.all.pdfreader.pro.app.R
|
||||||
|
import com.all.pdfreader.pro.app.util.PDFSearchManager
|
||||||
|
|
||||||
|
class SearchResultsAdapter(
|
||||||
|
private val results: List<PDFSearchManager.TextSearchResult>,
|
||||||
|
private val onItemClick: (PDFSearchManager.TextSearchResult) -> Unit
|
||||||
|
) : RecyclerView.Adapter<SearchResultsAdapter.ViewHolder>() {
|
||||||
|
|
||||||
|
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
|
val pageText: TextView = view.findViewById(R.id.pageText)
|
||||||
|
val matchCount: TextView = view.findViewById(R.id.matchCount)
|
||||||
|
val previewText: TextView = view.findViewById(R.id.previewText)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
val view = LayoutInflater.from(parent.context)
|
||||||
|
.inflate(R.layout.item_search_result, parent, false)
|
||||||
|
return ViewHolder(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
|
val result = results[position]
|
||||||
|
|
||||||
|
holder.pageText.text = "第 ${result.pageNumber} 页"
|
||||||
|
holder.matchCount.text = "${result.positions.size} 处匹配"
|
||||||
|
holder.previewText.text = "搜索: \"${result.text}\""
|
||||||
|
|
||||||
|
holder.itemView.setOnClickListener {
|
||||||
|
onItemClick(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount() = results.size
|
||||||
|
}
|
||||||
@ -1,13 +1,12 @@
|
|||||||
package com.all.pdfreader.pro.app.ui.fragment
|
package com.all.pdfreader.pro.app.ui.fragment
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import com.all.pdfreader.pro.app.databinding.FragmentHomeBinding
|
import com.all.pdfreader.pro.app.databinding.FragmentHomeBinding
|
||||||
import com.all.pdfreader.pro.app.model.FragmentType
|
import com.all.pdfreader.pro.app.model.FragmentType
|
||||||
@ -15,6 +14,7 @@ import com.all.pdfreader.pro.app.model.SortConfig
|
|||||||
import com.all.pdfreader.pro.app.room.entity.PdfDocumentEntity
|
import com.all.pdfreader.pro.app.room.entity.PdfDocumentEntity
|
||||||
import com.all.pdfreader.pro.app.room.repository.PdfRepository
|
import com.all.pdfreader.pro.app.room.repository.PdfRepository
|
||||||
import com.all.pdfreader.pro.app.ui.act.MainActivity
|
import com.all.pdfreader.pro.app.ui.act.MainActivity
|
||||||
|
import com.all.pdfreader.pro.app.ui.act.PdfTextSearchTestActivity
|
||||||
import com.all.pdfreader.pro.app.ui.act.PdfViewActivity
|
import com.all.pdfreader.pro.app.ui.act.PdfViewActivity
|
||||||
import com.all.pdfreader.pro.app.ui.adapter.PdfAdapter
|
import com.all.pdfreader.pro.app.ui.adapter.PdfAdapter
|
||||||
import com.all.pdfreader.pro.app.ui.dialog.ListMoreDialogFragment
|
import com.all.pdfreader.pro.app.ui.dialog.ListMoreDialogFragment
|
||||||
@ -53,6 +53,11 @@ class HomeFrag : BaseFrag(), MainActivity.SortableFragment {
|
|||||||
adapter = PdfAdapter(pdfList = mutableListOf(), onItemClick = { pdf ->
|
adapter = PdfAdapter(pdfList = mutableListOf(), onItemClick = { pdf ->
|
||||||
val intent = PdfViewActivity.createIntent(requireContext(), pdf.filePath)
|
val intent = PdfViewActivity.createIntent(requireContext(), pdf.filePath)
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
|
|
||||||
|
// val intent = Intent(context, PdfTextSearchTestActivity::class.java).apply {
|
||||||
|
// putExtra("pdf_path", pdf.filePath)
|
||||||
|
// }
|
||||||
|
// startActivity(intent)
|
||||||
}, onMoreClick = { pdf ->
|
}, onMoreClick = { pdf ->
|
||||||
ListMoreDialogFragment(pdf.filePath).show(parentFragmentManager, FRAG_TAG)
|
ListMoreDialogFragment(pdf.filePath).show(parentFragmentManager, FRAG_TAG)
|
||||||
}, onLongClick = { pdf ->
|
}, onLongClick = { pdf ->
|
||||||
|
|||||||
@ -0,0 +1,81 @@
|
|||||||
|
package com.all.pdfreader.pro.app.util
|
||||||
|
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RectF
|
||||||
|
import com.github.barteksc.pdfviewer.PDFView
|
||||||
|
import com.github.barteksc.pdfviewer.listener.OnDrawListener
|
||||||
|
|
||||||
|
class PDFHighlighter(
|
||||||
|
private val pdfView: PDFView,
|
||||||
|
private val searchManager: PDFSearchManager
|
||||||
|
) : OnDrawListener {
|
||||||
|
|
||||||
|
private val highlightPaint = Paint().apply {
|
||||||
|
color = 0x80FFD700.toInt() // 半透明黄色
|
||||||
|
style = Paint.Style.FILL
|
||||||
|
}
|
||||||
|
|
||||||
|
private val highlightStrokePaint = Paint().apply {
|
||||||
|
color = 0xFFFFA500.toInt() // 橙色边框
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
strokeWidth = 2f
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentPageHighlights: List<PDFSearchManager.TextPositionInfo> = emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前页面的高亮
|
||||||
|
*/
|
||||||
|
fun setCurrentPageHighlights(page: Int) {
|
||||||
|
currentPageHighlights = searchManager.getHighlightsForPage(page)
|
||||||
|
pdfView.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除当前高亮
|
||||||
|
*/
|
||||||
|
fun clearCurrentHighlights() {
|
||||||
|
currentPageHighlights = emptyList()
|
||||||
|
pdfView.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLayerDrawn(canvas: Canvas, pageWidth: Float, pageHeight: Float, displayedPage: Int) {
|
||||||
|
// 只绘制当前显示页面的高亮
|
||||||
|
if (displayedPage != searchManager.currentHighlightPage) {
|
||||||
|
searchManager.currentHighlightPage = displayedPage
|
||||||
|
setCurrentPageHighlights(displayedPage + 1) // PDFView 页面从0开始,我们的搜索从1开始
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制高亮区域
|
||||||
|
currentPageHighlights.forEach { position ->
|
||||||
|
try {
|
||||||
|
val rect = position.getRelativeRect()
|
||||||
|
val absoluteRect = RectF(
|
||||||
|
rect.left * pageWidth,
|
||||||
|
rect.top * pageHeight,
|
||||||
|
rect.right * pageWidth,
|
||||||
|
rect.bottom * pageHeight
|
||||||
|
)
|
||||||
|
|
||||||
|
// 可选:添加小的边距让高亮更明显
|
||||||
|
val margin = 1f
|
||||||
|
val paddedRect = RectF(
|
||||||
|
absoluteRect.left - margin,
|
||||||
|
absoluteRect.top - margin,
|
||||||
|
absoluteRect.right + margin,
|
||||||
|
absoluteRect.bottom + margin
|
||||||
|
)
|
||||||
|
|
||||||
|
// 绘制半透明高亮
|
||||||
|
canvas.drawRect(paddedRect, highlightPaint)
|
||||||
|
|
||||||
|
// 绘制边框
|
||||||
|
canvas.drawRect(paddedRect, highlightStrokePaint)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,332 @@
|
|||||||
|
package com.all.pdfreader.pro.app.util
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.github.barteksc.pdfviewer.PDFView
|
||||||
|
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
||||||
|
import com.tom_roush.pdfbox.pdmodel.PDPage
|
||||||
|
import com.tom_roush.pdfbox.text.PDFTextStripper
|
||||||
|
import com.tom_roush.pdfbox.text.TextPosition
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class PDFSearchManager(private val pdfView: PDFView) {
|
||||||
|
|
||||||
|
private var currentSearchResults: List<TextSearchResult> = emptyList()
|
||||||
|
var currentHighlightPage = -1
|
||||||
|
|
||||||
|
private var cachedDoc: PDDocument? = null // 缓存已打开的文档
|
||||||
|
private var cachedPath: String? = null // 记录缓存对应的文件路径
|
||||||
|
|
||||||
|
var currentSearchText: String? = null//记录当前的搜索文字
|
||||||
|
var hasSearched: Boolean = false//记录当前是否进行过搜索
|
||||||
|
|
||||||
|
data class TextSearchResult(
|
||||||
|
val pageNumber: Int,
|
||||||
|
val text: String,
|
||||||
|
val positions: List<TextPositionInfo>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TextPositionInfo(
|
||||||
|
val text: String, // 当前字符或文本片段
|
||||||
|
val x: Float, // 字符左下角的 X 坐标(PDF页面坐标系)
|
||||||
|
val y: Float, // 字符基线(baseline)在页面上的 Y 坐标(PDF页面坐标系)
|
||||||
|
val width: Float, // 字符宽度
|
||||||
|
val height: Float, // 字符高度
|
||||||
|
val pageWidth: Float, // 当前 PDF 页宽
|
||||||
|
val pageHeight: Float // 当前 PDF 页高
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* 将字符位置信息转换为 PDFView 可用的相对矩形 (RectF)
|
||||||
|
*
|
||||||
|
* @return RectF 返回一个相对页面比例的矩形(左上角和右下角的值在 0~1 范围)
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - PDFBox 中 y 是字符基线 (baseline),不是顶部,所以顶部需要计算为 y - height。(如果不这样那选中的位子会在文字的正下方)
|
||||||
|
* - RectF 的 left/top/right/bottom 坐标是相对于页面尺寸的比例。
|
||||||
|
* - 添加了 padding,使高亮框稍微大于文字,避免高亮框太紧。
|
||||||
|
*/
|
||||||
|
fun getRelativeRect(): android.graphics.RectF {
|
||||||
|
// padding:用于微调高亮框大小,按页面比例计算
|
||||||
|
val padding = 2f / pageHeight
|
||||||
|
|
||||||
|
// 计算相对页面坐标
|
||||||
|
val left = x / pageWidth - padding // 左边界
|
||||||
|
val top = (y - height) / pageHeight - padding // 顶部边界(基线减去字符高度,再减去 margin)
|
||||||
|
val right = (x + width) / pageWidth + padding // 右边界
|
||||||
|
val bottom = y / pageHeight + padding // 底部边界(基线加上 margin)
|
||||||
|
|
||||||
|
// 返回相对页面坐标的矩形
|
||||||
|
return android.graphics.RectF(left, top, right, bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 PDDocument,若已缓存则直接返回,否则加载并缓存。
|
||||||
|
* @param file 要加载的 PDF 文件
|
||||||
|
* @param onLoaded 加载成功时回调(带 PDDocument 参数)
|
||||||
|
* @param onError 加载失败时回调(可选)
|
||||||
|
*/
|
||||||
|
fun getDocument(
|
||||||
|
file: File,
|
||||||
|
onLoaded: ((PDDocument) -> Unit)? = null,
|
||||||
|
onError: ((Exception) -> Unit)? = null
|
||||||
|
): PDDocument {
|
||||||
|
if (cachedDoc == null || cachedPath != file.absolutePath) {
|
||||||
|
try {
|
||||||
|
// 如果是新的文件,则关闭旧文档
|
||||||
|
cachedDoc?.close()
|
||||||
|
cachedDoc = PDDocument.load(file)
|
||||||
|
cachedPath = file.absolutePath
|
||||||
|
|
||||||
|
// 通知加载成功
|
||||||
|
onLoaded?.invoke(cachedDoc!!)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError?.invoke(e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 已缓存文档,仍可调用回调
|
||||||
|
onLoaded?.invoke(cachedDoc!!)
|
||||||
|
}
|
||||||
|
return cachedDoc!!
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCachedDoc(): PDDocument? {
|
||||||
|
return cachedDoc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放 PDF 资源。
|
||||||
|
*/
|
||||||
|
fun closeCachedDocument() {
|
||||||
|
try {
|
||||||
|
cachedDoc?.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
} finally {
|
||||||
|
cachedDoc = null
|
||||||
|
cachedPath = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主搜索方法
|
||||||
|
* @param pdfFile 要搜索的 PDF 文件
|
||||||
|
* @param key 搜索关键词
|
||||||
|
* @param targetPage 可选参数:指定页码(从 0 开始)。如果为 null 则搜索整本。
|
||||||
|
*/
|
||||||
|
fun searchText(pdfFile: File, key: String, targetPage: Int? = null): List<TextSearchResult> {
|
||||||
|
Log.d("ocean", "searchText pdfFile->$pdfFile")
|
||||||
|
Log.d("ocean", "searchText key->$key")
|
||||||
|
Log.d("ocean", "searchText targetPage->$targetPage")
|
||||||
|
val results = mutableListOf<TextSearchResult>()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val doc = getDocument(pdfFile) // 这里不再每次重新 load
|
||||||
|
|
||||||
|
val startPage = targetPage ?: 0
|
||||||
|
val endPage = targetPage ?: (doc.numberOfPages - 1)
|
||||||
|
|
||||||
|
for (pageIndex in startPage..endPage) {
|
||||||
|
val page = doc.getPage(pageIndex)
|
||||||
|
val pageResults = searchInPage(doc, pageIndex, key, page)
|
||||||
|
results.add(pageResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSearchResults = results
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchInPage(
|
||||||
|
document: PDDocument,
|
||||||
|
pageIndex: Int,
|
||||||
|
searchText: String,
|
||||||
|
page: PDPage
|
||||||
|
): TextSearchResult {
|
||||||
|
val positions = mutableListOf<TextPositionInfo>()
|
||||||
|
val searchTextLower = searchText.toLowerCase()
|
||||||
|
val pageWidth = page.mediaBox.width
|
||||||
|
val pageHeight = page.mediaBox.height
|
||||||
|
|
||||||
|
val textStripper = object : PDFTextStripper() {
|
||||||
|
private val currentPagePositions = mutableListOf<TextPositionInfo>()
|
||||||
|
|
||||||
|
override fun writeString(text: String, textPositions: List<TextPosition>) {
|
||||||
|
// 收集文本位置信息
|
||||||
|
for (tp in textPositions) {
|
||||||
|
val positionInfo = TextPositionInfo(
|
||||||
|
text = tp.unicode,
|
||||||
|
x = tp.xDirAdj,
|
||||||
|
y = tp.yDirAdj,
|
||||||
|
width = tp.widthDirAdj,
|
||||||
|
height = tp.heightDir,
|
||||||
|
pageWidth = pageWidth,
|
||||||
|
pageHeight = pageHeight
|
||||||
|
)
|
||||||
|
currentPagePositions.add(positionInfo)
|
||||||
|
}
|
||||||
|
super.writeString(text, textPositions)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun endPage(page: PDPage?) {
|
||||||
|
// 在页面结束时进行搜索匹配
|
||||||
|
val matchedPositions = findTextMatches(currentPagePositions, searchTextLower)
|
||||||
|
if (matchedPositions.isNotEmpty()) {
|
||||||
|
positions.addAll(matchedPositions)
|
||||||
|
}
|
||||||
|
currentPagePositions.clear()
|
||||||
|
super.endPage(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textStripper.startPage = pageIndex + 1
|
||||||
|
textStripper.endPage = pageIndex + 1
|
||||||
|
|
||||||
|
try {
|
||||||
|
textStripper.getText(document)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextSearchResult(
|
||||||
|
pageNumber = pageIndex + 1,
|
||||||
|
text = searchText,
|
||||||
|
positions = positions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findTextMatches(
|
||||||
|
positions: List<TextPositionInfo>,
|
||||||
|
searchText: String
|
||||||
|
): List<TextPositionInfo> {
|
||||||
|
val matches = mutableListOf<TextPositionInfo>()
|
||||||
|
val fullText = positions.joinToString("") { it.text }.toLowerCase()
|
||||||
|
|
||||||
|
var index = 0
|
||||||
|
while (index < fullText.length) {
|
||||||
|
val matchIndex = fullText.indexOf(searchText, index)
|
||||||
|
if (matchIndex == -1) break
|
||||||
|
|
||||||
|
// 精确查找匹配的文本段在positions中的范围
|
||||||
|
var currentLength = 0
|
||||||
|
val matchedPositions = mutableListOf<TextPositionInfo>()
|
||||||
|
|
||||||
|
for (pos in positions) {
|
||||||
|
val positionStart = currentLength
|
||||||
|
val positionEnd = currentLength + pos.text.length
|
||||||
|
|
||||||
|
// 精确检查:这个位置是否在当前匹配范围内
|
||||||
|
if (positionStart < matchIndex + searchText.length &&
|
||||||
|
positionEnd > matchIndex
|
||||||
|
) {
|
||||||
|
matchedPositions.add(pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLength += pos.text.length
|
||||||
|
|
||||||
|
// 如果已经超过当前匹配范围,立即停止
|
||||||
|
if (currentLength >= matchIndex + searchText.length) break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算合并后的矩形区域
|
||||||
|
if (matchedPositions.isNotEmpty()) {
|
||||||
|
val mergedPosition = mergePositions(matchedPositions, searchText)
|
||||||
|
matches.add(mergedPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
index = matchIndex + searchText.length
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d("PDF_DEBUG", "找到 ${matches.size} 个独立匹配")
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mergePositions(
|
||||||
|
positions: List<TextPositionInfo>,
|
||||||
|
originalText: String
|
||||||
|
): TextPositionInfo {
|
||||||
|
val first = positions.first()
|
||||||
|
val minX = positions.minByOrNull { it.x }?.x ?: first.x
|
||||||
|
val maxX = positions.maxByOrNull { it.x + it.width }?.let { it.x + it.width }
|
||||||
|
?: (first.x + first.width)
|
||||||
|
val minY = positions.minByOrNull { it.y }?.y ?: first.y
|
||||||
|
val maxY = positions.maxByOrNull { it.y + it.height }?.let { it.y + it.height }
|
||||||
|
?: (first.y + first.height)
|
||||||
|
|
||||||
|
return TextPositionInfo(
|
||||||
|
text = originalText,
|
||||||
|
x = minX,
|
||||||
|
y = minY,
|
||||||
|
width = maxX - minX,
|
||||||
|
height = maxY - minY,
|
||||||
|
pageWidth = first.pageWidth,
|
||||||
|
pageHeight = first.pageHeight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前页面的高亮区域
|
||||||
|
*/
|
||||||
|
fun getHighlightsForPage(page: Int): List<TextPositionInfo> {
|
||||||
|
return currentSearchResults
|
||||||
|
.find { it.pageNumber == page }
|
||||||
|
?.positions ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有高亮
|
||||||
|
*/
|
||||||
|
fun clearHighlights() {
|
||||||
|
currentSearchResults = emptyList()
|
||||||
|
pdfView.invalidate()
|
||||||
|
currentSearchText = null
|
||||||
|
hasSearched = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前页是否有可搜索文字
|
||||||
|
* @param doc 当前 PDF 文档
|
||||||
|
* @param pageIndex 当前页索引
|
||||||
|
* @param minChars 阈值:认为一页有文字的最少字符数
|
||||||
|
*/
|
||||||
|
fun pageHasText(doc: PDDocument, pageIndex: Int, minChars: Int = 10): Boolean {
|
||||||
|
var charCount = 0
|
||||||
|
|
||||||
|
val stripper = object : PDFTextStripper() {
|
||||||
|
override fun processTextPosition(text: TextPosition) {
|
||||||
|
Log.d(
|
||||||
|
"ocean",
|
||||||
|
"processTextPosition text->$text width->${text.width}height->${text.height}"
|
||||||
|
)
|
||||||
|
//太小的文字当没有
|
||||||
|
if (text.width > 1 && text.height > 2) {
|
||||||
|
charCount++
|
||||||
|
}
|
||||||
|
// 如果超过阈值,直接停止
|
||||||
|
if (charCount >= minChars) {
|
||||||
|
throw StopParsingException()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
stripper.startPage = pageIndex + 1
|
||||||
|
stripper.endPage = pageIndex + 1
|
||||||
|
stripper.getText(doc)
|
||||||
|
// 解析完,如果字符数达不到阈值就返回 false
|
||||||
|
charCount >= minChars
|
||||||
|
} catch (e: StopParsingException) {
|
||||||
|
// 遇到足够字符,认为有文字
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private class StopParsingException : RuntimeException()
|
||||||
|
}
|
||||||
@ -39,6 +39,56 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/searchTextLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:background="@drawable/dr_item_img_frame"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/searchEdit"
|
||||||
|
style="@style/TextViewFont_PopRegular"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="8dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@null"
|
||||||
|
android:hint="@string/search_hint"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:layout_marginEnd="12dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/searchIv"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:src="@drawable/search" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/deleteIv"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:src="@drawable/delete_cha_icon"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/title"
|
android:id="@+id/title"
|
||||||
style="@style/TextViewFont_PopMedium"
|
style="@style/TextViewFont_PopMedium"
|
||||||
@ -53,10 +103,25 @@
|
|||||||
android:textSize="16sp" />
|
android:textSize="16sp" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/moreBtn"
|
android:id="@+id/searchTextBtn"
|
||||||
android:layout_width="40dp"
|
android:layout_width="40dp"
|
||||||
android:layout_height="40dp"
|
android:layout_height="40dp"
|
||||||
android:layout_marginStart="8dp"
|
android:layout_marginStart="8dp"
|
||||||
|
android:background="@drawable/dr_click_effect_oval_transparent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:src="@drawable/search" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/moreBtn"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
android:layout_marginEnd="8dp"
|
android:layout_marginEnd="8dp"
|
||||||
android:background="@drawable/dr_click_effect_oval_transparent"
|
android:background="@drawable/dr_click_effect_oval_transparent"
|
||||||
android:gravity="center">
|
android:gravity="center">
|
||||||
@ -67,6 +132,7 @@
|
|||||||
android:src="@drawable/more" />
|
android:src="@drawable/more" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|||||||
74
app/src/main/res/layout/activity_pdf_viewer_test.xml
Normal file
74
app/src/main/res/layout/activity_pdf_viewer_test.xml
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="#f5f5f5"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="8dp">
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/searchEditText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:hint="输入搜索关键词"
|
||||||
|
android:imeOptions="actionSearch"
|
||||||
|
android:singleLine="true" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/searchButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="搜索" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/clearButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="清除" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/debugTextView"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressBar"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<!-- PDF显示区域 -->
|
||||||
|
<com.github.barteksc.pdfviewer.PDFView
|
||||||
|
android:id="@+id/pdfView"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="3" />
|
||||||
|
|
||||||
|
<!-- 搜索结果列表 -->
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/searchResultsRecyclerView"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="#ffffff" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
34
app/src/main/res/layout/item_search_result.xml
Normal file
34
app/src/main/res/layout/item_search_result.xml
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:background="?android:attr/selectableItemBackground">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/pageText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#333333" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/matchCount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="#666666"
|
||||||
|
android:layout_marginTop="2dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/previewText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
Loading…
Reference in New Issue
Block a user