diff --git a/app/src/main/java/com/all/pdfreader/pro/app/ui/act/PdfViewActivity.kt b/app/src/main/java/com/all/pdfreader/pro/app/ui/act/PdfViewActivity.kt index c74a303..09e4e2b 100644 --- a/app/src/main/java/com/all/pdfreader/pro/app/ui/act/PdfViewActivity.kt +++ b/app/src/main/java/com/all/pdfreader/pro/app/ui/act/PdfViewActivity.kt @@ -3,13 +3,19 @@ package com.all.pdfreader.pro.app.ui.act import android.content.Context import android.content.Intent import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher import android.view.MotionEvent import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.Toast +import androidx.activity.OnBackPressedCallback import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import com.all.pdfreader.pro.app.R import com.all.pdfreader.pro.app.databinding.ActivityPdfViewBinding import com.all.pdfreader.pro.app.model.FileActionEvent +import com.all.pdfreader.pro.app.model.PdfPickerSource import com.all.pdfreader.pro.app.room.entity.PdfDocumentEntity import com.all.pdfreader.pro.app.ui.dialog.BookmarksDialogFragment import com.all.pdfreader.pro.app.ui.dialog.ListMoreDialogFragment @@ -17,7 +23,11 @@ import com.all.pdfreader.pro.app.ui.dialog.PdfPasswordProtectionDialogFragment import com.all.pdfreader.pro.app.ui.dialog.ViewModelDialogFragment import com.all.pdfreader.pro.app.ui.view.CustomScrollHandle import com.all.pdfreader.pro.app.util.AppUtils +import com.all.pdfreader.pro.app.util.AppUtils.hideKeyboard +import com.all.pdfreader.pro.app.util.AppUtils.showKeyboard import com.all.pdfreader.pro.app.util.FileUtils +import com.all.pdfreader.pro.app.util.PDFHighlighter +import com.all.pdfreader.pro.app.util.PDFSearchManager import com.all.pdfreader.pro.app.viewmodel.PdfViewModel import com.all.pdfreader.pro.app.viewmodel.observeEvent import com.github.barteksc.pdfviewer.listener.OnErrorListener @@ -26,7 +36,13 @@ import com.github.barteksc.pdfviewer.listener.OnPageChangeListener import com.github.barteksc.pdfviewer.listener.OnTapListener import com.gyf.immersionbar.BarHide import com.gyf.immersionbar.ImmersionBar +import com.tom_roush.pdfbox.pdmodel.PDDocument +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File class PdfViewActivity : BaseActivity(), OnLoadCompleteListener, OnPageChangeListener, @@ -51,6 +67,10 @@ class PdfViewActivity : BaseActivity(), OnLoadCompleteListener, OnPageChangeList private val repository = getRepository() private var isFullScreen = false private var lastLoadedFilePath: String? = null + private var showSearchTextView = false + private lateinit var searchManager: PDFSearchManager + private lateinit var highlighter: PDFHighlighter + private var pageCheckJob: Job? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -58,6 +78,7 @@ class PdfViewActivity : BaseActivity(), OnLoadCompleteListener, OnPageChangeList setContentView(binding.root) ImmersionBar.with(this).statusBarView(binding.view).statusBarDarkFont(true) .navigationBarColor(R.color.white).init() + setupDoubleBackExit() initObserve() val filePath = intent.getStringExtra(EXTRA_PDF_HASH) ?: throw IllegalArgumentException("PDF file hash is required") @@ -165,7 +186,7 @@ class PdfViewActivity : BaseActivity(), OnLoadCompleteListener, OnPageChangeList private fun setupOnClick() { binding.backBtn.setOnClickListener { - finish() + onBackPressedDispatcher.onBackPressed() } binding.eyeCareOverlayBtn.setOnClickListener { appStore.isEyeCareMode = !appStore.isEyeCareMode @@ -188,6 +209,55 @@ class PdfViewActivity : BaseActivity(), OnLoadCompleteListener, OnPageChangeList binding.moreBtn.setOnClickListener { ListMoreDialogFragment(pdfDocument.filePath).show(supportFragmentManager, FRAG_TAG) } + binding.searchTextBtn.setOnClickListener { + showSearchTextView = true + updateSearchState(true) + binding.selectNextBtn.visibility = View.GONE + binding.selectPreviousBtn.visibility = View.GONE + } + binding.searchEdit.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable?) { + val query = s?.toString().orEmpty() + binding.deleteIv.visibility = if (query.isEmpty()) View.GONE else View.VISIBLE + binding.searchIv.visibility = if (query.isEmpty()) View.VISIBLE else View.GONE + } + }) + binding.searchEdit.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_SEARCH) { + val searchText = binding.searchEdit.text.toString().trim() + if (searchText.isNotEmpty()) { + performSearch(searchText) + binding.searchEdit.hideKeyboard() // 收起键盘 + } else { + showToast(getString(R.string.enter_search_key)) + } + true + } else { + false + } + } + binding.deleteIv.setOnClickListener { + binding.searchEdit.apply { + setText("") + clearComposingText() + setSelection(0) + } + clearHighlights() + binding.selectNextBtn.visibility = View.GONE + binding.selectPreviousBtn.visibility = View.GONE + } + binding.selectNextBtn.setOnClickListener { + highlighter.selectNext { id -> + showToast(getString(id)) + } + } + binding.selectPreviousBtn.setOnClickListener { + highlighter.selectPrevious { id -> + showToast(getString(id)) + } + } } private fun loadPdf() { @@ -199,6 +269,7 @@ class PdfViewActivity : BaseActivity(), OnLoadCompleteListener, OnPageChangeList finish() return } + setupPDFSearchManager()//初始化搜索文本需要的类 if (pdfDocument.isPassword) { showPasswordDialog(file) } else { @@ -239,10 +310,22 @@ class PdfViewActivity : BaseActivity(), OnLoadCompleteListener, OnPageChangeList lastReadPage = page, readingProgress = (page.toFloat() / pageCount.toFloat()) * 100 ) saveReadingProgress() - } - override fun onDestroy() { - super.onDestroy() + val doc = searchManager.getCachedDoc() ?: return + // 取消上一次检查任务 + pageCheckJob?.cancel() + // 启动新的防抖任务 + pageCheckJob = lifecycleScope.launch(Dispatchers.IO) { + delay(120) // 防抖 120ms + val hasText = checkPageHasText(doc, page) + if (hasText && searchManager.hasSearched && !searchManager.currentSearchText.isNullOrEmpty()) { + searchTextIng( + File(pdfDocument.filePath), + searchManager.currentSearchText ?: "", + binding.pdfview.currentPage + ) + } + } } private fun saveReadingProgress() { @@ -276,9 +359,7 @@ class PdfViewActivity : BaseActivity(), OnLoadCompleteListener, OnPageChangeList onTap(this@PdfViewActivity) // 单击回调 onPageChange(this@PdfViewActivity) // 页面改变回调 scrollHandle(CustomScrollHandle(this@PdfViewActivity)) // 自定义的页数展示 - onDraw { canvas, pageWidth, pageHeight, displayedPage -> - - } + onDraw(highlighter) if (appStore.isPageFling) { pageSnap(true)//页面可在逐页中,可居中展示,非逐页模式,滑动停止后可自动定格在居中位置。 pageFling(true)//逐页 @@ -299,6 +380,9 @@ class PdfViewActivity : BaseActivity(), OnLoadCompleteListener, OnPageChangeList } private fun toggleFullScreen() { + if (showSearchTextView) {//如果是搜索文本时,不全屏 + return + } isFullScreen = !isFullScreen updateStatusAndNavigationLayout(isFullScreen) if (isFullScreen) { @@ -337,4 +421,129 @@ class PdfViewActivity : BaseActivity(), OnLoadCompleteListener, OnPageChangeList ImmersionBar.with(this).statusBarView(binding.view).statusBarDarkFont(true) .navigationBarColor(navColor).init() } + + private fun setupDoubleBackExit() { + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (showSearchTextView) {//如果当前是搜索,则退出搜索 + showSearchTextView = false + updateSearchState(false) + clearHighlights() + binding.searchEdit.apply { + setText("") + clearComposingText() + setSelection(0) + } + } else { + isEnabled = false // 解除拦截 + onBackPressedDispatcher.onBackPressed() // 调用系统默认返回逻辑 + } + } + }) + } + + private fun updateSearchState(b: Boolean) { + if (b) { + binding.searchTextLayout.visibility = View.VISIBLE + binding.navigationLayout.visibility = View.GONE + binding.searchEdit.showKeyboard() + } else { + binding.searchTextLayout.visibility = View.GONE + binding.navigationLayout.visibility = View.VISIBLE + binding.searchEdit.hideKeyboard() + } + } + + private fun setupPDFSearchManager() { + searchManager = PDFSearchManager(binding.pdfview) + highlighter = PDFHighlighter(binding.pdfview, searchManager) + + initSearchDocument() + } + + private fun initSearchDocument() { + val file = File(pdfDocument.filePath) + lifecycleScope.launch(Dispatchers.IO) { + searchManager.getDocument(file, onLoaded = { doc -> + lifecycleScope.launch(Dispatchers.IO) { + checkPageHasText(doc, binding.pdfview.currentPage) + } + }, onError = { e -> + runOnUiThread { + binding.searchTextBtn.visibility = View.GONE + binding.searchTextBtn.isEnabled = false + } + }) + } + } + + /** + * 异步检查指定页是否有文字并更新按钮 + */ + private suspend fun checkPageHasText(doc: PDDocument, pageIndex: Int): Boolean { + val hasText = searchManager.pageHasText(doc, pageIndex, minChars = 10) // 可调整阈值 + withContext(Dispatchers.Main) { + if (hasText) { + binding.searchTextBtn.visibility = View.VISIBLE + binding.searchTextBtn.isEnabled = true + } else { + binding.searchTextBtn.visibility = View.GONE + binding.searchTextBtn.isEnabled = false + } + } + return hasText + } + + private fun performSearch(searchText: String) { + val file = File(pdfDocument.filePath) + 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) { + if (!results.isEmpty()) { + binding.selectNextBtn.visibility = View.VISIBLE + binding.selectPreviousBtn.visibility = View.VISIBLE + results.firstOrNull()?.let { firstResult -> + onSearchResultClicked(firstResult) + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + 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 + } + + private fun clearHighlights() { + searchManager.clearHighlights() + highlighter.clearCurrentHighlights() + } + + override fun onDestroy() { + super.onDestroy() + searchManager.closeCachedDocument() + } + } \ No newline at end of file diff --git a/app/src/main/java/com/all/pdfreader/pro/app/util/AppUtils.kt b/app/src/main/java/com/all/pdfreader/pro/app/util/AppUtils.kt index 0751f9a..71d4bb7 100644 --- a/app/src/main/java/com/all/pdfreader/pro/app/util/AppUtils.kt +++ b/app/src/main/java/com/all/pdfreader/pro/app/util/AppUtils.kt @@ -100,6 +100,15 @@ object AppUtils { }, 200) } + fun EditText.hideKeyboard() { + clearFocus() + postDelayed({ + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(windowToken, 0) + }, 200) + + } + /** * 分享文件(自动识别 MIME 类型) * @param context 上下文 diff --git a/app/src/main/java/com/all/pdfreader/pro/app/util/PDFHighlighter.kt b/app/src/main/java/com/all/pdfreader/pro/app/util/PDFHighlighter.kt index 901cd92..ca9e9f2 100644 --- a/app/src/main/java/com/all/pdfreader/pro/app/util/PDFHighlighter.kt +++ b/app/src/main/java/com/all/pdfreader/pro/app/util/PDFHighlighter.kt @@ -3,6 +3,7 @@ package com.all.pdfreader.pro.app.util import android.graphics.Canvas import android.graphics.Paint import android.graphics.RectF +import com.all.pdfreader.pro.app.R import com.github.barteksc.pdfviewer.PDFView import com.github.barteksc.pdfviewer.listener.OnDrawListener @@ -11,6 +12,13 @@ class PDFHighlighter( private val searchManager: PDFSearchManager ) : OnDrawListener { + enum class ClickType { + NEXT, + PREVIOUS + } + + private var type = ClickType.NEXT + private val highlightPaint = Paint().apply { color = 0x80FFD700.toInt() // 半透明黄色 style = Paint.Style.FILL @@ -22,13 +30,34 @@ class PDFHighlighter( strokeWidth = 2f } + private val selectedPaint = Paint().apply { + color = 0x80FF4500.toInt() // 当前选中高亮,橙红色半透明 + style = Paint.Style.FILL + } + + private val selectedStrokePaint = Paint().apply { + color = 0xFFFF6347.toInt() // 当前选中边框,亮橙红 + style = Paint.Style.STROKE + strokeWidth = 2f + } + private var currentPageHighlights: List = emptyList() + private var currentSelectedIndex: Int = -1 /** * 设置当前页面的高亮 */ fun setCurrentPageHighlights(page: Int) { currentPageHighlights = searchManager.getHighlightsForPage(page) + currentSelectedIndex = when (type) { + ClickType.NEXT -> { + if (currentPageHighlights.isNotEmpty()) 0 else -1 + } + + ClickType.PREVIOUS -> { + if (currentPageHighlights.isNotEmpty()) currentPageHighlights.size - 1 else -1 + } + } pdfView.invalidate() } @@ -37,18 +66,78 @@ class PDFHighlighter( */ fun clearCurrentHighlights() { currentPageHighlights = emptyList() + currentSelectedIndex = -1 pdfView.invalidate() } - override fun onLayerDrawn(canvas: Canvas, pageWidth: Float, pageHeight: Float, displayedPage: Int) { - // 只绘制当前显示页面的高亮 + /** + * 选中下一条高亮 + */ + fun selectNext(rid: ((Int) -> Unit)? = null) { + type = ClickType.NEXT + if (currentPageHighlights.isEmpty()) {//当前页没有对应的文字,直接跳转下一页 + val nextPage = searchManager.currentHighlightPage + 1 + pdfView.jumpTo(nextPage, true) + return + } + if (currentSelectedIndex < currentPageHighlights.size - 1) { + currentSelectedIndex++ + //下一页+,需要刷新 + pdfView.invalidate() + } else { + // 当前页最后一条,尝试跳到下一页 + val nextPage = searchManager.currentHighlightPage + 1 + if (nextPage < pdfView.pageCount) { + pdfView.jumpTo(nextPage, true) + currentSelectedIndex = 0 + } else { + rid?.invoke(R.string.no_more_results) + } + } + } + + /** + * 选中上一条高亮 + */ + fun selectPrevious(rid: ((Int) -> Unit)? = null) { + type = ClickType.PREVIOUS + if (currentPageHighlights.isEmpty()) { // 当前页没有对应的文字,直接跳转上一页 + val prevPage = searchManager.currentHighlightPage - 1 + if (prevPage >= 0) { + pdfView.jumpTo(prevPage, true) + } else { + rid?.invoke(R.string.no_more_results) + } + return + } + if (currentSelectedIndex > 0) { + currentSelectedIndex-- + pdfView.invalidate() + } else { + val prevPage = searchManager.currentHighlightPage - 1 + if (prevPage >= 0) { + pdfView.jumpTo(prevPage, true) + currentPageHighlights = emptyList() + currentSelectedIndex = -1 + } else { + rid?.invoke(R.string.no_more_results) + } + } + } + + + override fun onLayerDrawn( + canvas: Canvas, + pageWidth: Float, + pageHeight: Float, + displayedPage: Int + ) { if (displayedPage != searchManager.currentHighlightPage) { searchManager.currentHighlightPage = displayedPage - setCurrentPageHighlights(displayedPage + 1) // PDFView 页面从0开始,我们的搜索从1开始 +// setCurrentPageHighlights(displayedPage + 1) } - // 绘制高亮区域 - currentPageHighlights.forEach { position -> + currentPageHighlights.forEachIndexed { index, position -> try { val rect = position.getRelativeRect() val absoluteRect = RectF( @@ -58,7 +147,6 @@ class PDFHighlighter( rect.bottom * pageHeight ) - // 可选:添加小的边距让高亮更明显 val margin = 1f val paddedRect = RectF( absoluteRect.left - margin, @@ -67,15 +155,16 @@ class PDFHighlighter( absoluteRect.bottom + margin ) - // 绘制半透明高亮 - canvas.drawRect(paddedRect, highlightPaint) - - // 绘制边框 - canvas.drawRect(paddedRect, highlightStrokePaint) - + if (index == currentSelectedIndex) { + canvas.drawRect(paddedRect, selectedPaint) + canvas.drawRect(paddedRect, selectedStrokePaint) + } else { + canvas.drawRect(paddedRect, highlightPaint) + canvas.drawRect(paddedRect, highlightStrokePaint) + } } catch (e: Exception) { e.printStackTrace() } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/all/pdfreader/pro/app/util/PDFSearchManager.kt b/app/src/main/java/com/all/pdfreader/pro/app/util/PDFSearchManager.kt index 962f41e..f663e6f 100644 --- a/app/src/main/java/com/all/pdfreader/pro/app/util/PDFSearchManager.kt +++ b/app/src/main/java/com/all/pdfreader/pro/app/util/PDFSearchManager.kt @@ -198,49 +198,74 @@ class PDFSearchManager(private val pdfView: PDFView) { ) } + /** + * 在一页的 TextPositionInfo 列表中搜索指定文本,并返回每个匹配的高亮区域。 + * + * 逻辑说明: + * 1. 按视觉顺序(纵向 y, 横向 x)排序字符。 + * 2. 根据 y 坐标将字符分组为“同一行”,避免跨行高亮。 + * 3. 对每一行独立进行逐字符匹配: + * - 清理不可打印字符(零宽空格、控制字符等)。 + * - 如果匹配成功,则将匹配字符合并为一个矩形区域。 + * + * @param positions 当前页的所有字符位置信息(TextPositionInfo)。 + * @param searchText 要搜索的文本(不区分大小写)。 + * @return 匹配的高亮区域列表,每个 TextPositionInfo 对象表示一段匹配文字。 + */ private fun findTextMatches( positions: List, searchText: String ): List { val matches = mutableListOf() - val fullText = positions.joinToString("") { it.text }.toLowerCase() + val chars = searchText.lowercase().toCharArray() - var index = 0 - while (index < fullText.length) { - val matchIndex = fullText.indexOf(searchText, index) - if (matchIndex == -1) break + // 按纵向 y、横向 x 排序,保证字符视觉顺序 + val sortedPositions = positions.sortedWith(compareBy({ it.y }, { it.x })) - // 精确查找匹配的文本段在positions中的范围 - var currentLength = 0 - val matchedPositions = mutableListOf() + // 按行拆分字符,避免高亮跨行 + val lines = mutableListOf>() + var currentLine = mutableListOf() + var lastY = sortedPositions.firstOrNull()?.y ?: 0f + val lineThreshold = 2f // y 差值小于阈值则视为同一行,可根据字体大小微调 - for (pos in positions) { - val positionStart = currentLength - val positionEnd = currentLength + pos.text.length + for (pos in sortedPositions) { + if (currentLine.isNotEmpty() && kotlin.math.abs(pos.y - lastY) > lineThreshold) { + // y 坐标变化超过阈值,说明换行 + lines.add(currentLine) + currentLine = mutableListOf() + } + currentLine.add(pos) + lastY = pos.y + } + if (currentLine.isNotEmpty()) lines.add(currentLine) - // 精确检查:这个位置是否在当前匹配范围内 - if (positionStart < matchIndex + searchText.length && - positionEnd > matchIndex - ) { - matchedPositions.add(pos) + // 对每一行独立匹配搜索文本 + for (line in lines) { + var i = 0 + while (i <= line.size - chars.size) { + var matched = true + val matchedPositions = mutableListOf() + + for (j in chars.indices) { + // 清理不可打印字符(零宽空格、控制字符等) + val posChar = line[i + j].text.replace(Regex("\\p{C}"), "").lowercase() + if (posChar != chars[j].toString()) { + matched = false + break + } + matchedPositions.add(line[i + j]) } - currentLength += pos.text.length - - // 如果已经超过当前匹配范围,立即停止 - if (currentLength >= matchIndex + searchText.length) break + if (matched) { + // 合并匹配字符为矩形区域 + matches.add(mergePositions(matchedPositions, searchText)) + i += chars.size + } else { + i++ + } } - - // 计算合并后的矩形区域 - if (matchedPositions.isNotEmpty()) { - val mergedPosition = mergePositions(matchedPositions, searchText) - matches.add(mergedPosition) - } - - index = matchIndex + searchText.length } - Log.d("PDF_DEBUG", "找到 ${matches.size} 个独立匹配") return matches } @@ -297,10 +322,6 @@ class PDFSearchManager(private val pdfView: PDFView) { 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++ diff --git a/app/src/main/res/drawable/arrow_next_icon.xml b/app/src/main/res/drawable/arrow_next_icon.xml new file mode 100644 index 0000000..fec7a55 --- /dev/null +++ b/app/src/main/res/drawable/arrow_next_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/arrow_previous_icon.xml b/app/src/main/res/drawable/arrow_previous_icon.xml new file mode 100644 index 0000000..1a6499d --- /dev/null +++ b/app/src/main/res/drawable/arrow_previous_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/dr_item_img_frame.xml b/app/src/main/res/drawable/dr_item_img_frame.xml index 63d6b9f..cb2fb17 100644 --- a/app/src/main/res/drawable/dr_item_img_frame.xml +++ b/app/src/main/res/drawable/dr_item_img_frame.xml @@ -2,6 +2,6 @@ + android:width="1dp"/> \ No newline at end of file diff --git a/app/src/main/res/layout/activity_pdf_view.xml b/app/src/main/res/layout/activity_pdf_view.xml index 767f606..6b7e245 100644 --- a/app/src/main/res/layout/activity_pdf_view.xml +++ b/app/src/main/res/layout/activity_pdf_view.xml @@ -42,47 +42,87 @@ - + android:background="@drawable/dr_item_img_frame" + android:gravity="center_vertical"> + + - - - + android:layout_marginStart="12dp" + android:layout_marginEnd="12dp"> + + + + + + + + + + android:layout_width="24dp" + android:layout_height="24dp" + android:src="@drawable/arrow_previous_icon" /> + + + + + + + + - - + android:visibility="visible"> No app found to open image Failed to open image Press again to exit + Please enter the search keyword + No more results \ No newline at end of file