diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0549fdd..c89c917 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -61,4 +61,5 @@ dependencies { implementation(libs.glide) implementation(libs.androidpdfviewer) implementation(libs.pdfbox.android) + implementation(libs.jp2forandroid) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 069e4a1..29ec8d2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -67,6 +67,12 @@ + + = mutableListOf() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityPdfSplitBinding.inflate(layoutInflater) + setContentView(binding.root) + ImmersionBar.with(this).statusBarView(binding.view).statusBarDarkFont(true) + .navigationBarColor(R.color.white).init() + val filePath = intent.getStringExtra(EXTRA_PDF_PATH) + ?: throw IllegalArgumentException("PDF file hash is required") + if (filePath.isEmpty()) { + showToast(getString(R.string.file_not)) + finish() + } + initView() + setupClick() + initSplitData(File(filePath)) + } + + private fun initView() { + binding.title.text = getString(R.string.selected_page, 0) + binding.selectAllBtn.visibility = View.GONE + binding.loadingRoot.root.visibility = View.VISIBLE + binding.splitRv.layoutManager = GridLayoutManager(this, 2) + adapter = SplitPdfAdapter(splitList) { item, pos -> + item.isSelected = !item.isSelected + adapter.setItemSelected(pos, item.isSelected) + binding.title.text = getString(R.string.selected_page, adapter.getSelPages()) + } + binding.splitRv.adapter = adapter + } + + private fun setupClick() { + binding.backBtn.setOnClickListener { finish() } + binding.selectAllBtn.setOnClickListener { + val selectAll = splitList.any { !it.isSelected } + adapter.setAllSelected(selectAll) + binding.title.text = getString(R.string.selected_page, adapter.getSelPages()) + + if (selectAll) { + binding.selectAll.setBackgroundResource(R.drawable.dr_circular_sel_on_bg) + } else { + binding.selectAll.setBackgroundResource(R.drawable.dr_circular_sel_off_bg) + } + } + } + + private fun initSplitData(file: File) { + lifecycleScope.launch { + PdfUtils.clearPdfThumbsCache(this@SplitPdfActivity) // 先清理旧缓存 + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + var firstPageLoaded = false + PdfUtils.splitPdfToPageItemsFlow(this@SplitPdfActivity, file).collect { pageItem -> + logDebug("pageItem flow ->$pageItem") + if (splitList.size <= pageItem.pageIndex) { + splitList.add(pageItem) + adapter.notifyItemInserted(splitList.size - 1) + } else { + splitList[pageItem.pageIndex] = pageItem + adapter.updateItem(pageItem.pageIndex) + } + if (!firstPageLoaded) { + binding.loadingRoot.root.visibility = View.GONE + binding.selectAllBtn.visibility = View.VISIBLE + firstPageLoaded = true + } + } + binding.loadingRoot.root.visibility = View.GONE + } + } + } + + override fun onDestroy() { + super.onDestroy() + splitList.clear() + PdfUtils.clearPdfThumbsCache(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/all/pdfreader/pro/app/ui/adapter/SplitPdfAdapter.kt b/app/src/main/java/com/all/pdfreader/pro/app/ui/adapter/SplitPdfAdapter.kt new file mode 100644 index 0000000..44dc759 --- /dev/null +++ b/app/src/main/java/com/all/pdfreader/pro/app/ui/adapter/SplitPdfAdapter.kt @@ -0,0 +1,106 @@ +package com.all.pdfreader.pro.app.ui.adapter + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.all.pdfreader.pro.app.R +import com.all.pdfreader.pro.app.databinding.AdapterSplitPageItemBinding +import com.all.pdfreader.pro.app.model.PdfPageItem +import com.all.pdfreader.pro.app.util.AppUtils.dpToPx +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import java.io.File + +class SplitPdfAdapter( + private val pdfList: MutableList, + private val onItemClick: (PdfPageItem, Int) -> Unit +) : RecyclerView.Adapter() { + + inner class PdfViewHolder(val binding: AdapterSplitPageItemBinding) : + RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = PdfViewHolder( + AdapterSplitPageItemBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) + + @SuppressLint("SetTextI18n") + override fun onBindViewHolder(holder: PdfViewHolder, position: Int) { + bindAll(holder, position) + } + + // 支持 payload,局部刷新 + override fun onBindViewHolder( + holder: PdfViewHolder, position: Int, payloads: MutableList + ) { + if (payloads.isEmpty()) { + bindAll(holder, position) // 全量刷新 + } else { + if (payloads.any { it == "selection" }) { + bindSelectionOnly(holder, position) + } + } + } + + @SuppressLint("SetTextI18n") + private fun bindAll(holder: PdfViewHolder, position: Int) { + val item = pdfList[position] + holder.binding.apply { + //更新文字图片与选中状态 + pageNumberTv.text = "${item.pageIndex + 1}" + Glide.with(holder.binding.root) + .load(File(item.previewFilePath ?: "")) + .transform(CenterCrop(), RoundedCorners(8.dpToPx(holder.binding.root.context))) + .into(image) + bindSelection(item, holder) + } + holder.binding.root.setOnClickListener { + val pos = holder.bindingAdapterPosition + if (pos != RecyclerView.NO_POSITION) { + onItemClick(pdfList[pos], pos) + } + } + } + + private fun bindSelectionOnly(holder: PdfViewHolder, position: Int) { + val item = pdfList[position] + bindSelection(item, holder) + } + + //更新选中状态 + private fun bindSelection(item: PdfPageItem, holder: PdfViewHolder) { + val b = holder.binding + val selRes = + if (item.isSelected) R.drawable.dr_circular_sel_on_bg else R.drawable.dr_circular_sel_off_bg + b.selIv.setBackgroundResource(selRes) + + val bgRes = if (item.isSelected) R.drawable.dr_sel_on_frame else R.drawable.dr_sel_off_frame + b.itemBgLayout.setBackgroundResource(bgRes) + + val pageBgRes = + if (item.isSelected) R.drawable.dr_item_page_number_sel_on_bg else R.drawable.dr_item_page_number_sel_off_bg + b.pageNumberTv.setBackgroundResource(pageBgRes) + } + + + override fun getItemCount() = pdfList.size + + fun updateItem(position: Int) = notifyItemChanged(position) + + // 只刷新选中状态 + fun setItemSelected(position: Int, selected: Boolean) { + pdfList[position].isSelected = selected + notifyItemChanged(position, "selection") // 这里的 payload 就是标记 + } + + fun getSelPages() = pdfList.count { it.isSelected } + + // 批量更新选中状态,用 payload 避免图片重新加载 + fun setAllSelected(selected: Boolean) { + pdfList.forEach { it.isSelected = selected } + notifyItemRangeChanged(0, pdfList.size, "selection") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/all/pdfreader/pro/app/ui/dialog/ListMoreDialogFragment.kt b/app/src/main/java/com/all/pdfreader/pro/app/ui/dialog/ListMoreDialogFragment.kt index 1309f6e..9cfcea1 100644 --- a/app/src/main/java/com/all/pdfreader/pro/app/ui/dialog/ListMoreDialogFragment.kt +++ b/app/src/main/java/com/all/pdfreader/pro/app/ui/dialog/ListMoreDialogFragment.kt @@ -1,6 +1,7 @@ package com.all.pdfreader.pro.app.ui.dialog import android.net.Uri +import android.nfc.Tag import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -12,6 +13,8 @@ import com.all.pdfreader.pro.app.databinding.DialogListMoreBinding import com.all.pdfreader.pro.app.model.PrintResult import com.all.pdfreader.pro.app.model.RenameType import com.all.pdfreader.pro.app.room.entity.PdfDocumentEntity +import com.all.pdfreader.pro.app.ui.act.SplitPdfActivity +import com.all.pdfreader.pro.app.ui.fragment.HomeFrag import com.all.pdfreader.pro.app.util.AppUtils.dpToPx import com.all.pdfreader.pro.app.util.AppUtils.printPdfFile import com.all.pdfreader.pro.app.util.AppUtils.setClickWithAnimation @@ -71,10 +74,14 @@ class ListMoreDialogFragment(val filePath: String) : BottomSheetDialogFragment() } private fun initUi() { - if (pdfDocument.lastOpenedTime > 0) { - binding.removeRecentBtn.visibility = View.VISIBLE - } else { + if (tag == HomeFrag().tag) { binding.removeRecentBtn.visibility = View.GONE + } else { + if (pdfDocument.lastOpenedTime > 0) { + binding.removeRecentBtn.visibility = View.VISIBLE + } else { + binding.removeRecentBtn.visibility = View.GONE + } } binding.tvFileName.text = pdfDocument.fileName binding.tvFileSize.text = pdfDocument.fileSize.toFormatFileSize() @@ -181,6 +188,11 @@ class ListMoreDialogFragment(val filePath: String) : BottomSheetDialogFragment() }).show(parentFragmentManager, "removeRecent") dismiss() } + binding.splitBtn.setOnClickListener { + val intent = SplitPdfActivity.createIntent(requireActivity(), pdfDocument.filePath) + startActivity(intent) + dismiss() + } } private fun updateCollectUi(b: Boolean) { diff --git a/app/src/main/java/com/all/pdfreader/pro/app/util/PdfUtils.kt b/app/src/main/java/com/all/pdfreader/pro/app/util/PdfUtils.kt new file mode 100644 index 0000000..9508e60 --- /dev/null +++ b/app/src/main/java/com/all/pdfreader/pro/app/util/PdfUtils.kt @@ -0,0 +1,129 @@ +package com.all.pdfreader.pro.app.util + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.ParcelFileDescriptor +import androidx.core.graphics.createBitmap +import com.all.pdfreader.pro.app.model.PdfPageItem +import com.shockwave.pdfium.PdfiumCore +import com.tom_roush.pdfbox.pdmodel.PDDocument +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream + +object PdfUtils { + + //拆分缩略图缓存地址 + const val child = "pdf_split_thumbs" + + //清除拆分缓存文件夹 + fun clearPdfThumbsCache(context: Context) { + val cacheDir = File(context.cacheDir, child) + if (cacheDir.exists()) { + cacheDir.listFiles()?.forEach { it.delete() } + } + } + + /** + * 分页渲染 PDF 并返回 Flow + * 先 emit 页对象(preview = null),UI 可立即显示总页数 + * 后续异步渲染 Bitmap 并更新对象 + */ + fun splitPdfToPageItemsFlow( + context: Context, + inputFile: File, + dpi: Float = 72f, + chunkSize: Int = 5, + thumbWidth: Int = 200 + ): Flow = flow { + val pdfiumCore = PdfiumCore(context) + ParcelFileDescriptor.open(inputFile, ParcelFileDescriptor.MODE_READ_ONLY).use { fd -> + val pdfDocument = pdfiumCore.newDocument(fd) + val pageCount = pdfiumCore.getPageCount(pdfDocument) + + val pages = List(pageCount) { + PdfPageItem(pageIndex = it, previewFilePath = null, isSelected = false) + } + + // 先 emit 页对象,让 UI 知道总页数 + pages.forEach { emit(it) } + + val cacheDir = File(context.cacheDir, child).apply { mkdirs() } + + for (i in 0 until pageCount) { + pdfiumCore.openPage(pdfDocument, i) + + val width = (pdfiumCore.getPageWidthPoint(pdfDocument, i) * dpi / 72).toInt() + val height = (pdfiumCore.getPageHeightPoint(pdfDocument, i) * dpi / 72).toInt() + + val scale = thumbWidth.toFloat() / width + val targetHeight = (height * scale).toInt() + + val bitmap = createBitmap(thumbWidth, targetHeight, Bitmap.Config.RGB_565) + val canvas = Canvas(bitmap) + canvas.scale(scale, scale) + + pdfiumCore.renderPageBitmap(pdfDocument, bitmap, i, 0, 0, width, height) + + // 保存为压缩 JPEG + val outFile = File(cacheDir, "page_$i.jpg") + FileOutputStream(outFile).use { fos -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 70, fos) + } + bitmap.recycle() + + pages[i].previewFilePath = outFile.absolutePath + emit(pages[i]) + + if ((i + 1) % chunkSize == 0) delay(50) // 分批渲染,保证 UI 流畅 + } + + pdfiumCore.closeDocument(pdfDocument) + } + }.flowOn(Dispatchers.IO) + + /** + * 获取 PDF 总页数 + */ + fun getPdfPageCount(inputFile: File): Int { + PDDocument.load(inputFile).use { return it.numberOfPages } + } + + /** + * 导出用户勾选的页生成新的 PDF + * @param inputFile 原 PDF 文件 + * @param selectedPages 用户选择的页列表 + * @param outputDir 输出目录(外部存储可访问目录) + * @param outputFileName 输出文件名 + */ + suspend fun exportSelectedPages( + inputFile: File, selectedPages: List, outputDir: File, outputFileName: String + ): File? = withContext(Dispatchers.IO) { + if (selectedPages.isEmpty()) return@withContext null + + if (!outputDir.exists()) outputDir.mkdirs() // 确保目录存在 + + val outputFile = File(outputDir, outputFileName) + try { + PDDocument.load(inputFile).use { document -> + val newDocument = PDDocument() + selectedPages.sortedBy { it.pageIndex }.forEach { pageItem -> + val page = document.getPage(pageItem.pageIndex) + newDocument.addPage(page) + } + newDocument.save(outputFile) + newDocument.close() + } + outputFile + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} diff --git a/app/src/main/res/drawable/back_black.xml b/app/src/main/res/drawable/back_black.xml index c02aa11..5e39e91 100644 --- a/app/src/main/res/drawable/back_black.xml +++ b/app/src/main/res/drawable/back_black.xml @@ -5,5 +5,5 @@ android:viewportHeight="1024"> + android:fillColor="#000000"/> diff --git a/app/src/main/res/drawable/bg_loading.xml b/app/src/main/res/drawable/bg_loading.xml new file mode 100644 index 0000000..fa39b5c --- /dev/null +++ b/app/src/main/res/drawable/bg_loading.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/dr_circular_sel_off_bg.xml b/app/src/main/res/drawable/dr_circular_sel_off_bg.xml new file mode 100644 index 0000000..59faf37 --- /dev/null +++ b/app/src/main/res/drawable/dr_circular_sel_off_bg.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dr_circular_sel_on_bg.xml b/app/src/main/res/drawable/dr_circular_sel_on_bg.xml new file mode 100644 index 0000000..08fbd13 --- /dev/null +++ b/app/src/main/res/drawable/dr_circular_sel_on_bg.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dr_item_page_img_sel_off_bg.xml b/app/src/main/res/drawable/dr_item_page_img_sel_off_bg.xml new file mode 100644 index 0000000..dd9a43f --- /dev/null +++ b/app/src/main/res/drawable/dr_item_page_img_sel_off_bg.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dr_item_page_img_sel_on_bg.xml b/app/src/main/res/drawable/dr_item_page_img_sel_on_bg.xml new file mode 100644 index 0000000..5c83ec6 --- /dev/null +++ b/app/src/main/res/drawable/dr_item_page_img_sel_on_bg.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dr_item_page_number_sel_off_bg.xml b/app/src/main/res/drawable/dr_item_page_number_sel_off_bg.xml new file mode 100644 index 0000000..11c6fb0 --- /dev/null +++ b/app/src/main/res/drawable/dr_item_page_number_sel_off_bg.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dr_item_page_number_sel_on_bg.xml b/app/src/main/res/drawable/dr_item_page_number_sel_on_bg.xml new file mode 100644 index 0000000..cfee03f --- /dev/null +++ b/app/src/main/res/drawable/dr_item_page_number_sel_on_bg.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dr_placeholder_bg.xml b/app/src/main/res/drawable/dr_placeholder_bg.xml new file mode 100644 index 0000000..7498e17 --- /dev/null +++ b/app/src/main/res/drawable/dr_placeholder_bg.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dr_sel_off_frame.xml b/app/src/main/res/drawable/dr_sel_off_frame.xml new file mode 100644 index 0000000..57d977b --- /dev/null +++ b/app/src/main/res/drawable/dr_sel_off_frame.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dr_sel_on_frame.xml b/app/src/main/res/drawable/dr_sel_on_frame.xml new file mode 100644 index 0000000..dbcef5b --- /dev/null +++ b/app/src/main/res/drawable/dr_sel_on_frame.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/file_document.xml b/app/src/main/res/drawable/file_document.xml new file mode 100644 index 0000000..b295851 --- /dev/null +++ b/app/src/main/res/drawable/file_document.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/gou_white.xml b/app/src/main/res/drawable/gou_white.xml new file mode 100644 index 0000000..b00118a --- /dev/null +++ b/app/src/main/res/drawable/gou_white.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/icon_split.xml b/app/src/main/res/drawable/icon_split.xml new file mode 100644 index 0000000..a3cdab0 --- /dev/null +++ b/app/src/main/res/drawable/icon_split.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/lock.xml b/app/src/main/res/drawable/lock.xml index 3c17f6f..639d123 100644 --- a/app/src/main/res/drawable/lock.xml +++ b/app/src/main/res/drawable/lock.xml @@ -3,10 +3,10 @@ android:height="256dp" android:viewportWidth="1024" android:viewportHeight="1024"> - - + + diff --git a/app/src/main/res/layout/activity_pdf_split.xml b/app/src/main/res/layout/activity_pdf_split.xml new file mode 100644 index 0000000..38409b9 --- /dev/null +++ b/app/src/main/res/layout/activity_pdf_split.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/adapter_split_page_item.xml b/app/src/main/res/layout/adapter_split_page_item.xml new file mode 100644 index 0000000..d30ef8e --- /dev/null +++ b/app/src/main/res/layout/adapter_split_page_item.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_list_more.xml b/app/src/main/res/layout/dialog_list_more.xml index 5a5fa22..4b88f48 100644 --- a/app/src/main/res/layout/dialog_list_more.xml +++ b/app/src/main/res/layout/dialog_list_more.xml @@ -251,6 +251,28 @@ android:textSize="16sp" /> + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 90a8b14..ed6df19 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -23,4 +23,5 @@ #BB6D64 #E43521 #33E43521 + #E8EAEE \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fffeaf8..19fc48b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -119,4 +119,9 @@ Are you sure you want to delete all Bookmarks? Loading bookmarks, please try again later no files yet + Split PDF + Merge PDF + %1$d Selected + Loading + Continue Now \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aea20b8..cb27d74 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ appcompat = "1.7.1" agp = "8.10.1" fragmentKtx = "1.8.9" glide = "5.0.4" +jp2forandroid = "1.0.4" kotlin = "2.0.21" ksp = "2.0.21-1.0.27" coreKtx = "1.17.0" @@ -30,6 +31,7 @@ androidx-room-compiler = { group = "androidx.room", name = "room-compiler", vers androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room_version" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room_version" } glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } +jp2forandroid = { module = "com.github.Tgo1014:JP2ForAndroid", version.ref = "jp2forandroid" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }