Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions plugins/pdf-office-workbench/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## v0.2.1 - 2026-09-01

- 适配 ZTools 3.2:合并、拆分和重命名生成的 PDF 可直接拖到外部应用,并采用 5 分钟、单次、精确真实路径授权。
- 新增 2.4.0 宿主版本门禁;低版本或真实宿主版本不可识别时在业务事件绑定前显示升级提示,2.4–3.1 保留打开所在文件夹流程。

## v0.2.0

- 接入真实 PDF 文件读写:选择、合并、按页拆分和输出结果。
Expand Down
7 changes: 7 additions & 0 deletions plugins/pdf-office-workbench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

当前范围聚焦 PDF 与票据流程;Word、Excel、PPT 的原生编辑/转换仍属于后续能力,不在本版本中宣称支持。

## ZTools 兼容性

- ZTools 3.2.0:生成的 PDF 可直接拖到外部应用。
- 外拖授权仅覆盖最近 5 分钟内由合并、拆分或重命名成功产生的普通 PDF,使用一次后立即失效。
- ZTools 2.4–3.1:保留打开所在文件夹的结果操作。
- 低于 2.4.0,或真实 ZTools 宿主无法提供可比较版本号:显示升级提示。仅未注入 `window.ztools` 的浏览器开发预览放行。

## 首批能力

- 选择多个 PDF 并真实合并为一个 PDF。
Expand Down
23 changes: 22 additions & 1 deletion plugins/pdf-office-workbench/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,16 @@ <h2>文件队列</h2>
import { extractInvoiceFields } from './src/core/index.mjs';

const services = window.services;
const state = { files: [], lastPath: '' };
const compatibility = !window.ztools
? { supported: true }
: (() => {
try { return services?.hostCompatibility?.() || { supported: false }; }
catch (_) { return { supported: false }; }
})();
if (!compatibility.supported) {
document.querySelector('main').innerHTML = '<section class="card"><h2>需要升级 ZTools</h2><p class="muted">当前 ZTools 版本过低或无法识别(最低支持 2.4.0)。为了获得更完整、稳定的体验,请升级后再使用 PDF 办公工坊。</p></section>';
} else {
const state = { files: [], lastPath: '', outputPaths: [] };
const $ = (selector) => document.querySelector(selector);
const output = $('#output');
const status = $('#status');
Expand All @@ -146,6 +155,11 @@ <h2>文件队列</h2>
status.className = `status${isError ? ' error' : ''}`;
}

function outputPaths(value) {
const entries = Array.isArray(value) ? value : [value];
return entries.map((entry) => entry?.path).filter((entry) => typeof entry === 'string' && entry.length > 0);
}

function render() {
const files = $('#files');
$('#summary').textContent = state.files.length
Expand Down Expand Up @@ -184,6 +198,7 @@ <h2>文件队列</h2>
try {
const result = await task();
state.lastPath = Array.isArray(result) ? result.at(-1)?.path || '' : result?.path || '';
state.outputPaths = outputPaths(result);
if (state.lastPath) $('#open-result').disabled = false;
show(result);
setStatus('完成');
Expand Down Expand Up @@ -214,9 +229,15 @@ <h2>文件队列</h2>
});
$('#invoice').addEventListener('click', () => { show(extractInvoiceFields($('#invoice-input').value)); setStatus('字段提取完成'); });
$('#open-result').addEventListener('click', () => state.lastPath && services.reveal(state.lastPath));
$('#open-result').draggable = services.canStartDrag?.() || false;
$('#open-result').addEventListener('dragstart', (event) => {
event.preventDefault();
if (state.outputPaths.length) services.startDrag(state.outputPaths).catch((error) => setStatus(error?.message || String(error), true));
});

window.addEventListener('pdf-office-enter', event => addFiles(event.detail.files));
render();
}
</script>
</body>
</html>
2 changes: 1 addition & 1 deletion plugins/pdf-office-workbench/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ztools-pdf-office-workbench",
"version": "0.2.0",
"version": "0.2.1",
"private": true,
"type": "module",
"description": "ZTools PDF and office document workflow plugin.",
Expand Down
2 changes: 1 addition & 1 deletion plugins/pdf-office-workbench/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"title": "PDF 办公工坊",
"description": "本地 PDF 和票据处理工作台:真实合并拆分、批量重命名、票据字段提取和批次汇总。",
"author": "harris",
"version": "0.2.0",
"version": "0.2.1",
"main": "index.html",
"preload": "preload/services.js",
"logo": "logo.svg",
Expand Down
60 changes: 60 additions & 0 deletions plugins/pdf-office-workbench/preload/file-drag-grants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const DEFAULT_TTL_MS = 5 * 60 * 1000

function createFileDragGrantStore(options) {
const fs = options.fs
const path = options.path
const now = options.now || Date.now
const ttlMs = options.ttlMs || DEFAULT_TTL_MS
const requiredExtension = String(options.requiredExtension || '').toLowerCase()
const grants = new Map()

function canonicalFile(filePath) {
if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) {
throw new Error('拖出的输出路径无效。')
}
let canonical
try {
canonical = fs.realpathSync(filePath)
if (!fs.statSync(canonical).isFile()) throw new Error('not a regular file')
} catch (_) {
throw new Error('拖出的输出路径无效或文件已不存在。')
}
if (requiredExtension && path.extname(canonical).toLowerCase() !== requiredExtension) {
throw new Error('拖出的输出文件类型无效。')
}
return canonical
}

function pruneExpired() {
const current = now()
for (const [filePath, expiresAt] of grants) {
if (expiresAt <= current) grants.delete(filePath)
}
}

return {
grant(filePath) {
const canonical = canonicalFile(filePath)
pruneExpired()
grants.set(canonical, now() + ttlMs)
return canonical
},

consume(filePaths) {
const values = Array.isArray(filePaths) ? filePaths : [filePaths]
if (!values.length) throw new Error('拖出的输出路径无效。')
pruneExpired()
const canonical = values.map(canonicalFile)
if (new Set(canonical).size !== canonical.length) {
throw new Error('拖出的输出路径包含重复文件。')
}
if (canonical.some(filePath => !grants.has(filePath))) {
throw new Error('只能拖出刚刚由插件生成的 PDF 文件。')
}
canonical.forEach(filePath => grants.delete(filePath))
return canonical
}
}
}

module.exports = { DEFAULT_TTL_MS, createFileDragGrantStore }
58 changes: 54 additions & 4 deletions plugins/pdf-office-workbench/preload/services.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { PDFDocument } = require('pdf-lib')
const { createFileDragGrantStore } = require('./file-drag-grants.js')

let shell
try {
Expand All @@ -10,6 +11,38 @@ try {
shell = null
}

const MINIMUM_VERSION = '2.4.0'
const dragGrants = createFileDragGrantStore({ fs, path, requiredExtension: '.pdf' })

function parseVersion(value) {
if (typeof value !== 'string') return null
const match = value.match(/^\s*v?(\d+)\.(\d+)(?:\.(\d+))?(?:[-+][0-9A-Za-z.-]+)?\s*$/)
if (!match) return null
const parts = [Number(match[1]), Number(match[2]), Number(match[3] || 0)]
return parts.every(Number.isSafeInteger) ? parts : null
}

function isPrereleaseVersion(value) {
return typeof value === 'string' && /^\s*v?\d+\.\d+(?:\.\d+)?-/.test(value)
}

function isSupportedHost() {
const ztools = window.ztools
if (!ztools) return { version: '', supported: true }
let getAppVersion
try { getAppVersion = ztools.getAppVersion } catch (_) { return { version: '', supported: false } }
if (typeof getAppVersion !== 'function') return { version: '', supported: false }
let version
try { version = getAppVersion.call(ztools) } catch (_) { return { version: '', supported: false } }
const current = parseVersion(version)
const minimum = parseVersion(MINIMUM_VERSION)
const atMinimum = Boolean(current && minimum) && current.every((part, index) => part === minimum[index])
const supported = Boolean(current && minimum) && !(
atMinimum && isPrereleaseVersion(version)
) && (current[0] > minimum[0] || (current[0] === minimum[0] && (current[1] > minimum[1] || (current[1] === minimum[1] && current[2] >= minimum[2]))))
return { version: typeof version === 'string' ? version : '', supported }
}

function hostPath(name, fallback) {
try {
const value = window.ztools?.getPath?.(name)
Expand Down Expand Up @@ -128,6 +161,7 @@ async function mergePdfs(paths, outputPath) {
}
fs.mkdirSync(path.dirname(output), { recursive: true })
fs.writeFileSync(output, await merged.save())
dragGrants.grant(output)
return { path: output, pages: totalPages, size: fs.statSync(output).size }
}

Expand All @@ -154,6 +188,7 @@ async function splitPdf(sourcePath, expression, outputDirectory) {
fs.writeFileSync(output, await document.save())
outputs.push({ path: output, pages: pages.length, size: fs.statSync(output).size })
}
outputs.forEach(item => dragGrants.grant(item.path))
return outputs
}

Expand All @@ -179,6 +214,7 @@ async function renameFiles(paths, template) {
for (const operation of operations) {
if (operation.source !== operation.target) fs.renameSync(operation.source, operation.target)
}
operations.forEach(operation => dragGrants.grant(operation.target))
return operations.map(operation => ({ path: operation.target, name: path.basename(operation.target) }))
}

Expand All @@ -195,7 +231,17 @@ const services = {
reveal(filePath) {
if (shell?.showItemInFolder) shell.showItemInFolder(filePath)
},
hostCompatibility: isSupportedHost,
canStartDrag() {
return typeof window.ztools?.startDrag === 'function'
},
async startDrag(paths) {
if (typeof window.ztools?.startDrag !== 'function') throw new Error('请升级到 ZTools 3.2.0 以拖出文件。')
const values = dragGrants.consume(normalizePaths(paths))
await Promise.resolve(window.ztools.startDrag(values.length === 1 ? values[0] : values))
},
async handlePluginEnter(action) {
if (!isSupportedHost().supported) return
const paths = normalizePaths(action?.payload)
if (!paths.length) return
const files = []
Expand All @@ -212,8 +258,12 @@ const services = {

window.services = services

window.ztools?.onPluginEnter?.(action => {
services.handlePluginEnter(action).catch(error => {
window.ztools?.showNotification?.(error instanceof Error ? error.message : String(error))
if (isSupportedHost().supported) {
window.ztools?.onPluginEnter?.(action => {
services.handlePluginEnter(action).catch(error => {
window.ztools?.showNotification?.(error instanceof Error ? error.message : String(error))
})
})
})
}

module.exports = { MINIMUM_VERSION, parseVersion, isSupportedHost, normalizePaths, parsePageRanges, services }
31 changes: 31 additions & 0 deletions plugins/pdf-office-workbench/tests/file-drag-grants.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const { createFileDragGrantStore } = require('../preload/file-drag-grants.js');

test('PDF output drag grants expire', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pdf-office-drag-expiry-'));
const output = path.join(root, 'generated.pdf');
fs.writeFileSync(output, 'pdf');
let currentTime = 100;
const grants = createFileDragGrantStore({
fs,
path,
now: () => currentTime,
ttlMs: 50,
requiredExtension: '.pdf'
});

try {
grants.grant(output);
currentTime = 151;
assert.throws(() => grants.consume(output), /刚刚由插件生成/);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
44 changes: 43 additions & 1 deletion plugins/pdf-office-workbench/tests/file-operations.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ async function makePdf(filePath, pageCount) {
test('preload services merge, split and rename real PDF files', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pdf-office-workbench-'));
const previousWindow = globalThis.window;
globalThis.window = { ztools: {} };
const dragged = [];
globalThis.window = { ztools: { getAppVersion: () => '3.2.0', startDrag: value => dragged.push(value) } };
try {
require('../preload/services.js');
const serviceModule = globalThis.window.services;
Expand All @@ -28,12 +29,17 @@ test('preload services merge, split and rename real PDF files', async () => {
await makePdf(first, 2);
await makePdf(second, 1);

await assert.rejects(serviceModule.startDrag(first), /刚刚由插件生成/);

const firstInfo = await serviceModule.readPdfInfo(first);
assert.equal(firstInfo.pages, 2);

const merged = await serviceModule.mergePdfs([first, second], path.join(root, 'merged.pdf'));
assert.equal(merged.pages, 3);
assert.equal((await serviceModule.readPdfInfo(merged.path)).pages, 3);
await serviceModule.startDrag(merged.path);
assert.deepEqual(dragged, [await fs.realpath(merged.path)]);
await assert.rejects(serviceModule.startDrag(merged.path), /刚刚由插件生成/);

const split = await serviceModule.splitPdf(merged.path, '1-2,3', root);
assert.deepEqual(split.map(item => item.pages), [2, 1]);
Expand All @@ -42,8 +48,44 @@ test('preload services merge, split and rename real PDF files', async () => {
const renamed = await serviceModule.renameFiles([split[0].path, split[1].path], 'invoice-{index}');
assert.deepEqual(renamed.map(item => item.name), ['invoice-1.pdf', 'invoice-2.pdf']);
assert.equal((await serviceModule.readPdfInfo(renamed[1].path)).pages, 1);
await serviceModule.startDrag(renamed.map(item => item.path));
assert.deepEqual(dragged[1], await Promise.all(renamed.map(item => fs.realpath(item.path))));
await assert.rejects(serviceModule.startDrag(renamed.map(item => item.path)), /刚刚由插件生成/);
} finally {
globalThis.window = previousWindow;
await fs.rm(root, { recursive: true, force: true });
}
});

test('host version gate only bypasses an explicit browser preview', async () => {
const previousWindow = globalThis.window;
globalThis.window = {};
try {
const module = require('../preload/services.js');
assert.equal(module.isSupportedHost().supported, true);
globalThis.window.ztools = {};
assert.equal(module.isSupportedHost().supported, false);
const throwingGetter = {};
Object.defineProperty(throwingGetter, 'getAppVersion', { get() { throw new Error('unavailable'); } });
globalThis.window.ztools = throwingGetter;
assert.equal(module.isSupportedHost().supported, false);
globalThis.window.ztools = { getAppVersion: () => { throw new Error('unavailable'); } };
assert.equal(module.isSupportedHost().supported, false);
for (const version of ['', 'unknown', 320]) {
globalThis.window.ztools = { getAppVersion: () => version };
assert.equal(module.isSupportedHost().supported, false);
}
globalThis.window.ztools = { getAppVersion: () => '2.3.9' };
assert.equal(module.isSupportedHost().supported, false);
globalThis.window.ztools = { getAppVersion: () => '2.4.0-beta.1' };
assert.equal(module.isSupportedHost().supported, false);
globalThis.window.ztools = { getAppVersion: () => '2.4.0' };
assert.equal(module.isSupportedHost().supported, true);
globalThis.window.ztools = { getAppVersion: () => '3.1.9' };
assert.equal(module.isSupportedHost().supported, true);
assert.deepEqual(module.parseVersion('3.2'), [3, 2, 0]);
assert.equal(module.parseVersion('ZTools 3.2.0'), null);
} finally {
globalThis.window = previousWindow;
}
});
Loading