- 桌面应用
- 开发工具
【免费下载链接】MooTool
Handy tool set for developers
本篇基于 MooTool 仓库next-compose产品线(Jetpack Compose Multiplatform 桌面版)F24 PDF 功能的本轮验收记录 results.md 展开,说明在 JDK 21 环境下执行./gradlew :composeApp:desktopTest全量 87/87 通过的验收口径、拆分/合并的页码语义与_split.pdf输出规则,并结合同仓库 Electron 参考实现(pdf-lib)与 Compose 侧PdfEngine源码,讲清 PDFBoximportPage的页级复制原理、加密 PDF 的失败语义、取消时的半成品清理机制,以及本轮仍未覆盖的测试边界(窗口截图、加密样本走查、安装镜像等)。
一、验收记录原文口径:F24 是什么、本轮做了什么
验收记录位于 next-compose/docs/evidence/2026-09-09-f24/results.md,完整要素如下:
| 记录项 | 内容 |
|---|---|
| 阶段/条目 | F24 PDF(P4 媒体/加密组) |
| 本产品工作树 | 仅next-compose/目录(Compose 产品线独立开发,不改动 Electron 或 Java 产品线) |
| 依赖 | Apache PDFBox3.0.4(版本固化在 next-compose/gradle/libs.versions.toml:pdfbox = "3.0.4") |
| 参考基线 | Electron 版 pdfService.ts、pageRanges.ts、PdfTool.tsx |
| 已执行 | JAVA_HOME指向 Zulu 21,./gradlew :composeApp:desktopTest87/87通过 |
| 语义覆盖 | 拆分/合并 Tab、最多 20 项、奇偶/自定义页码、源顺序去重、_split.pdf后缀、合并保存对话框、历史与分离窗口、取消删除半成品 |
| 关键差异 | DIFF-012(PDFBoximportPage对齐pdf-libcopyPages,见 diff/012-pdfbox-import-page.md) |
| 未测项 | 窗口截图、加密样本(手工走查)、表单/书签/签名(安装镜像内)、安装镜像 |
| 下一轮 | P4 剩余 F23 图片助手 |
在 next-compose/docs/feature-parity.md 的功能清单中,F24 登记为pdf工具(pdf/PdfTool.tsx的 Compose 对齐项),其验收状态与差异引用(DIFF-012、DIFF-032、DIFF-048)均指向本记录所在的 PDF 拆分/合并能力。
二、参考基线:Electron 版用 pdf-lib 做了什么
Compose 产品线的目标不是重写功能,而是与 Electron 产品线做行为对齐(parity)。Electron 侧的实现是两条短链:
- 页码解析在 next/src/shared/utils/pageRanges.ts:
export function parsePageSelection(expression: string, maxPage: number): number[] { if (!Number.isSafeInteger(maxPage) || maxPage < 1) throw new Error('PDF has no pages') const value = expression.trim().replace(/[,,]/g, ';').replace(/\s+/g, '') if (!value || !/^\d+(?:-\d+)?(?:;\d+(?:-\d+)?)*$/.test(value)) throw new Error('Use page ranges such as 1-5;8;10-12') const pages = new Set<number>() for (const token of value.split(';')) { const [startText, endText = startText] = token.split('-') const start = Number(startText) const end = Number(endText) if (start < 1 || end < start || end > maxPage) throw new Error(`Page range must stay between 1 and ${maxPage}`) for (let page = start; page <= end; page += 1) pages.add(page) } return [...pages] }要点:逗号(含中文逗号,)统一转分号、剔除空白后用^\d+(?:-\d+)?(?:;\d+(?:-\d+)?)*$校验;用Set按 token 出现顺序去重,返回 1 基页码列表。selectSplitPages再在其上叠加odd/even/custom(自定义规则与候选页求交集,且保留候选顺序)。
- 页面操作在 next/electron/main/pdfService.ts:
mergePdfs要求至少 2 个源;splitPdfs对每个任务读取字节 →PDFDocument.load→selectSplitPages选页 →output.copyPages(source, pages.map((p) => p - 1))→addPage,输出路径固定为源目录下的`${parsed.name.toLowerCase()}_split.pdf`(小写主名 +_split.pdf),已存在则覆盖。加密 PDF 未支持。
三、Compose 侧实现:PdfEngine 的页级复制与错误语义
Compose 产品线把上述逻辑落在 next-compose/composeApp/src/desktopMain/kotlin/com/rememberber/mootool/next/compose/domain/PdfEngine.kt(对象PdfEngine,依赖org.apache.pdfbox:pdfbox:3.0.4,在 composeApp/build.gradle.kts 的 desktopMain 源集中以implementation(libs.pdfbox)引入)。
3.1 页码解析与奇偶/自定义规则(对齐 pageRanges.ts)
parsePageSelection(expression, maxPage)与 Electron 版逐条对齐:maxPage < 1抛no-pages;trim后把[,,]替换为;、剔除空白;同一套正则校验,非法抛invalid-range(错误信息同为Use page ranges such as 1-5;8;10-12);用LinkedHashSet展开区间并保持源顺序去重,越界(start < 1 || end < start || end > maxPage)抛invalid-range。
selectSplitPages(pageRange, rule, customRule, maxPage)对PdfSplitRule.Odd/Even做奇偶过滤,Custom则把自定义表达式解析成集合后取交集——与 pageRanges.ts 中selectSplitPages的语义完全一致。
3.2 拆分:_split.pdf命名与 20 项上限
split(tasks, cancelled)的约束与行为:
- 空任务列表抛
empty-selection;tasks.size > MAX_TASKS(常量MAX_TASKS = 20)抛too-many(错误信息At most 20 PDF tasks); - 每个任务内先
open(sourcePath)(内部ensurePdf校验存在性与.pdf扩展名,再Loader.loadPDF),按selectSplitPages选页,空选择抛empty-pages; - 输出路径由
splitOutputPath(source)生成:source.resolveSibling("${source.nameWithoutExtension.lowercase()}_split.pdf")——即源目录、小写主名 +_split.pdf,与 ElectronpdfService.ts第 44 行的join(dirname(task.path), `${parsed.name.toLowerCase()}_split.pdf`)一致;源文件叫Quarterly.PDF时输出为quarterly_split.pdf; - 每页写入前与写完后都会检查
cancelled(),命中即抛cancelled。
页级复制的核心是writePages:新建空文档,subset.importPage(source.getPage(page - 1))逐页导入,PDFMergerUtility().appendDocument(destination, subset)合并后保存。importPage是 PDFBox 的页面对象复制 API,直接搬移 PDF 内部的页面对象(内容流、资源引用),而不是把页面渲染成位图再重新造一个 PDF——这正是 DIFF-012 中「保留页面内容流,满足真 PDF 页面操作」的实现依据,也对应 Electron 侧pdf-libcopyPages的等价语义。
3.3 合并:至少 2 个源、按源顺序追加
merge(sources, outputPath, cancelled):少于 2 个源抛need-two,超过 20 个抛too-many;对每个源解析各自页码表达式、importPage到临时子集文档、appendDocument追加到目标文档;最终pageCount == 0抛empty-pages;保存前Files.createDirectories补齐父目录,destination.save落盘。合并入口由 UI 弹出保存对话框指定输出(验收记录中的「合并保存对话框」,Electron 侧默认桌面merge.pdf)。
3.4 三个值得注意的纵深细节
- 加密 PDF 的稳定错误码:
open()捕获 PDFBox 的InvalidPasswordException并转成PdfException("encrypted", "Encrypted PDF files are not supported"),其他读取失败统一归一为invalid。这与 DIFF-012 声明的「加密 PDF 以InvalidPasswordException明确失败」一致,且错误码稳定,便于 UI 以 toast 形式提示而非栈溢出信息。 - 取消删除半成品:
split的 catch 块中,若异常是code == "cancelled",会Files.deleteIfExists删除本批已经写出的全部输出文件再向外抛出——即验收记录中的「取消删除半成品」;测试侧用AtomicBoolean(true)模拟取消,断言目标文件不存在(见 4.1 节第 6 条)。 - 页级表单修复
repairImportedInteractiveForms:importPage复制的页面携带 AcroForm widget 标注后,PDFBox 需要重新挂接孤儿 widget 与默认值。源码对每个子集文档与最终文档执行AcroFormOrphanWidgetsProcessor(document).process()+AcroFormDefaultFixup(document).apply(),保证拆分/合并后表单字段、签名字段计数可被inspect正确统计。同时ELECTRON_PARITY_OUTLINES_NOT_COPIED = true显式声明:文档大纲/书签不被复制,与 Electronpdf-libcopyPages行为一致——这是刻意为之的 parity 取舍,而非能力缺失。
inspect(path)还会返回结构统计:页数、表单字段数(acroForm.fields)、书签数(递归遍历documentOutline)、签名字段数(PDSignatureField计数),hasSpecialObjects供 UI 在含表单/书签/签名时给出提示。
四、测试证据:PdfEngineTest 如何印证验收语义
验收记录的「已执行./gradlew :composeApp:desktopTest87/87」中,PDF 引擎的验证集中在 next-compose/composeApp/src/desktopTest/kotlin/com/rememberber/mootool/next/compose/domain/PdfEngineTest.kt。DIFF-012 记录的证据条目与测试一一对应:
1-3;2;7;9-10→[1,2,3,7,9,10]:跨 token 去重保序;1-2, 4,6验证逗号/中文逗号/空白归一;3-1、1;;2、1-6(maxPage=5) 断言invalid-range失败。- 奇偶/自定义:
2-8+Odd→[3,5,7];Even→[2,4,6,8];Custom "1;3-4;8-10"→[3,4,8](交集且保持候选顺序)。 - 真实文件合并:4 页
First.pdf(取2-3)+ 3 页Second.pdf(取1,3)合并 4 页,extractText验证第 1 页含Page 2、第 4 页含Page 3——用PDFTextStripper提取文本证明输出是结构完整、可再解析的真 PDF。 - 拆分命名:
Quarterly.PDF6 页按Odd拆 3 页,输出恰为quarterly_split.pdf,且第 1/3 页文本为Page 1/Page 5。 - 上限:21 个拆分任务与 21 个合并源均断言
PdfException.code == "too-many"。 - 边界与取消:非 PDF 文件断言
not-pdf;0 个合并源断言need-two;cancelled = { true }的合并不产出cancelled.pdf(取消无输出文件)。 - 结构保留:构造含表单 + 签名字段 + 书签的 fixture,合并/拆分后
inspect的表单与签名字段数保持不变,而书签数为 0,断言注释明确标注原因即「Page-only merge does not copy document outlines (same as Electron pdf-lib copyPages)」。 - 加密样本:
StandardProtectionPolicy保护后的 PDF,inspect断言code == "encrypted"。
测试 fixture(writeNumberedPdf/writeStructuredPdf/writeEncryptedPdf)全部用 PDFBox 在临时目录现造,无外部资产依赖,这也是 87/87 可以在无头 CI 环境稳定跑通的原因。
五、如何在本仓库复跑该验收
环境与命令以 next-compose/README.md 与 next-compose/scripts/check-core.sh 为准:
export JAVA_HOME="$(/usr/libexec/java_home -v 21)" # Zulu 21(README 环境表:JDK 21 / Zulu 21.0.12.1) ./gradlew :composeApp:desktopTest # 或 ./scripts/check-core.sh 封装执行适用前提与限制:
- 本记录对应的验收轮只跑
next-compose/工作树(:composeApp:desktopTest87/87);next-compose/docs/acceptance.md中后续轮次的数字(如 1342/1344)属于后续 DIFF 累积后的全量结果,不代表 2026-09-09 本轮口径; - PDFBox 版本以
gradle/libs.versions.toml的pdfbox = "3.0.4"为准; - 未覆盖项如实登记:窗口截图、加密样本的手工走查、表单/书签/签名在安装镜像内的表现、安装镜像内的 PDFBox native 字体替代路径均未测——即 DIFF-012 的「受影响范围」条目,阅读代码结论时应以此为边界。
六、与 Electron 版的差异与边界(DIFF-012 摘要)
diff/012-pdfbox-import-page.md 对两版实现做了明确归档,可归纳为三点:
| 维度 | Electron(pdf-lib) | next-compose(PDFBox 3.0.4) |
|---|---|---|
| 页面复制 | PDFDocument.load字节后copyPages+addPage | Loader.loadPDF+PDDocument.importPage+PDFMergerUtility,页面对象复制,不光栅化 |
| 拆分输出 | 源目录{小写主名}_split.pdf | 相同(splitOutputPath),同样覆盖已存在的*_split.pdf |
| 加密 PDF | 未支持(PDFDocument.load失败) | 以InvalidPasswordException明确失败,稳定错误码encrypted |
差异文档同时声明边界:表单/书签/签名的保留范围未做专项实测,不能宣称与pdf-lib像素级一致;文档书签在拆分/合并不复制(ELECTRON_PARITY_OUTLINES_NOT_COPIED)。选择 PDFBox 而非 iText 的理由是架构指定,避免引入额外 PDF 库。
七、结论:这条验收记录留下的可验证链路
F24 本轮验收的价值在于形成了一条完整可复现的证据链:页码语义由 pageRanges.ts 定义、PdfEngine.kt 逐条复刻(含too-many/encrypted/cancelled等稳定错误码与半成品清理),PdfEngineTest.kt 用真实生成的 PDF 验证拆分/合并/去重/取消/结构保留,./gradlew :composeApp:desktopTest87/87 收口,差异与未测项由 DIFF-012 与验收记录本身登记在案。下一轮按记录推进至 P4 剩余的 F23 图片助手。
- 桌面应用
- 开发工具
【免费下载链接】MooTool
Handy tool set for developers
相关推荐
MooTool next-compose 的 PDF 拆分/合并引擎:基于 PDFBox importPage 的真页面操作实现
MooTool next compose 的 PDF 拆分/合并引擎:基于 PDFBox importPage 的真页面操作实现 导读 MooTool 的 ne
桌面应用开发工具Symfony Process 组件 CHANGELOG 深度解读:从 Process 到 PhpSubprocess 与 Messenger 的完整演进
Symfony Process 组件 CHANGELOG 深度解读:从 Process 到 PhpSubprocess 与 Messenger 的完整演进 本篇
桌面应用开发工具Wand-Enhancer 完整指南:免费解除 WeMod 免费版 2 小时限制、启用 Pro 功能
Wand Enhancer 完整指南:免费解除 WeMod 免费版 2 小时限制、启用 Pro 功能 Boss 打到一半突然被掐断,多半是免费版 WeMod 用
桌面应用开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考