From c528a3ad45222a15dc3565f62da871fb7adfa806 Mon Sep 17 00:00:00 2001 From: wangzihao Date: Tue, 1 Sep 2026 11:39:58 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(format-converter):=20=E9=80=82?= =?UTF-8?q?=E9=85=8D=E6=88=AA=E5=9B=BE=E5=AF=BC=E5=85=A5=E3=80=81=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=8B=96=E6=8B=BD=E4=B8=8E=20pluginData?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接入 ZTools 3.2 截图编辑态、文件外拖和插件数据目录,同时保留 2.4–3.1 兼容回退。 AI-Co-Authored-By: Codex --- plugins/format-converter/CHANGELOG.md | 7 + plugins/format-converter/README.md | 7 + plugins/format-converter/package.json | 2 +- plugins/format-converter/plugin.json | 2 +- .../preload/capture-temp-store.cjs | 304 ++++++++++++++++++ .../preload/host-compatibility.cjs | 89 +++++ .../format-converter/preload/job-manager.cjs | 1 + plugins/format-converter/preload/package.json | 2 +- .../preload/screen-capture.cjs | 19 ++ plugins/format-converter/preload/services.cjs | 99 +++++- .../format-converter/scripts/verify-dist.mjs | 2 +- plugins/format-converter/src/App.tsx | 35 +- plugins/format-converter/src/types.ts | 7 + .../tests/preload/capture-temp-store.test.cjs | 186 +++++++++++ .../tests/preload/host-compatibility.test.cjs | 57 ++++ .../tests/preload/job-manager.test.cjs | 16 + .../tests/preload/screen-capture.test.cjs | 28 ++ .../tests/preload/services.test.cjs | 142 +++++++- 18 files changed, 991 insertions(+), 14 deletions(-) create mode 100644 plugins/format-converter/preload/capture-temp-store.cjs create mode 100644 plugins/format-converter/preload/host-compatibility.cjs create mode 100644 plugins/format-converter/preload/screen-capture.cjs create mode 100644 plugins/format-converter/tests/preload/capture-temp-store.test.cjs create mode 100644 plugins/format-converter/tests/preload/host-compatibility.test.cjs create mode 100644 plugins/format-converter/tests/preload/screen-capture.test.cjs diff --git a/plugins/format-converter/CHANGELOG.md b/plugins/format-converter/CHANGELOG.md index b7c015811..bf53f5c55 100644 --- a/plugins/format-converter/CHANGELOG.md +++ b/plugins/format-converter/CHANGELOG.md @@ -1,5 +1,12 @@ # 更新日志 +## 0.1.1 - 2026-09-01 + +- 适配 ZTools 3.2:截图导入、转换结果外拖,以及 runtime 使用 `pluginData` 专属目录。 +- runtime 迁移保留旧 `userData` 副本,ZTools 2.4–3.1 继续从原路径工作。 +- 新增 2.4.0 宿主版本门禁;低版本或真实宿主版本不可识别时在业务初始化前显示升级提示,仅浏览器开发预览放行。 +- 输出外拖拒绝符号链接、非普通文件及真实路径逃逸授权目录的文件,并只向宿主传递规范化真实路径。 + ## 0.1.0 - 2026-07-29 - 首次提供通用格式转换中心和批量作业队列。 diff --git a/plugins/format-converter/README.md b/plugins/format-converter/README.md index 9a92842f1..49d8c0139 100644 --- a/plugins/format-converter/README.md +++ b/plugins/format-converter/README.md @@ -2,6 +2,13 @@ 面向 ZTools 的本地批量格式转换中心。支持 Word、Excel、PowerPoint、PDF、图片、文本和常见数据格式,并明确区分视觉保真、可编辑重建和内容提取三类路线。 +## ZTools 兼容性 + +- ZTools 3.2.0:支持截图导入、转换输出外拖,并将可重建 runtime 优先放入 `pluginData`。 +- ZTools 2.4–3.1:继续使用原 `userData` runtime、文件选择和打开目录流程。 +- runtime 迁移为复制后保留旧目录,不会在升级时删除旧数据,支持临时降级宿主。 +- 低于 2.4.0,或真实 ZTools 宿主无法提供可比较版本号:显示升级提示。仅未注入 `window.ztools` 的浏览器开发预览放行。 + ## 首发范围 - 图片:PNG、JPEG、WebP、AVIF、TIFF、GIF、BMP 的读取与常见格式输出。 diff --git a/plugins/format-converter/package.json b/plugins/format-converter/package.json index cf35ef2aa..399d376af 100644 --- a/plugins/format-converter/package.json +++ b/plugins/format-converter/package.json @@ -1,6 +1,6 @@ { "name": "ztools-format-converter", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "description": "Cross-platform batch format conversion workbench for ZTools.", diff --git a/plugins/format-converter/plugin.json b/plugins/format-converter/plugin.json index a0c755b72..7128bbdbc 100644 --- a/plugins/format-converter/plugin.json +++ b/plugins/format-converter/plugin.json @@ -3,7 +3,7 @@ "title": "格式转换", "description": "Word、Excel、PPT、PDF、图片、文本与常见数据格式的本地批量转换中心。", "author": "harris", - "version": "0.1.0", + "version": "0.1.1", "main": "index.html", "preload": "preload/services.cjs", "logo": "logo.svg", diff --git a/plugins/format-converter/preload/capture-temp-store.cjs b/plugins/format-converter/preload/capture-temp-store.cjs new file mode 100644 index 000000000..a3a3111ee --- /dev/null +++ b/plugins/format-converter/preload/capture-temp-store.cjs @@ -0,0 +1,304 @@ +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs/promises"); +const fssync = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const DEFAULT_MAX_CAPTURE_BYTES = 64 * 1024 ** 2; +const activeStores = new Set(); +let processCleanupRegistered = false; + +function captureError(code, message, cause) { + const error = new Error(message, cause ? { cause } : undefined); + error.code = code; + return error; +} + +function parseCaptureDataUrl(dataUrl, maxBytes = DEFAULT_MAX_CAPTURE_BYTES) { + if (typeof dataUrl !== "string") throw captureError("SCREEN_CAPTURE_INVALID", "截图格式不受支持。"); + const header = /^data:image\/(png|jpeg|jpg|webp);base64,/i.exec(dataUrl); + if (!header) throw captureError("SCREEN_CAPTURE_INVALID", "截图格式不受支持。"); + const payload = dataUrl.slice(header[0].length); + const maxBase64Length = Math.ceil(maxBytes / 3) * 4; + if (payload.length > maxBase64Length) throw captureError("SCREEN_CAPTURE_TOO_LARGE", `截图不能超过 ${Math.round(maxBytes / 1024 ** 2)} MB。`); + if (!payload || payload.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(payload)) { + throw captureError("SCREEN_CAPTURE_INVALID", "截图数据不是有效的 Base64 图像。"); + } + const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0; + const decodedBytes = payload.length / 4 * 3 - padding; + if (decodedBytes <= 0 || decodedBytes > maxBytes) throw captureError("SCREEN_CAPTURE_TOO_LARGE", `截图不能超过 ${Math.round(maxBytes / 1024 ** 2)} MB。`); + const buffer = Buffer.from(payload, "base64"); + if (buffer.length !== decodedBytes) throw captureError("SCREEN_CAPTURE_INVALID", "截图数据不是有效的 Base64 图像。"); + return { buffer, extension: header[1].toLowerCase() === "jpg" ? "jpeg" : header[1].toLowerCase() }; +} + +function registerProcessCleanup() { + if (processCleanupRegistered || typeof process?.once !== "function") return; + processCleanupRegistered = true; + process.once("exit", () => { + for (const store of [...activeStores]) store.cleanupAllSync(); + }); +} + +function createCaptureTempStore(options = {}) { + const maxBytes = Number.isSafeInteger(options.maxBytes) && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_CAPTURE_BYTES; + const now = options.now || Date.now; + const randomId = options.randomId || (() => crypto.randomUUID()); + const tempDirectory = options.tempDirectory || os.tmpdir(); + const trackedPaths = new Set(); + const grants = new Map(); + let root = null; + let rootPromise = null; + let generation = 0; + + function rootIsSafeSync(candidate) { + if (!candidate || path.dirname(candidate) === candidate) return false; + try { + const stat = fssync.lstatSync(candidate); + return stat.isDirectory() && !stat.isSymbolicLink() && fssync.realpathSync(candidate) === candidate; + } catch { return false; } + } + + function removeRootIfEmptySync() { + if (!root || trackedPaths.size) return; + const candidate = root; + try { + const stat = fssync.lstatSync(candidate); + if (stat.isSymbolicLink()) fssync.unlinkSync(candidate); + else if (stat.isDirectory()) fssync.rmdirSync(candidate); + } catch {} + if (!fssync.existsSync(candidate)) { + root = null; + activeStores.delete(store); + } + } + + function clearGrantRecord(grantId) { + const grant = grants.get(grantId); + if (!grant) return null; + grants.delete(grantId); + if (grant.timer) clearTimeout(grant.timer); + return grant; + } + + function cleanupTrackedPathSync(filePath) { + if (!trackedPaths.has(filePath)) return false; + if (!rootIsSafeSync(root)) { + cleanupAllSync(); + return true; + } + if (path.dirname(filePath) !== root) { + trackedPaths.delete(filePath); + return false; + } + try { + const stat = fssync.lstatSync(filePath); + if (stat.isFile() || stat.isSymbolicLink()) fssync.unlinkSync(filePath); + } catch {} + trackedPaths.delete(filePath); + removeRootIfEmptySync(); + return true; + } + + function cleanupGrantSync(grantId) { + const grant = clearGrantRecord(grantId); + if (!grant) return false; + return cleanupTrackedPathSync(grant.filePath); + } + + function cleanupAllSync() { + generation += 1; + for (const grantId of [...grants.keys()]) clearGrantRecord(grantId); + const candidate = root; + if (!candidate) { + trackedPaths.clear(); + activeStores.delete(store); + return; + } + if (!rootIsSafeSync(candidate)) { + try { if (fssync.lstatSync(candidate).isSymbolicLink()) fssync.unlinkSync(candidate); } catch {} + trackedPaths.clear(); + root = null; + activeStores.delete(store); + return; + } + for (const filePath of [...trackedPaths]) { + if (path.dirname(filePath) !== candidate) continue; + try { + const stat = fssync.lstatSync(filePath); + if (stat.isFile() || stat.isSymbolicLink()) fssync.unlinkSync(filePath); + } catch {} + } + trackedPaths.clear(); + try { fssync.rmdirSync(candidate); } catch {} + if (!fssync.existsSync(candidate)) root = null; + activeStores.delete(store); + } + + function cleanupInactiveSync() { + generation += 1; + const currentTime = now(); + const retainedPaths = new Set(); + for (const [grantId, grant] of [...grants]) { + const retryable = grant.jobId !== null && grant.expiresAt > currentTime; + if (grant.active || retryable) retainedPaths.add(grant.filePath); + else cleanupGrantSync(grantId); + } + for (const filePath of [...trackedPaths]) if (!retainedPaths.has(filePath)) cleanupTrackedPathSync(filePath); + } + + async function assertSafeRoot(candidate) { + const stat = await fs.lstat(candidate).catch(() => null); + if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) throw captureError("CAPTURE_TEMP_BOUNDARY", "截图临时目录已失效,请重试。"); + const [canonicalRoot, canonicalTemp] = await Promise.all([fs.realpath(candidate), fs.realpath(tempDirectory)]); + if (canonicalRoot !== candidate || path.dirname(canonicalRoot) !== canonicalTemp) { + throw captureError("CAPTURE_TEMP_BOUNDARY", "截图临时目录越过了安全边界。"); + } + return canonicalRoot; + } + + async function ensureRoot() { + if (root) return assertSafeRoot(root); + if (rootPromise) return rootPromise; + const expectedGeneration = generation; + rootPromise = (async () => { + const canonicalTemp = await fs.realpath(tempDirectory); + const created = await fs.mkdtemp(path.join(canonicalTemp, `ztools-format-converter-${process.pid}-`)); + try { + await fs.chmod(created, 0o700); + const canonicalRoot = await fs.realpath(created); + const stat = await fs.lstat(created); + if (generation !== expectedGeneration) throw captureError("CAPTURE_TEMP_CLEANED", "插件已退出,截图临时目录已清理。"); + if (stat.isSymbolicLink() || !stat.isDirectory() || canonicalRoot !== created || path.dirname(canonicalRoot) !== canonicalTemp) { + throw captureError("CAPTURE_TEMP_BOUNDARY", "截图临时目录越过了安全边界。"); + } + root = canonicalRoot; + activeStores.add(store); + registerProcessCleanup(); + return root; + } catch (error) { + try { await fs.rmdir(created); } catch {} + throw error; + } + })(); + try { return await rootPromise; } + finally { rootPromise = null; } + } + + async function createFromDataUrl(dataUrl) { + const { buffer, extension } = parseCaptureDataUrl(dataUrl, maxBytes); + const expectedGeneration = generation; + const safeRoot = await ensureRoot(); + if (generation !== expectedGeneration) throw captureError("CAPTURE_TEMP_CLEANED", "插件已退出,截图临时目录已清理。"); + const id = String(randomId()); + if (!/^[A-Za-z0-9-]{1,128}$/.test(id)) throw captureError("CAPTURE_TEMP_WRITE_FAILED", "无法生成安全的截图临时文件名。"); + const output = path.join(safeRoot, `capture-${id}.${extension}`); + if (path.dirname(output) !== safeRoot) throw captureError("CAPTURE_TEMP_BOUNDARY", "截图临时文件越过了安全边界。"); + let handle; + let created = false; + try { + await assertSafeRoot(safeRoot); + const flags = fssync.constants.O_WRONLY | fssync.constants.O_CREAT | fssync.constants.O_EXCL | (fssync.constants.O_NOFOLLOW || 0); + handle = await fs.open(output, flags, 0o600); + created = true; + await handle.writeFile(buffer); + await handle.chmod(0o600); + await handle.sync(); + await handle.close(); + handle = null; + if (generation !== expectedGeneration) throw captureError("CAPTURE_TEMP_CLEANED", "插件已退出,截图临时文件已清理。"); + const [stat, canonicalFile, canonicalParent] = await Promise.all([fs.lstat(output), fs.realpath(output), fs.realpath(path.dirname(output))]); + if (stat.isSymbolicLink() || !stat.isFile() || canonicalParent !== safeRoot || path.dirname(canonicalFile) !== safeRoot || canonicalFile !== output) { + throw captureError("CAPTURE_TEMP_BOUNDARY", "截图临时文件越过了安全边界。"); + } + trackedPaths.add(canonicalFile); + return { path: canonicalFile, size: stat.size }; + } catch (error) { + try { await handle?.close(); } catch {} + if (created) await fs.unlink(output).catch(() => undefined); + removeRootIfEmptySync(); + if (error?.code === "EEXIST" || error?.code === "ELOOP") throw captureError("CAPTURE_TEMP_CONFLICT", "截图临时文件已被占用,请重试。", error); + if (error?.code) throw error; + throw captureError("CAPTURE_TEMP_WRITE_FAILED", "无法安全写入截图临时文件。", error); + } + } + + function scheduleExpiry(grantId) { + const grant = grants.get(grantId); + if (!grant) return; + const delay = Math.max(0, Math.min(2_147_483_647, grant.expiresAt - now() + 1)); + grant.timer = setTimeout(() => { + const current = grants.get(grantId); + if (!current) return; + if (current.expiresAt <= now() && current.active) { + current.expired = true; + current.timer = null; + } else if (current.expiresAt <= now()) cleanupGrantSync(grantId); + else scheduleExpiry(grantId); + }, delay); + grant.timer.unref?.(); + } + + function associateGrant(grantId, filePath, expiresAt) { + if (typeof grantId !== "string" || !trackedPaths.has(filePath)) throw captureError("CAPTURE_TEMP_GRANT_INVALID", "截图临时文件授权无效。"); + const expiry = Number.isFinite(expiresAt) ? expiresAt : now() + 12 * 60 * 60 * 1000; + grants.set(grantId, { filePath, expiresAt: expiry, jobId: null, active: false, expired: false, timer: null }); + if (expiry <= now()) cleanupGrantSync(grantId); else scheduleExpiry(grantId); + } + + function assertConsumable(grantId) { + const grant = grants.get(grantId); + if (grant?.jobId) throw captureError("SCREEN_CAPTURE_GRANT_CONSUMED", "该截图已开始转换,请重新截图后再创建新任务。"); + } + + function markConsumed(grantId, jobId) { + const grant = grants.get(grantId); + if (grant) { grant.jobId = jobId; grant.active = true; } + } + + function markJobActive(jobId) { + for (const grant of grants.values()) if (grant.jobId === jobId) grant.active = true; + } + + function settleJob(grantId, job) { + const grant = grants.get(grantId); + if (!grant || (grant.jobId && grant.jobId !== job?.id)) return false; + grant.active = false; + const retryable = Array.isArray(job?.items) && job.items.some(item => item.status === "failed" || item.status === "cancelled"); + if (!retryable || grant.expired || grant.expiresAt <= now()) return cleanupGrantSync(grantId); + return false; + } + + function discardFile(filePath) { + for (const [grantId, grant] of grants) if (grant.filePath === filePath) clearGrantRecord(grantId); + return cleanupTrackedPathSync(filePath); + } + + function pruneExpired(at = now()) { + for (const [grantId, grant] of grants) { + if (grant.expiresAt > at) continue; + if (grant.active) grant.expired = true; + else cleanupGrantSync(grantId); + } + } + + const store = Object.freeze({ + maxBytes, + createFromDataUrl, + associateGrant, + assertConsumable, + markConsumed, + markJobActive, + settleJob, + discardFile, + cleanupGrantSync, + cleanupInactiveSync, + cleanupAllSync, + pruneExpired + }); + return store; +} + +module.exports = { DEFAULT_MAX_CAPTURE_BYTES, captureError, parseCaptureDataUrl, createCaptureTempStore }; diff --git a/plugins/format-converter/preload/host-compatibility.cjs b/plugins/format-converter/preload/host-compatibility.cjs new file mode 100644 index 000000000..de042e9db --- /dev/null +++ b/plugins/format-converter/preload/host-compatibility.cjs @@ -0,0 +1,89 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +const MINIMUM_VERSION = "2.4.0"; + +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 compareVersions(left, right) { + const a = parseVersion(left); + const b = parseVersion(right); + if (!a || !b) return null; + for (let index = 0; index < 3; index += 1) { + if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1; + } + const leftPrerelease = typeof left === "string" && /^\s*v?\d+\.\d+(?:\.\d+)?-/.test(left); + const rightPrerelease = typeof right === "string" && /^\s*v?\d+\.\d+(?:\.\d+)?-/.test(right); + if (leftPrerelease !== rightPrerelease) return leftPrerelease ? -1 : 1; + return 0; +} + +function getHostCompatibility(ztools) { + // A missing ztools object is the explicit browser-development preview case. + 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 comparison = compareVersions(version, MINIMUM_VERSION); + return { + version: typeof version === "string" ? version : "", + supported: comparison !== null && comparison >= 0 + }; +} + +function copyDirectory(source, destination) { + if (!fs.existsSync(source)) return false; + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.cpSync(source, destination, { recursive: true, force: false, errorOnExist: false }); + return directoriesMatch(source, destination); +} + +function directoriesMatch(source, destination) { + const sourceStat = fs.statSync(source); + const destinationStat = fs.statSync(destination); + if (sourceStat.isDirectory() !== destinationStat.isDirectory()) return false; + if (!sourceStat.isDirectory()) return sourceStat.size === destinationStat.size; + const sourceNames = fs.readdirSync(source).sort(); + const destinationNames = fs.readdirSync(destination).sort(); + if (sourceNames.length !== destinationNames.length || sourceNames.some((name, index) => name !== destinationNames[index])) return false; + return sourceNames.every(name => directoriesMatch(path.join(source, name), path.join(destination, name))); +} + +function runtimeRoot(ztools, legacyRoot) { + let pluginData = ""; + try { pluginData = typeof ztools?.getPath === "function" ? ztools.getPath("pluginData") : ""; } catch {} + if (!pluginData) return { root: legacyRoot, migrated: false, modern: false }; + const root = path.join(pluginData, "runtime", "v1"); + const marker = path.join(pluginData, ".format-converter-runtime-migration-v1.json"); + let migrated = false; + try { + if (fs.existsSync(marker)) { + if (fs.existsSync(root) || !fs.existsSync(legacyRoot)) return { root, migrated, modern: true }; + return { root: legacyRoot, migrated: false, modern: false }; + } + if (fs.existsSync(legacyRoot)) { + const verified = fs.existsSync(root) + ? directoriesMatch(legacyRoot, root) + : copyDirectory(legacyRoot, root); + if (!verified) return { root: legacyRoot, migrated: false, modern: false }; + fs.writeFileSync(marker, JSON.stringify({ version: 1, source: legacyRoot, destination: root, copiedAt: new Date().toISOString() })); + migrated = true; + } + } catch { + // A failed migration must leave the old runtime usable; never remove it. + return { root: legacyRoot, migrated: false, modern: false }; + } + return { root, migrated, modern: true }; +} + +module.exports = { MINIMUM_VERSION, parseVersion, compareVersions, getHostCompatibility, copyDirectory, directoriesMatch, runtimeRoot }; diff --git a/plugins/format-converter/preload/job-manager.cjs b/plugins/format-converter/preload/job-manager.cjs index 49fc6add6..cebe3d673 100644 --- a/plugins/format-converter/preload/job-manager.cjs +++ b/plugins/format-converter/preload/job-manager.cjs @@ -83,6 +83,7 @@ function createJobManager(options) { } job.completedAt = now(); job.running = false; + try { await options.onJobSettled?.(snapshot(job)); } catch {} } function start(request, plan) { diff --git a/plugins/format-converter/preload/package.json b/plugins/format-converter/preload/package.json index 0484405d5..bc073627e 100644 --- a/plugins/format-converter/preload/package.json +++ b/plugins/format-converter/preload/package.json @@ -1,6 +1,6 @@ { "name": "format-converter-preload-runtime", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "commonjs", "license": "MIT", diff --git a/plugins/format-converter/preload/screen-capture.cjs b/plugins/format-converter/preload/screen-capture.cjs new file mode 100644 index 000000000..79ae657a9 --- /dev/null +++ b/plugins/format-converter/preload/screen-capture.cjs @@ -0,0 +1,19 @@ +"use strict"; + +function requestScreenCapture(ztools) { + if (typeof ztools?.screenCapture !== "function") { + return Promise.reject(Object.assign(new Error("请升级到 ZTools 3.2.0 以使用截图导入。"), { code: "SCREEN_CAPTURE_UNAVAILABLE" })); + } + return new Promise((resolve, reject) => { + try { + // ZTools 3.2 uses the second argument to enter the editable capture flow. + // Older hosts ignore the extra JavaScript argument. + const request = ztools.screenCapture((image, bounds) => resolve({ image, bounds }), false); + if (request && typeof request.then === "function") Promise.resolve(request).catch(reject); + } catch (error) { + reject(error); + } + }); +} + +module.exports = { requestScreenCapture }; diff --git a/plugins/format-converter/preload/services.cjs b/plugins/format-converter/preload/services.cjs index 32cf40cc6..d7bf0245c 100644 --- a/plugins/format-converter/preload/services.cjs +++ b/plugins/format-converter/preload/services.cjs @@ -2,6 +2,7 @@ const path = require("node:path"); const os = require("node:os"); +const fs = require("node:fs/promises"); const { FORMAT_DEFINITIONS, TARGET_IDS, buildAllRoutes } = require("./format-registry.cjs"); const { probeRuntimes } = require("./runtime-probe.cjs"); const { createPathPolicy, isWithin } = require("./path-policy.cjs"); @@ -9,6 +10,9 @@ const { createConversionEngine } = require("./conversion-engine.cjs"); const { createJobManager } = require("./job-manager.cjs"); const { createOfficeCliInstaller } = require("./officecli-installer.cjs"); const { createRuntimeInstaller } = require("./runtime-installer.cjs"); +const { getHostCompatibility, runtimeRoot } = require("./host-compatibility.cjs"); +const { requestScreenCapture } = require("./screen-capture.cjs"); +const { DEFAULT_MAX_CAPTURE_BYTES, createCaptureTempStore } = require("./capture-temp-store.cjs"); const TOOL_NAMES = Object.freeze({ plan: "format_conversion_plan", execute: "format_conversion_execute", job: "format_conversion_job" }); const registeredHosts = new WeakMap(); @@ -27,6 +31,42 @@ function dialogPaths(result) { return []; } +async function captureToTemporaryFile(ztools, captureStore) { + const result = await requestScreenCapture(ztools); + if (!result?.image || typeof result.image !== "string") throw Object.assign(new Error("未获取到截图。"), { code: "SCREEN_CAPTURE_CANCELLED" }); + const output = await captureStore.createFromDataUrl(result.image); + return { ...output, bounds: result.bounds }; +} + +async function canonicalApprovedOutput(filePath, approvedRoots) { + if (typeof filePath !== "string" || !path.isAbsolute(filePath)) { + throw Object.assign(new Error("拖出的输出路径无效。"), { code: "INVALID_OUTPUT_PATH" }); + } + if (!approvedRoots.some(root => isWithin(root, filePath))) { + throw Object.assign(new Error("只能拖出已授权输出目录中的文件。"), { code: "PATH_NOT_APPROVED" }); + } + let stat; + try { + stat = await fs.lstat(filePath); + } catch { + throw Object.assign(new Error("拖出的输出文件不存在。"), { code: "INVALID_OUTPUT_PATH" }); + } + if (stat.isSymbolicLink()) { + throw Object.assign(new Error("不能拖出符号链接。"), { code: "OUTPUT_SYMLINK_NOT_ALLOWED" }); + } + if (!stat.isFile()) { + throw Object.assign(new Error("只能拖出常规文件。"), { code: "INVALID_OUTPUT_PATH" }); + } + const canonicalPath = await fs.realpath(filePath); + const canonicalRoots = (await Promise.all(approvedRoots.map(async root => { + try { return await fs.realpath(root); } catch { return null; } + }))).filter(Boolean); + if (!canonicalRoots.some(root => isWithin(root, canonicalPath))) { + throw Object.assign(new Error("文件真实路径不在已授权输出目录内。"), { code: "PATH_NOT_APPROVED" }); + } + return canonicalPath; +} + function validateObject(input, allowed, label) { if (!input || typeof input !== "object" || Array.isArray(input)) throw Object.assign(new Error(`${label}参数必须是对象。`), { code: "INVALID_TOOL_INPUT" }); if (Object.keys(input).some(key => !allowed.has(key))) throw Object.assign(new Error(`${label}包含未允许字段。`), { code: "INVALID_TOOL_INPUT" }); @@ -66,15 +106,27 @@ function createFormatConverterServices(target, dependencies = {}) { const ztools = target?.ztools || {}; const storage = dependencies.storage || ztools.dbStorage; const pathPolicy = dependencies.pathPolicy || createPathPolicy({ storage }); + const captureLimit = Number.isSafeInteger(pathPolicy.limits?.maxFileBytes) + ? Math.min(pathPolicy.limits.maxFileBytes, DEFAULT_MAX_CAPTURE_BYTES) + : DEFAULT_MAX_CAPTURE_BYTES; + const captureStore = dependencies.captureStore || createCaptureTempStore({ maxBytes: captureLimit }); const installer = dependencies.installer || createOfficeCliInstaller(); - const runtimeRoot = dependencies.runtimeRoot || (() => { + const legacyRuntimeRoot = (() => { try { if (typeof ztools.getPath === "function") return path.join(ztools.getPath("userData"), "format-converter", "runtime", "v1"); } catch {} return path.join(os.homedir(), ".ztools", "format-converter", "runtime", "v1"); })(); - const runtimeInstaller = dependencies.runtimeInstaller || createRuntimeInstaller({ runtimeRoot }); + const runtimeLocation = dependencies.runtimeRoot ? { root: dependencies.runtimeRoot, modern: false } : runtimeRoot(ztools, legacyRuntimeRoot); + const runtimeInstaller = dependencies.runtimeInstaller || createRuntimeInstaller({ runtimeRoot: runtimeLocation.root }); let runtimes = dependencies.runtimes || []; const engine = dependencies.engine || createConversionEngine({ pathPolicy, runtimes }); - const jobs = dependencies.jobs || createJobManager({ conversionEngine: engine, pathPolicy, concurrency: 2 }); + const jobs = dependencies.jobs || createJobManager({ + conversionEngine: engine, + pathPolicy, + concurrency: 2, + onJobSettled(job) { captureStore.settleJob(job.request?.inputGrantId, job); } + }); + + if (typeof ztools.onPluginOut === "function") ztools.onPluginOut(() => captureStore.cleanupInactiveSync()); async function refreshRuntimes() { runtimes = await (dependencies.probeRuntimes || probeRuntimes)(); @@ -104,6 +156,20 @@ function createFormatConverterServices(target, dependencies = {}) { return paths.length ? pathPolicy.createInputGrant(paths, "ui") : null; }, "INPUT_SELECTION_FAILED"); }, + captureScreen() { + return safe(async () => { + const capture = await captureToTemporaryFile(ztools, captureStore); + try { + const grant = await pathPolicy.createInputGrant([capture.path], "ui"); + captureStore.associateGrant(grant.id, capture.path, grant.expiresAt); + return grant; + } catch (error) { + captureStore.discardFile(capture.path); + throw error; + } + }, "SCREEN_CAPTURE_FAILED"); + }, + canCaptureScreen() { return typeof ztools.screenCapture === "function"; }, acceptInputs(paths) { return safe(() => pathPolicy.createInputGrant(paths, "ui"), "INPUT_VALIDATION_FAILED"); }, selectOutputDirectory() { return safe(async () => { @@ -116,12 +182,25 @@ function createFormatConverterServices(target, dependencies = {}) { getApprovedRoots() { return safe(() => pathPolicy.approvedRoots()); }, removeApprovedRoot(root) { return safe(() => pathPolicy.removeApprovedRoot(root)); }, planConversion(request) { return safe(async () => { await ensureRuntimes(); return engine.plan(request); }, "CONVERSION_PLAN_FAILED"); }, - startConversion(request) { return safe(async () => { await ensureRuntimes(); const plan = engine.plan(request); if (!plan.executable) throw Object.assign(new Error("转换路线缺少所需引擎。"), { code: "ENGINE_UNAVAILABLE", details: plan.warnings }); return jobs.start(request, plan); }, "CONVERSION_START_FAILED"); }, + startConversion(request) { return safe(async () => { await ensureRuntimes(); captureStore.assertConsumable(request?.inputGrantId); const plan = engine.plan(request); if (!plan.executable) throw Object.assign(new Error("转换路线缺少所需引擎。"), { code: "ENGINE_UNAVAILABLE", details: plan.warnings }); const job = jobs.start(request, plan); captureStore.markConsumed(request.inputGrantId, job.id); return job; }, "CONVERSION_START_FAILED"); }, getJob(jobId) { return safe(() => jobs.get(jobId), "JOB_QUERY_FAILED"); }, cancelJob(jobId) { return safe(() => jobs.cancel(jobId), "JOB_CANCEL_FAILED"); }, - retryFailed(jobId) { return safe(() => jobs.retryFailed(jobId), "JOB_RETRY_FAILED"); }, + retryFailed(jobId) { return safe(() => { const job = jobs.retryFailed(jobId); captureStore.markJobActive(job.id); return job; }, "JOB_RETRY_FAILED"); }, installRuntime(runtimeId) { return safe(async () => { await runtimeInstaller.install(runtimeId); return refreshRuntimes(); }, "RUNTIME_INSTALL_FAILED"); }, installOfficeCli() { return safe(async () => { await installer.install(); const next = await refreshRuntimes(); return next.find(item => item.id === "officecli"); }, "OFFICECLI_INSTALL_FAILED"); }, + hostCompatibility() { return getHostCompatibility(target?.ztools); }, + canStartDrag() { return typeof ztools.startDrag === "function"; }, + startDrag(paths) { + return safe(async () => { + const values = Array.isArray(paths) ? paths : [paths]; + if (!values.length || values.some(item => typeof item !== "string" || !path.isAbsolute(item))) throw Object.assign(new Error("拖出的输出路径无效。"), { code: "INVALID_OUTPUT_PATH" }); + const roots = await pathPolicy.approvedRoots(); + const canonicalPaths = await Promise.all(values.map(item => canonicalApprovedOutput(item, roots))); + if (typeof ztools.startDrag !== "function") throw Object.assign(new Error("请升级到 ZTools 3.2.0 以拖出文件。"), { code: "START_DRAG_UNAVAILABLE" }); + await Promise.resolve(ztools.startDrag(canonicalPaths.length === 1 ? canonicalPaths[0] : canonicalPaths)); + return true; + }, "START_DRAG_FAILED"); + }, revealPath(filePath) { return safe(async () => { if (typeof filePath !== "string" || !path.isAbsolute(filePath)) throw Object.assign(new Error("输出路径无效。"), { code: "INVALID_OUTPUT_PATH" }); @@ -155,7 +234,7 @@ function createFormatConverterServices(target, dependencies = {}) { } async function jobForMcp(input) { const value = validateJobToolInput(input); return value.action === "cancel" ? jobs.cancel(value.jobId) : jobs.get(value.jobId); } - return { services, tools: { planForMcp, executeForMcp, jobForMcp }, internals: { pathPolicy, engine, jobs, runtimeInstaller } }; + return { services, tools: { planForMcp, executeForMcp, jobForMcp }, internals: { pathPolicy, engine, jobs, runtimeInstaller, captureStore } }; } function throwToolFailure(result) { if (result?.ok) return result.data; const error = new Error(result?.error?.message || "格式转换失败。"); error.code = result?.error?.code || "FORMAT_CONVERTER_ERROR"; error.details = result?.error?.details; throw error; } @@ -173,6 +252,12 @@ function registerTools(target, bundle) { function attachFormatConverter(target, dependencies) { if (!target || (typeof target !== "object" && typeof target !== "function")) throw new Error("需要 window-like 目标挂载格式转换服务。"); + const compatibility = getHostCompatibility(target.ztools); + if (!compatibility.supported) { + const services = Object.freeze({ hostCompatibility: () => compatibility }); + target.formatConverter = services; + return { services, tools: Object.freeze({}), internals: Object.freeze({}) }; + } const bundle = createFormatConverterServices(target, dependencies); target.formatConverter = bundle.services; registerTools(target, bundle); @@ -182,4 +267,4 @@ function attachFormatConverter(target, dependencies) { let defaultBundle = null; if (typeof window !== "undefined") defaultBundle = attachFormatConverter(window); -module.exports = { TOOL_NAMES, envelope, failure, safe, dialogPaths, validatePlanToolInput, validateExecuteToolInput, validateJobToolInput, createFormatConverterServices, registerTools, attachFormatConverter, defaultBundle }; +module.exports = { TOOL_NAMES, envelope, failure, safe, dialogPaths, captureToTemporaryFile, canonicalApprovedOutput, validatePlanToolInput, validateExecuteToolInput, validateJobToolInput, createFormatConverterServices, registerTools, attachFormatConverter, defaultBundle }; diff --git a/plugins/format-converter/scripts/verify-dist.mjs b/plugins/format-converter/scripts/verify-dist.mjs index 21e3161d0..a92aa8b73 100644 --- a/plugins/format-converter/scripts/verify-dist.mjs +++ b/plugins/format-converter/scripts/verify-dist.mjs @@ -16,7 +16,7 @@ async function requireFile(relativePath) { const required = [ "index.html", "plugin.json", "logo.svg", "README.md", "CHANGELOG.md", "LICENSE", "THIRD_PARTY_NOTICES.md", - "preload/services.cjs", "preload/format-registry.cjs", "preload/path-policy.cjs", "preload/runtime-probe.cjs", + "preload/services.cjs", "preload/capture-temp-store.cjs", "preload/format-registry.cjs", "preload/path-policy.cjs", "preload/runtime-probe.cjs", "preload/conversion-engine.cjs", "preload/job-manager.cjs", "preload/engine-utils.cjs", "preload/text-converter.cjs", "preload/image-converter.cjs", "preload/pdf-converter.cjs", "preload/office-converter.cjs", "preload/officecli-installer.cjs", "preload/runtime-loader.cjs", "preload/runtime-installer.cjs", "preload/runtime-manifest.json", diff --git a/plugins/format-converter/src/App.tsx b/plugins/format-converter/src/App.tsx index 41ebb582c..5d2ad0315 100644 --- a/plugins/format-converter/src/App.tsx +++ b/plugins/format-converter/src/App.tsx @@ -18,6 +18,7 @@ import { LoaderCircle, Play, RefreshCw, + ScanLine, RotateCcw, Route, Settings2, @@ -72,7 +73,23 @@ function statusIcon(status: string) { return ; } +function rendererHostIsSupported() { + if (!window.ztools) return true; + try { + return window.formatConverter?.hostCompatibility?.().supported === true; + } catch { + return false; + } +} + export default function App() { + if (!rendererHostIsSupported()) { + return
当前 ZTools 版本过低或无法识别(最低支持 2.4.0)。为了获得更完整、稳定的体验,请升级后再使用格式转换。
; + } + return ; +} + +function FormatConverterApp() { const api = window.formatConverter; const [capabilities, setCapabilities] = useState(null); const [inputGrant, setInputGrant] = useState(null); @@ -166,6 +183,21 @@ export default function App() { } finally { setBusy(null); } }; + const captureScreen = async () => { + if (!api) return; + setBusy("inputs"); setError(""); + try { setInputGrant(unwrap(await api.captureScreen())); setJob(null); } + catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); } + finally { setBusy(null); } + }; + + const dragOutput = async (event: React.DragEvent, outputs: string[]) => { + event.preventDefault(); + if (!api) return; + try { unwrap(await api.startDrag(outputs)); } + catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); } + }; + const selectOutput = async () => { if (!api) return; setBusy("output"); setError(""); @@ -289,6 +321,7 @@ export default function App() { {inputGrant ? "继续添加或重新选择" : "拖入文件,或点击选择"} Office、PDF、图片、文本与数据文件 · 最多 200 个 + {api?.canCaptureScreen() && }
{inputGrant?.files.map(file => { const definition = formatDefinition(file.format); @@ -364,7 +397,7 @@ export default function App() {
{progressLabel}{["queued", "running"].includes(job.status) && }{["partial", "failed"].includes(job.status) && }
-
{job.items.map(item =>
{statusIcon(item.status)}
{item.input.name}{item.route.description}
{item.progress}%{item.outputs.length > 0 && }{item.error && {item.error.message}}
)}
+
{job.items.map(item =>
{statusIcon(item.status)}
{item.input.name}{item.route.description}
{item.progress}%{item.outputs.length > 0 && }{item.error && {item.error.message}}
)}
}
diff --git a/plugins/format-converter/src/types.ts b/plugins/format-converter/src/types.ts index 316eaf753..ed667c234 100644 --- a/plugins/format-converter/src/types.ts +++ b/plugins/format-converter/src/types.ts @@ -141,6 +141,8 @@ export interface FormatConverterApi { getCapabilities(): Promise>; refreshRuntimes(): Promise>; selectInputs(): Promise>; + captureScreen(): Promise>; + canCaptureScreen(): boolean; acceptInputs(paths: string[]): Promise>; selectOutputDirectory(): Promise>; getApprovedRoots(): Promise>; @@ -153,6 +155,9 @@ export interface FormatConverterApi { installRuntime(runtimeId: string): Promise>; installOfficeCli(): Promise>; revealPath(path: string): Promise>; + hostCompatibility(): { version: string; supported: boolean }; + canStartDrag(): boolean; + startDrag(paths: string[]): Promise>; } export interface ZToolsLaunchParam { @@ -166,6 +171,8 @@ export interface ZToolsApi { onPluginOut?(callback: () => void): void; getPathForFile?(file: File): string; getPath?(name: string): string; + getAppVersion?(): string; + screenCapture?(callback: (image: string, bounds?: unknown) => void, autoConfirm?: boolean): unknown; } declare global { diff --git a/plugins/format-converter/tests/preload/capture-temp-store.test.cjs b/plugins/format-converter/tests/preload/capture-temp-store.test.cjs new file mode 100644 index 000000000..38f84308e --- /dev/null +++ b/plugins/format-converter/tests/preload/capture-temp-store.test.cjs @@ -0,0 +1,186 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const { createCaptureTempStore } = require("../../preload/capture-temp-store.cjs"); + +function imageData(bytes = Buffer.from("capture")) { + return `data:image/png;base64,${bytes.toString("base64")}`; +} + +test("creates an unpredictable private root and a private regular file", async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "format-capture-secure-")); + const store = createCaptureTempStore({ tempDirectory: parent }); + try { + const capture = await store.createFromDataUrl(imageData()); + const root = path.dirname(capture.path); + assert.match(path.basename(root), new RegExp(`^ztools-format-converter-${process.pid}-.{6}$`)); + assert.equal(fs.realpathSync(root), root); + assert.equal(fs.realpathSync(capture.path), capture.path); + assert.equal(fs.lstatSync(capture.path).isFile(), true); + assert.equal(fs.statSync(root).mode & 0o777, 0o700); + assert.equal(fs.statSync(capture.path).mode & 0o777, 0o600); + } finally { + store.cleanupAllSync(); + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test("rejects oversized data before creating a temporary root", async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "format-capture-limit-")); + const store = createCaptureTempStore({ tempDirectory: parent, maxBytes: 4 }); + try { + await assert.rejects(store.createFromDataUrl(imageData(Buffer.alloc(5, 1))), error => error.code === "SCREEN_CAPTURE_TOO_LARGE"); + assert.deepEqual(fs.readdirSync(parent), []); + } finally { + store.cleanupAllSync(); + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test("does not follow a preoccupied symlink file", async t => { + if (process.platform === "win32") { t.skip("symlink creation requires elevated privileges on Windows"); return; } + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "format-capture-conflict-")); + const outside = path.join(parent, "outside.txt"); + const store = createCaptureTempStore({ tempDirectory: parent, randomId: () => "fixed" }); + try { + fs.writeFileSync(outside, "outside"); + const first = await store.createFromDataUrl(imageData()); + fs.unlinkSync(first.path); + fs.symlinkSync(outside, first.path); + await assert.rejects(store.createFromDataUrl(imageData(Buffer.from("replacement"))), error => error.code === "CAPTURE_TEMP_CONFLICT"); + assert.equal(fs.readFileSync(outside, "utf8"), "outside"); + } finally { + store.cleanupAllSync(); + assert.equal(fs.readFileSync(outside, "utf8"), "outside"); + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test("rejects a replaced root and cleanup never follows the replacement symlink", async t => { + if (process.platform === "win32") { t.skip("symlink creation requires elevated privileges on Windows"); return; } + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "format-capture-root-link-")); + const outside = path.join(parent, "outside"); + const store = createCaptureTempStore({ tempDirectory: parent }); + let moved; + try { + const first = await store.createFromDataUrl(imageData()); + const root = path.dirname(first.path); + moved = `${root}-moved`; + fs.mkdirSync(outside); + fs.renameSync(root, moved); + fs.writeFileSync(path.join(outside, path.basename(first.path)), "outside"); + fs.symlinkSync(outside, root, "dir"); + await assert.rejects(store.createFromDataUrl(imageData()), error => error.code === "CAPTURE_TEMP_BOUNDARY"); + store.cleanupAllSync(); + assert.equal(fs.existsSync(root), false); + assert.equal(fs.readFileSync(path.join(outside, path.basename(first.path)), "utf8"), "outside"); + } finally { + store.cleanupAllSync(); + if (moved) fs.rmSync(moved, { recursive: true, force: true }); + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test("retains retryable captures, then removes consumed and expired grants", async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "format-capture-grants-")); + const store = createCaptureTempStore({ tempDirectory: parent }); + try { + const consumed = await store.createFromDataUrl(imageData()); + store.associateGrant("capture-1", consumed.path, Date.now() + 60_000); + store.markConsumed("capture-1", "job-1"); + assert.throws(() => store.assertConsumable("capture-1"), error => error.code === "SCREEN_CAPTURE_GRANT_CONSUMED"); + assert.equal(store.settleJob("capture-1", { id: "job-1", items: [{ status: "failed" }] }), false); + assert.equal(fs.existsSync(consumed.path), true); + assert.equal(store.settleJob("capture-1", { id: "job-1", items: [{ status: "succeeded" }] }), true); + assert.equal(fs.existsSync(consumed.path), false); + + const expired = await store.createFromDataUrl(imageData(Buffer.from("expired"))); + store.associateGrant("capture-2", expired.path, Date.now() + 60_000); + store.pruneExpired(Date.now() + 60_001); + assert.equal(fs.existsSync(expired.path), false); + assert.deepEqual(fs.readdirSync(parent), []); + } finally { + store.cleanupAllSync(); + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test("defers expiry while a conversion is active, then cleans after its settled result", async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "format-capture-active-expiry-")); + let clock = 1_000; + const store = createCaptureTempStore({ tempDirectory: parent, now: () => clock }); + try { + const capture = await store.createFromDataUrl(imageData()); + store.associateGrant("capture-active", capture.path, 1_100); + store.markConsumed("capture-active", "job-active"); + clock = 1_101; + store.pruneExpired(); + assert.equal(fs.existsSync(capture.path), true); + assert.equal(store.settleJob("capture-active", { id: "job-active", items: [{ status: "failed" }] }), true); + assert.equal(fs.existsSync(capture.path), false); + assert.deepEqual(fs.readdirSync(parent), []); + } finally { + store.cleanupAllSync(); + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test("plugin lifecycle cleanup removes inactive captures but preserves active work until settle", async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "format-capture-lifecycle-")); + const store = createCaptureTempStore({ tempDirectory: parent }); + try { + const inactive = await store.createFromDataUrl(imageData(Buffer.from("inactive"))); + store.associateGrant("capture-inactive", inactive.path, Date.now() + 60_000); + const active = await store.createFromDataUrl(imageData(Buffer.from("active"))); + store.associateGrant("capture-active", active.path, Date.now() + 60_000); + store.markConsumed("capture-active", "job-active"); + + store.cleanupInactiveSync(); + assert.equal(fs.existsSync(inactive.path), false); + assert.equal(fs.existsSync(active.path), true); + + assert.equal(store.settleJob("capture-active", { id: "job-active", items: [{ status: "succeeded" }] }), true); + assert.equal(fs.existsSync(active.path), false); + assert.deepEqual(fs.readdirSync(parent), []); + } finally { + store.cleanupAllSync(); + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test("plugin lifecycle cleanup preserves a failed capture while its retry grant is valid", async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "format-capture-retry-lifecycle-")); + const store = createCaptureTempStore({ tempDirectory: parent }); + try { + const capture = await store.createFromDataUrl(imageData(Buffer.from("retryable"))); + store.associateGrant("capture-retry", capture.path, Date.now() + 60_000); + store.markConsumed("capture-retry", "job-retry"); + assert.equal(store.settleJob("capture-retry", { id: "job-retry", items: [{ status: "failed" }] }), false); + + store.cleanupInactiveSync(); + assert.equal(fs.existsSync(capture.path), true); + + store.markJobActive("job-retry"); + assert.equal(store.settleJob("capture-retry", { id: "job-retry", items: [{ status: "succeeded" }] }), true); + assert.equal(fs.existsSync(capture.path), false); + assert.deepEqual(fs.readdirSync(parent), []); + } finally { + store.cleanupAllSync(); + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test("process exit hook removes tracked capture files and their root", () => { + const modulePath = path.resolve(__dirname, "../../preload/capture-temp-store.cjs"); + const script = `const { createCaptureTempStore } = require(${JSON.stringify(modulePath)}); (async () => { const store = createCaptureTempStore(); const item = await store.createFromDataUrl('data:image/png;base64,Y2FwdHVyZQ=='); store.associateGrant('active', item.path, Date.now() + 60_000); store.markConsumed('active', 'job'); console.log(require('node:path').dirname(item.path)); })().catch(error => { console.error(error); process.exitCode = 1; });`; + const result = spawnSync(process.execPath, ["-e", script], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + const root = result.stdout.trim(); + assert.ok(root); + assert.equal(fs.existsSync(root), false); +}); diff --git a/plugins/format-converter/tests/preload/host-compatibility.test.cjs b/plugins/format-converter/tests/preload/host-compatibility.test.cjs new file mode 100644 index 000000000..aecffc3d3 --- /dev/null +++ b/plugins/format-converter/tests/preload/host-compatibility.test.cjs @@ -0,0 +1,57 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { compareVersions, copyDirectory, directoriesMatch, getHostCompatibility, runtimeRoot } = require("../../preload/host-compatibility.cjs"); + +test("host compatibility only bypasses an explicit browser preview", () => { + assert.equal(compareVersions("3.2.0", "3.1.9"), 1); + assert.equal(compareVersions("2.4", "2.4.0"), 0); + assert.equal(compareVersions("2.4.0-beta.1", "2.4.0"), -1); + assert.equal(compareVersions("unknown", "2.4.0"), null); + assert.equal(getHostCompatibility(undefined).supported, true); + assert.equal(getHostCompatibility({}).supported, false); + const throwingGetter = {}; + Object.defineProperty(throwingGetter, "getAppVersion", { get() { throw new Error("unavailable"); } }); + assert.equal(getHostCompatibility(throwingGetter).supported, false); + assert.equal(getHostCompatibility({ getAppVersion: () => { throw new Error("unavailable"); } }).supported, false); + for (const version of ["", "unknown", 320]) { + assert.equal(getHostCompatibility({ getAppVersion: () => version }).supported, false); + } + assert.equal(getHostCompatibility({ getAppVersion: () => "2.3.9" }).supported, false); + assert.equal(getHostCompatibility({ getAppVersion: () => "2.4.0-beta.1" }).supported, false); + assert.equal(getHostCompatibility({ getAppVersion: () => "2.4.0" }).supported, true); + assert.equal(getHostCompatibility({ getAppVersion: () => "3.1.9" }).supported, true); +}); + +test("runtime migration copy verifies its complete directory before writing a marker", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "format-host-compat-")); + try { + const source = path.join(root, "old"); + const destination = path.join(root, "new"); + fs.mkdirSync(path.join(source, "nested"), { recursive: true }); + fs.writeFileSync(path.join(source, "nested", "runtime.bin"), "runtime"); + assert.equal(copyDirectory(source, destination), true); + assert.equal(directoriesMatch(source, destination), true); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test("runtime migration keeps using legacy data when an unverified destination already exists", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "format-host-compat-partial-")); + try { + const pluginData = path.join(base, "plugin-data"); + const legacy = path.join(base, "legacy"); + const destination = path.join(pluginData, "runtime", "v1"); + fs.mkdirSync(legacy, { recursive: true }); + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync(path.join(legacy, "runtime.bin"), "complete"); + fs.writeFileSync(path.join(destination, "runtime.bin"), "partial"); + + const resolved = runtimeRoot({ getPath: () => pluginData }, legacy); + assert.equal(resolved.root, legacy); + assert.equal(resolved.modern, false); + assert.equal(fs.existsSync(path.join(pluginData, ".format-converter-runtime-migration-v1.json")), false); + } finally { fs.rmSync(base, { recursive: true, force: true }); } +}); diff --git a/plugins/format-converter/tests/preload/job-manager.test.cjs b/plugins/format-converter/tests/preload/job-manager.test.cjs index 90035b85a..4522fe291 100644 --- a/plugins/format-converter/tests/preload/job-manager.test.cjs +++ b/plugins/format-converter/tests/preload/job-manager.test.cjs @@ -68,3 +68,19 @@ test("expires completed jobs and enforces a bounded job store", async () => { assert.throws(() => activeManager.start(request, plan), error => error.code === "JOB_CAPACITY_REACHED"); for (const job of activeManager._jobs.values()) activeManager.cancel(job.id); }); + +test("notifies lifecycle cleanup after the job has fully settled", async () => { + let settled; + const manager = createJobManager({ + pathPolicy: { requireOutputGrant: () => ({ directory: "/authorized" }) }, + conversionEngine: { async convertItem() { return { outputs: ["/authorized/ok.txt"], warnings: [] }; } }, + onJobSettled(job) { settled = job; } + }); + const request = { inputGrantId: "capture-grant", outputGrantId: "output", target: "txt", profile: "extract", collision: "rename", options: {} }; + const plan = { items: [{ input: { name: "a.txt", path: "/a.txt", format: "txt", family: "text", size: 1 }, route: { description: "test" } }] }; + const started = manager.start(request, plan); + await waitFor(() => settled?.id === started.id); + assert.equal(settled.status, "succeeded"); + assert.equal(settled.running, false); + assert.equal(settled.request.inputGrantId, "capture-grant"); +}); diff --git a/plugins/format-converter/tests/preload/screen-capture.test.cjs b/plugins/format-converter/tests/preload/screen-capture.test.cjs new file mode 100644 index 000000000..ab0c0a79a --- /dev/null +++ b/plugins/format-converter/tests/preload/screen-capture.test.cjs @@ -0,0 +1,28 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { requestScreenCapture } = require("../../preload/screen-capture.cjs"); + +test("screen capture keeps the ZTools 3.2 image and bounds callback", async () => { + let receivedAutoConfirm; + const result = await requestScreenCapture({ + screenCapture(callback, autoConfirm) { + receivedAutoConfirm = autoConfirm; + callback("data:image/png;base64,aGVsbG8=", { x: 1, y: 2, width: 3, height: 4 }); + return Promise.resolve(); + }, + }); + assert.equal(receivedAutoConfirm, false); + assert.deepEqual(result, { + image: "data:image/png;base64,aGVsbG8=", + bounds: { x: 1, y: 2, width: 3, height: 4 }, + }); +}); + +test("screen capture propagates asynchronous host rejection", async () => { + await assert.rejects( + requestScreenCapture({ screenCapture() { return Promise.reject(new Error("capture ipc failed")); } }), + /capture ipc failed/, + ); +}); diff --git a/plugins/format-converter/tests/preload/services.test.cjs b/plugins/format-converter/tests/preload/services.test.cjs index dfc5e4eda..5ea0d5201 100644 --- a/plugins/format-converter/tests/preload/services.test.cjs +++ b/plugins/format-converter/tests/preload/services.test.cjs @@ -2,6 +2,8 @@ const test = require("node:test"); const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); const path = require("node:path"); const { TOOL_NAMES, @@ -35,6 +37,7 @@ function fixture(overrides = {}) { ...overrides.jobs }; const ztools = { + getAppVersion: () => "3.2.0", dbStorage: { getItem: () => [], setItem() {} }, registerTool(name, handler) { calls.tools.set(name, handler); }, ...overrides.ztools @@ -54,14 +57,66 @@ function fixture(overrides = {}) { test("exposes only the narrow renderer bridge and registers the exact MCP tools", () => { const { target, calls } = fixture(); assert.deepEqual(Object.keys(target.formatConverter).sort(), [ - "acceptInputs", "cancelJob", "getApprovedRoots", "getCapabilities", "getJob", + "acceptInputs", "canCaptureScreen", "canStartDrag", "cancelJob", "captureScreen", "getApprovedRoots", "getCapabilities", "getJob", "hostCompatibility", "installOfficeCli", "installRuntime", "planConversion", "refreshRuntimes", "removeApprovedRoot", - "retryFailed", "revealPath", "selectInputs", "selectOutputDirectory", "startConversion" + "retryFailed", "revealPath", "selectInputs", "selectOutputDirectory", "startConversion", "startDrag" ]); assert.deepEqual([...calls.tools.keys()].sort(), Object.values(TOOL_NAMES).sort()); assert.equal(Object.isFrozen(target.formatConverter), true); }); +test("fails closed before constructing services or registering tools when the host version is unavailable", () => { + let registered = 0; + const target = { ztools: { registerTool() { registered += 1; } } }; + const bundle = attachFormatConverter(target, { + get pathPolicy() { throw new Error("business initialization must not run"); } + }); + assert.deepEqual(Object.keys(target.formatConverter), ["hostCompatibility"]); + assert.equal(target.formatConverter.hostCompatibility().supported, false); + assert.equal(Object.keys(bundle.tools).length, 0); + assert.equal(registered, 0); +}); + +test("startDrag rejects symlink escapes and passes only canonical regular files", async () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "format-drag-boundary-")); + try { + const approved = path.join(base, "approved"); + const outside = path.join(base, "outside"); + fs.mkdirSync(approved); + fs.mkdirSync(outside); + const regular = path.join(approved, "result.pdf"); + const outsideFile = path.join(outside, "secret.pdf"); + fs.writeFileSync(regular, "result"); + fs.writeFileSync(outsideFile, "secret"); + const directorySymlink = path.join(approved, "linked-directory"); + const directSymlink = process.platform === "win32" ? null : path.join(approved, "direct-link.pdf"); + if (directSymlink) fs.symlinkSync(outsideFile, directSymlink); + fs.symlinkSync(outside, directorySymlink, process.platform === "win32" ? "junction" : "dir"); + + const dragged = []; + const { target } = fixture({ + pathPolicy: { approvedRoots: async () => [approved] }, + ztools: { startDrag(value) { dragged.push(value); } } + }); + + if (directSymlink) { + const directResult = await target.formatConverter.startDrag(directSymlink); + assert.equal(directResult.ok, false); + assert.equal(directResult.error.code, "OUTPUT_SYMLINK_NOT_ALLOWED"); + } + + const escapedResult = await target.formatConverter.startDrag(path.join(directorySymlink, "secret.pdf")); + assert.equal(escapedResult.ok, false); + assert.equal(escapedResult.error.code, "PATH_NOT_APPROVED"); + + const regularResult = await target.formatConverter.startDrag(regular); + assert.equal(regularResult.ok, true); + assert.deepEqual(dragged, [fs.realpathSync(regular)]); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } +}); + test("file and directory dialogs create grants instead of returning raw filesystem access", async () => { let dialogCall = 0; const input = path.resolve("/approved/input.txt"); @@ -84,6 +139,89 @@ test("file and directory dialogs create grants instead of returning raw filesyst assert.deepEqual(calls.outputs, [output]); }); +test("screen capture cleans its private file on grant failure and plugin out", async () => { + let pluginOut; + let rejectedPath; + const first = fixture({ + pathPolicy: { + async createInputGrant(paths) { + rejectedPath = paths[0]; + throw Object.assign(new Error("invalid image"), { code: "MAGIC_MISMATCH" }); + } + }, + ztools: { + screenCapture(callback) { callback("data:image/png;base64,Y2FwdHVyZQ=="); }, + onPluginOut(callback) { pluginOut = callback; } + } + }); + const rejected = await first.target.formatConverter.captureScreen(); + assert.equal(rejected.ok, false); + assert.equal(rejected.error.code, "MAGIC_MISMATCH"); + assert.equal(fs.existsSync(rejectedPath), false); + first.bundle.internals.captureStore.cleanupAllSync(); + + const second = fixture({ + ztools: { + screenCapture(callback) { callback("data:image/png;base64,Y2FwdHVyZQ=="); }, + onPluginOut(callback) { pluginOut = callback; } + } + }); + const captured = await second.target.formatConverter.captureScreen(); + assert.equal(captured.ok, true); + const capturedPath = second.calls.inputs.at(-1)[0]; + const captureRoot = path.dirname(capturedPath); + assert.equal(fs.existsSync(capturedPath), true); + pluginOut(); + assert.equal(fs.existsSync(capturedPath), false); + assert.equal(fs.existsSync(captureRoot), false); +}); + +test("plugin out preserves an active screenshot until its job settles", async () => { + let pluginOut; + const current = fixture({ + ztools: { + screenCapture(callback) { callback("data:image/png;base64,Y2FwdHVyZQ=="); }, + onPluginOut(callback) { pluginOut = callback; } + } + }); + const captured = await current.target.formatConverter.captureScreen(); + assert.equal(captured.ok, true); + const capturedPath = current.calls.inputs.at(-1)[0]; + const captureRoot = path.dirname(capturedPath); + const started = await current.target.formatConverter.startConversion({ inputGrantId: captured.data.id, outputGrantId: "output-grant", target: "png", profile: "visual", collision: "rename", options: {} }); + assert.equal(started.ok, true); + + pluginOut(); + assert.equal(fs.existsSync(capturedPath), true); + current.bundle.internals.captureStore.settleJob(captured.data.id, { id: started.data.id, items: [{ status: "succeeded" }] }); + assert.equal(fs.existsSync(capturedPath), false); + assert.equal(fs.existsSync(captureRoot), false); +}); + +test("plugin out preserves a failed screenshot so retry can reuse it", async () => { + let pluginOut; + const current = fixture({ + ztools: { + screenCapture(callback) { callback("data:image/png;base64,Y2FwdHVyZQ=="); }, + onPluginOut(callback) { pluginOut = callback; } + } + }); + const captured = await current.target.formatConverter.captureScreen(); + assert.equal(captured.ok, true); + const capturedPath = current.calls.inputs.at(-1)[0]; + const started = await current.target.formatConverter.startConversion({ inputGrantId: captured.data.id, outputGrantId: "output-grant", target: "png", profile: "visual", collision: "rename", options: {} }); + assert.equal(started.ok, true); + assert.equal(current.bundle.internals.captureStore.settleJob(captured.data.id, { id: started.data.id, items: [{ status: "failed" }] }), false); + + pluginOut(); + assert.equal(fs.existsSync(capturedPath), true); + const retried = await current.target.formatConverter.retryFailed(started.data.id); + assert.equal(retried.ok, true); + assert.equal(fs.existsSync(capturedPath), true); + current.bundle.internals.captureStore.settleJob(captured.data.id, { id: started.data.id, items: [{ status: "succeeded" }] }); + assert.equal(fs.existsSync(capturedPath), false); +}); + test("execute tool accepts its own fields and rejects unknown nested options", () => { const valid = validateExecuteToolInput({ inputs: [path.resolve("/approved/input.txt")], From ffe9d077310cda2b14c35e997bb6f23563601fee Mon Sep 17 00:00:00 2001 From: wangzihao Date: Wed, 2 Sep 2026 14:38:44 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(format-converter):=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=B7=B2=E8=BF=81=E7=A7=BB=E7=9A=84=E6=97=A7=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用 SHA-256 校验 runtime 迁移结果,确认成功后删除 userData 旧目录并记录迁移状态。 AI-Co-Authored-By: Codex (GPT-5) --- plugins/format-converter/CHANGELOG.md | 2 +- plugins/format-converter/README.md | 4 +-- .../preload/host-compatibility.cjs | 30 ++++++++++++------- .../tests/preload/host-compatibility.test.cjs | 17 ++++++++++- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/plugins/format-converter/CHANGELOG.md b/plugins/format-converter/CHANGELOG.md index bf53f5c55..f7b4b4d07 100644 --- a/plugins/format-converter/CHANGELOG.md +++ b/plugins/format-converter/CHANGELOG.md @@ -3,7 +3,7 @@ ## 0.1.1 - 2026-09-01 - 适配 ZTools 3.2:截图导入、转换结果外拖,以及 runtime 使用 `pluginData` 专属目录。 -- runtime 迁移保留旧 `userData` 副本,ZTools 2.4–3.1 继续从原路径工作。 +- runtime 迁移在完整校验后删除旧 `userData` 副本;ZTools 2.4–3.1 直接安装仍从原路径工作。 - 新增 2.4.0 宿主版本门禁;低版本或真实宿主版本不可识别时在业务初始化前显示升级提示,仅浏览器开发预览放行。 - 输出外拖拒绝符号链接、非普通文件及真实路径逃逸授权目录的文件,并只向宿主传递规范化真实路径。 diff --git a/plugins/format-converter/README.md b/plugins/format-converter/README.md index 49d8c0139..440432ccf 100644 --- a/plugins/format-converter/README.md +++ b/plugins/format-converter/README.md @@ -5,7 +5,7 @@ ## ZTools 兼容性 - ZTools 3.2.0:支持截图导入、转换输出外拖,并将可重建 runtime 优先放入 `pluginData`。 -- ZTools 2.4–3.1:继续使用原 `userData` runtime、文件选择和打开目录流程。 +- ZTools 2.4–3.1:继续使用原 `userData` runtime、文件选择和打开目录流程。3.2 首次启动会校验迁移 runtime 并删除旧副本。 - runtime 迁移为复制后保留旧目录,不会在升级时删除旧数据,支持临时降级宿主。 - 低于 2.4.0,或真实 ZTools 宿主无法提供可比较版本号:显示升级提示。仅未注入 `window.ztools` 的浏览器开发预览放行。 @@ -29,7 +29,7 @@ - OCR:Tesseract.js,以及中英文模型;模型随 OCR 引擎从国内镜像一次安装,识别时不再访问外网。 - Excel 引擎:ExcelJS 与工作簿依赖。 -安装器不调用终端或依赖本机 npm。它读取仓库内由 `package-lock.json` 生成的固定清单,优先从 npmmirror 下载 tarball,失败后回退 npm 官方源;每个包通过 SRI/SHA-512 校验后才安全解压到 ZTools `userData/format-converter/runtime/v1`。压缩包中的绝对路径、路径穿越、符号链接和设备文件都会被拒绝,写入采用临时目录与原子替换。 +安装器不调用终端或依赖本机 npm。它读取仓库内由 `package-lock.json` 生成的固定清单,优先从 npmmirror 下载 tarball,失败后回退 npm 官方源;每个包通过 SRI/SHA-512 校验后才安全解压到 ZTools 3.2 的插件专属 `pluginData/runtime/v1`,旧宿主使用 `userData/format-converter/runtime/v1`。压缩包中的绝对路径、路径穿越、符号链接和设备文件都会被拒绝,写入采用临时目录与原子替换。 ## 开发 diff --git a/plugins/format-converter/preload/host-compatibility.cjs b/plugins/format-converter/preload/host-compatibility.cjs index de042e9db..2e3f7a13b 100644 --- a/plugins/format-converter/preload/host-compatibility.cjs +++ b/plugins/format-converter/preload/host-compatibility.cjs @@ -2,6 +2,7 @@ const fs = require("node:fs"); const path = require("node:path"); +const crypto = require("node:crypto"); const MINIMUM_VERSION = "2.4.0"; @@ -49,10 +50,15 @@ function copyDirectory(source, destination) { } function directoriesMatch(source, destination) { - const sourceStat = fs.statSync(source); - const destinationStat = fs.statSync(destination); + const sourceStat = fs.lstatSync(source); + const destinationStat = fs.lstatSync(destination); + if (sourceStat.isSymbolicLink() || destinationStat.isSymbolicLink()) return false; if (sourceStat.isDirectory() !== destinationStat.isDirectory()) return false; - if (!sourceStat.isDirectory()) return sourceStat.size === destinationStat.size; + if (!sourceStat.isDirectory()) { + return sourceStat.size === destinationStat.size + && crypto.createHash("sha256").update(fs.readFileSync(source)).digest("hex") + === crypto.createHash("sha256").update(fs.readFileSync(destination)).digest("hex"); + } const sourceNames = fs.readdirSync(source).sort(); const destinationNames = fs.readdirSync(destination).sort(); if (sourceNames.length !== destinationNames.length || sourceNames.some((name, index) => name !== destinationNames[index])) return false; @@ -64,23 +70,27 @@ function runtimeRoot(ztools, legacyRoot) { try { pluginData = typeof ztools?.getPath === "function" ? ztools.getPath("pluginData") : ""; } catch {} if (!pluginData) return { root: legacyRoot, migrated: false, modern: false }; const root = path.join(pluginData, "runtime", "v1"); - const marker = path.join(pluginData, ".format-converter-runtime-migration-v1.json"); + const marker = path.join(pluginData, ".format-converter-runtime-migration-v2.json"); let migrated = false; try { - if (fs.existsSync(marker)) { - if (fs.existsSync(root) || !fs.existsSync(legacyRoot)) return { root, migrated, modern: true }; - return { root: legacyRoot, migrated: false, modern: false }; - } if (fs.existsSync(legacyRoot)) { const verified = fs.existsSync(root) ? directoriesMatch(legacyRoot, root) : copyDirectory(legacyRoot, root); if (!verified) return { root: legacyRoot, migrated: false, modern: false }; - fs.writeFileSync(marker, JSON.stringify({ version: 1, source: legacyRoot, destination: root, copiedAt: new Date().toISOString() })); + fs.rmSync(legacyRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + if (fs.existsSync(legacyRoot)) return { root: legacyRoot, migrated: false, modern: false }; + try { fs.rmdirSync(path.dirname(legacyRoot)); } catch {} + try { fs.rmdirSync(path.dirname(path.dirname(legacyRoot))); } catch {} migrated = true; } + fs.mkdirSync(pluginData, { recursive: true }); + fs.writeFileSync(marker, JSON.stringify({ version: 2, destination: root, completedAt: new Date().toISOString() })); } catch { - // A failed migration must leave the old runtime usable; never remove it. + // Copy/verification failures retain the old runtime. If cleanup already + // completed, keep using the verified pluginData copy even if marker write failed. + if (fs.existsSync(legacyRoot)) return { root: legacyRoot, migrated: false, modern: false }; + if (fs.existsSync(root)) return { root, migrated: true, modern: true }; return { root: legacyRoot, migrated: false, modern: false }; } return { root, migrated, modern: true }; diff --git a/plugins/format-converter/tests/preload/host-compatibility.test.cjs b/plugins/format-converter/tests/preload/host-compatibility.test.cjs index aecffc3d3..176daf7fd 100644 --- a/plugins/format-converter/tests/preload/host-compatibility.test.cjs +++ b/plugins/format-converter/tests/preload/host-compatibility.test.cjs @@ -38,6 +38,21 @@ test("runtime migration copy verifies its complete directory before writing a ma } finally { fs.rmSync(root, { recursive: true, force: true }); } }); +test("runtime migration removes the verified userData copy", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "format-host-compat-move-")); + try { + const pluginData = path.join(base, "plugin-data"); + const legacy = path.join(base, "legacy"); + fs.mkdirSync(legacy, { recursive: true }); + fs.writeFileSync(path.join(legacy, "runtime.bin"), "runtime"); + const resolved = runtimeRoot({ getPath: () => pluginData }, legacy); + assert.equal(resolved.root, path.join(pluginData, "runtime", "v1")); + assert.equal(resolved.migrated, true); + assert.equal(fs.existsSync(legacy), false); + assert.equal(fs.existsSync(path.join(pluginData, ".format-converter-runtime-migration-v2.json")), true); + } finally { fs.rmSync(base, { recursive: true, force: true }); } +}); + test("runtime migration keeps using legacy data when an unverified destination already exists", () => { const base = fs.mkdtempSync(path.join(os.tmpdir(), "format-host-compat-partial-")); try { @@ -52,6 +67,6 @@ test("runtime migration keeps using legacy data when an unverified destination a const resolved = runtimeRoot({ getPath: () => pluginData }, legacy); assert.equal(resolved.root, legacy); assert.equal(resolved.modern, false); - assert.equal(fs.existsSync(path.join(pluginData, ".format-converter-runtime-migration-v1.json")), false); + assert.equal(fs.existsSync(path.join(pluginData, ".format-converter-runtime-migration-v2.json")), false); } finally { fs.rmSync(base, { recursive: true, force: true }); } }); From ba6f26d32a879b6e12f146f84cdba0b9ba5de92e Mon Sep 17 00:00:00 2001 From: wangzihao Date: Wed, 9 Sep 2026 14:14:21 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(format-converter):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=BD=AC=E6=8D=A2=20HEIC/HEIF=20=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 format-registry 中注册 heic/heif 格式及 ISO BMFF 魔数嗅探 - 集成 heic-decode 解码器,无缝桥接至 sharp 图像管线 - plugin.json 支持 heic/heif 拖拽与打开 - 增加 heic 转换与识别相关的单测及集成测试 --- plugins/format-converter/README.md | 2 +- plugins/format-converter/package-lock.json | 90 +++++----------- plugins/format-converter/package.json | 1 + plugins/format-converter/plugin.json | 2 +- .../preload/conversion-engine.cjs | 31 +++++- .../preload/format-registry.cjs | 10 +- .../preload/image-converter.cjs | 97 +++++++++++++++--- plugins/format-converter/src/App.tsx | 4 +- plugins/format-converter/src/lib/formats.ts | 3 + plugins/format-converter/src/types.ts | 2 +- .../tests/fixtures/sample.heic | Bin 0 -> 56854 bytes .../conversion-engine.integration.test.cjs | 11 +- .../tests/preload/format-registry.test.cjs | 10 +- .../tests/unit/formats.test.ts | 1 + 14 files changed, 171 insertions(+), 93 deletions(-) create mode 100644 plugins/format-converter/tests/fixtures/sample.heic diff --git a/plugins/format-converter/README.md b/plugins/format-converter/README.md index 440432ccf..d9d93c42a 100644 --- a/plugins/format-converter/README.md +++ b/plugins/format-converter/README.md @@ -11,7 +11,7 @@ ## 首发范围 -- 图片:PNG、JPEG、WebP、AVIF、TIFF、GIF、BMP 的读取与常见格式输出。 +- 图片:PNG、JPEG、WebP、AVIF、TIFF、GIF、BMP、HEIC/HEIF 的读取与常见格式输出。 - 文本与数据:TXT、Markdown、HTML、CSV、TSV、JSON 互转。 - PDF:文本提取、逐页图片、图片合成 PDF、拆分/合并基础能力。 - Office:DOCX/XLSX/PPTX 文本与 HTML 提取、PDF/图片导出,以及文本、图片、PDF向 Office 的重建。 diff --git a/plugins/format-converter/package-lock.json b/plugins/format-converter/package-lock.json index 985049b4f..f6e5465a4 100644 --- a/plugins/format-converter/package-lock.json +++ b/plugins/format-converter/package-lock.json @@ -1,18 +1,19 @@ { "name": "ztools-format-converter", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ztools-format-converter", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { "@napi-rs/canvas": "0.1.68", "@tesseract.js-data/chi_sim": "1.0.0", "@tesseract.js-data/eng": "1.0.0", "exceljs": "4.4.0", + "heic-decode": "^2.1.0", "iconv-lite": "0.6.3", "lucide-react": "1.17.0", "pdf-lib": "1.17.1", @@ -35,70 +36,6 @@ "node": ">=20.19.0" } }, - "node_modules/@emnapi/core": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-2.0.0-alpha.3.tgz", - "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "2.0.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/core/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true, - "peer": true - }, - "node_modules/@emnapi/runtime": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", - "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true, - "peer": true - }, - "node_modules/@emnapi/wasi-threads": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", - "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true, - "peer": true - }, "node_modules/@fast-csv/format": { "version": "4.3.5", "resolved": "https://registry.npmmirror.com/@fast-csv/format/-/format-4.3.5.tgz", @@ -2116,6 +2053,18 @@ "license": "ISC", "optional": true }, + "node_modules/heic-decode": { + "version": "2.1.0", + "resolved": "https://nexus-zcool.meitu-int.com/nexus/repository/npm-group/heic-decode/-/heic-decode-2.1.0.tgz", + "integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==", + "license": "ISC", + "dependencies": { + "libheif-js": "^1.19.8" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -2297,6 +2246,15 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/libheif-js": { + "version": "1.23.2", + "resolved": "https://nexus-zcool.meitu-int.com/nexus/repository/npm-group/libheif-js/-/libheif-js-1.23.2.tgz", + "integrity": "sha512-qvHIXtggEsw1lCNCWBYKloL2Z36DJBm0R9ThGiH2JnhKYdeZFLPFkP30Lw4yMskxxhx0bKg1gLrBHX1D2w2pSw==", + "license": "LGPL-3.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz", diff --git a/plugins/format-converter/package.json b/plugins/format-converter/package.json index 399d376af..ff6c2e55f 100644 --- a/plugins/format-converter/package.json +++ b/plugins/format-converter/package.json @@ -23,6 +23,7 @@ "@tesseract.js-data/chi_sim": "1.0.0", "@tesseract.js-data/eng": "1.0.0", "exceljs": "4.4.0", + "heic-decode": "^2.1.0", "iconv-lite": "0.6.3", "lucide-react": "1.17.0", "pdf-lib": "1.17.1", diff --git a/plugins/format-converter/plugin.json b/plugins/format-converter/plugin.json index 7128bbdbc..8fe84e3f8 100644 --- a/plugins/format-converter/plugin.json +++ b/plugins/format-converter/plugin.json @@ -38,7 +38,7 @@ "label": "批量转换文件", "extensions": [ "docx", "xlsx", "pptx", "pdf", - "png", "jpg", "jpeg", "webp", "avif", "tif", "tiff", "gif", "bmp", + "png", "jpg", "jpeg", "webp", "avif", "tif", "tiff", "gif", "bmp", "heic", "heif", "txt", "md", "html", "htm", "csv", "tsv", "json" ], "maxLength": 200 diff --git a/plugins/format-converter/preload/conversion-engine.cjs b/plugins/format-converter/preload/conversion-engine.cjs index 3dd92470f..1973f666d 100644 --- a/plugins/format-converter/preload/conversion-engine.cjs +++ b/plugins/format-converter/preload/conversion-engine.cjs @@ -100,7 +100,15 @@ function createConversionEngine(options) { return imageToPdf([input.path], output, { ...request.options, maxImagePixels: pathPolicy.limits.maxImagePixels }); } if (["text", "data"].includes(targetDef.family)) { - const results = await ocrImages([input.path], request.options, progress => onProgress(Math.round(progress * 75))); + const { prepareImageInput } = require("./image-converter.cjs"); + const prepared = await prepareImageInput(input.path); + let ocrPath = input.path; + if (prepared.options?.raw) { + const sharp = runtimes.require?.("sharp") || require("sharp"); + ocrPath = path.join(tempDir, `${stem}-ocr-source.png`); + await sharp(prepared.input, prepared.options).png().toFile(ocrPath); + } + const results = await ocrImages([ocrPath], request.options, progress => onProgress(Math.round(progress * 75))); const result = results[0]; const source = { text: result.text, rows: result.text.split(/\r?\n/).filter(Boolean).map(line => [line]), json: [{ source: result.source, confidence: result.confidence, text: result.text }], html: null }; return writeTextResult(tempDir, stem, request.target, convertStructured(source, "txt", request.target)); @@ -108,10 +116,27 @@ function createConversionEngine(options) { if (targetDef.family === "office") { const output = path.join(tempDir, `${stem}.${request.target}`); if (request.profile === "editable") { - const [result] = await ocrImages([input.path], request.options, progress => onProgress(Math.round(progress * 70))); + const { prepareImageInput } = require("./image-converter.cjs"); + const prepared = await prepareImageInput(input.path); + let ocrPath = input.path; + if (prepared.options?.raw) { + const sharp = runtimes.require?.("sharp") || require("sharp"); + ocrPath = path.join(tempDir, `${stem}-ocr-source.png`); + await sharp(prepared.input, prepared.options).png().toFile(ocrPath); + } + const [result] = await ocrImages([ocrPath], request.options, progress => onProgress(Math.round(progress * 70))); return generateOfficeFromText(result.text, request.target, output, runtimes, tempDir, { ...request.options, rows: result.text.split(/\r?\n/).filter(Boolean).map(line => [line]), signal }); } - return imagesToOffice([input.path], request.target, output, runtimes, tempDir, { ...request.options, signal }); + let sourcePath = input.path; + const ext = path.extname(input.path).toLowerCase(); + if (ext === ".heic" || ext === ".heif") { + const { prepareImageInput } = require("./image-converter.cjs"); + const prepared = await prepareImageInput(input.path); + const sharp = runtimes.require?.("sharp") || require("sharp"); + sourcePath = path.join(tempDir, `${stem}-office-source.png`); + await sharp(prepared.input, prepared.options).png().toFile(sourcePath); + } + return imagesToOffice([sourcePath], request.target, output, runtimes, tempDir, { ...request.options, signal }); } throw conversionError("ROUTE_NOT_IMPLEMENTED", `尚未实现 ${input.format} → ${request.target}。`); } diff --git a/plugins/format-converter/preload/format-registry.cjs b/plugins/format-converter/preload/format-registry.cjs index 25936dc9b..4842efe32 100644 --- a/plugins/format-converter/preload/format-registry.cjs +++ b/plugins/format-converter/preload/format-registry.cjs @@ -14,6 +14,7 @@ const FORMAT_DEFINITIONS = Object.freeze([ { id: "tiff", label: "TIFF", family: "image", extensions: ["tif", "tiff"], color: "#4d6674" }, { id: "gif", label: "GIF", family: "image", extensions: ["gif"], color: "#8d4b9d" }, { id: "bmp", label: "BMP", family: "image", extensions: ["bmp"], color: "#596a7d" }, + { id: "heic", label: "HEIC", family: "image", extensions: ["heic", "heif"], color: "#1a8a99" }, { id: "txt", label: "纯文本", family: "text", extensions: ["txt"], color: "#53606e" }, { id: "md", label: "Markdown", family: "text", extensions: ["md"], color: "#202b36" }, { id: "html", label: "HTML", family: "text", extensions: ["html", "htm"], color: "#e35c30" }, @@ -22,7 +23,7 @@ const FORMAT_DEFINITIONS = Object.freeze([ { id: "json", label: "JSON", family: "data", extensions: ["json"], color: "#8c741c" } ]); -const TARGET_IDS = new Set(FORMAT_DEFINITIONS.map(item => item.id).filter(id => id !== "bmp")); +const TARGET_IDS = new Set(FORMAT_DEFINITIONS.map(item => item.id).filter(id => id !== "bmp" && id !== "heic")); const EXTENSION_MAP = new Map(); for (const definition of FORMAT_DEFINITIONS) { for (const extension of definition.extensions) EXTENSION_MAP.set(extension, definition.id); @@ -38,6 +39,7 @@ function normalizeFormat(value) { if (normalized === "jpg") return "jpeg"; if (normalized === "tif") return "tiff"; if (normalized === "htm") return "html"; + if (normalized === "heif") return "heic"; return TARGET_IDS.has(normalized) ? normalized : EXTENSION_MAP.get(normalized) || null; } @@ -55,6 +57,12 @@ function sniffMagic(buffer, expected) { if (buffer.subarray(0, 4).toString("hex") === "49492a00" || buffer.subarray(0, 4).toString("hex") === "4d4d002a") return "tiff"; if (buffer.subarray(0, 2).toString("ascii") === "BM") return "bmp"; if (buffer.subarray(4, 12).toString("ascii").includes("ftypavif")) return "avif"; + if (buffer.length >= 12 && buffer.subarray(4, 8).toString("ascii") === "ftyp") { + const brand = buffer.subarray(8, 12).toString("ascii").toLowerCase(); + if (["heic", "heix", "hevc", "hevx", "heim", "heis", "hevm", "hevs", "mif1", "msf1"].includes(brand)) { + return "heic"; + } + } if (buffer.subarray(0, 4).toString("hex") === "504b0304" && ["docx", "xlsx", "pptx"].includes(expected)) return expected; if (["txt", "md", "csv", "tsv", "json", "html"].includes(expected)) { const nulCount = buffer.subarray(0, Math.min(buffer.length, 4096)).reduce((count, byte) => count + (byte === 0 ? 1 : 0), 0); diff --git a/plugins/format-converter/preload/image-converter.cjs b/plugins/format-converter/preload/image-converter.cjs index e09691da1..588040318 100644 --- a/plugins/format-converter/preload/image-converter.cjs +++ b/plugins/format-converter/preload/image-converter.cjs @@ -5,22 +5,85 @@ const path = require("node:path"); const { escapeHtml } = require("./text-converter.cjs"); const { runtimeRequire, runtimePath } = require("./runtime-loader.cjs"); -function imagePipeline(input, target, options = {}) { - const sharp = runtimeRequire("sharp"); - let pipeline = sharp(input, { animated: true, limitInputPixels: options.maxImagePixels || 100_000_000 }).rotate(); +function isHeicBuffer(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < 12) return false; + if (buffer.subarray(4, 8).toString("latin1") !== "ftyp") return false; + const brand = buffer.subarray(8, 12).toString("latin1").toLowerCase(); + return ["heic", "heix", "hevc", "hevx", "heim", "heis", "hevm", "hevs", "mif1", "msf1"].includes(brand); +} + +async function prepareImageInput(input) { + let buffer; + if (Buffer.isBuffer(input)) { + buffer = input; + } else if (typeof input === "string") { + const ext = path.extname(input).toLowerCase(); + if (ext === ".heic" || ext === ".heif") { + buffer = await fs.readFile(input); + } else { + const fd = await fs.open(input, "r"); + try { + const header = Buffer.alloc(16); + const { bytesRead } = await fd.read(header, 0, 16, 0); + if (bytesRead >= 12 && isHeicBuffer(header)) { + buffer = await fs.readFile(input); + } + } finally { + await fd.close(); + } + } + } + + if (buffer && isHeicBuffer(buffer)) { + let decodeHeic; + try { + decodeHeic = runtimeRequire("heic-decode"); + } catch { + decodeHeic = require("heic-decode"); + } + const { data, width, height } = await decodeHeic({ buffer }); + return { + input: Buffer.from(data), + options: { + raw: { + width, + height, + channels: 4 + } + } + }; + } + + return { input, options: {} }; +} + +function applyImageTarget(pipeline, target, options = {}) { const quality = Math.min(Math.max(options.quality || 86, 20), 100); - if (target === "png") pipeline = pipeline.png({ compressionLevel: 9, palette: false }); - else if (target === "jpeg") pipeline = pipeline.jpeg({ quality, mozjpeg: true }); - else if (target === "webp") pipeline = pipeline.webp({ quality }); - else if (target === "avif") pipeline = pipeline.avif({ quality: Math.min(quality, 90), effort: 5 }); - else if (target === "tiff") pipeline = pipeline.tiff({ quality, compression: "lzw" }); - else if (target === "gif") pipeline = pipeline.gif({ effort: 5 }); - else throw new Error(`Unsupported image target: ${target}`); + if (target === "png") return pipeline.png({ compressionLevel: 9, palette: false }); + if (target === "jpeg") return pipeline.jpeg({ quality, mozjpeg: true }); + if (target === "webp") return pipeline.webp({ quality }); + if (target === "avif") return pipeline.avif({ quality: Math.min(quality, 90), effort: 5 }); + if (target === "tiff") return pipeline.tiff({ quality, compression: "lzw" }); + if (target === "gif") return pipeline.gif({ effort: 5 }); + throw new Error(`Unsupported image target: ${target}`); +} + +async function imagePipeline(input, target, options = {}) { + const sharp = runtimeRequire("sharp"); + const prepared = await prepareImageInput(input); + let pipeline = sharp(prepared.input, { + ...prepared.options, + animated: !prepared.options.raw, + limitInputPixels: options.maxImagePixels || 100_000_000 + }); + if (!prepared.options.raw) pipeline = pipeline.rotate(); + pipeline = applyImageTarget(pipeline, target, options); return options.preserveMetadata ? pipeline.keepMetadata() : pipeline; } async function convertImage(inputPath, outputPath, target, options) { - await imagePipeline(inputPath, target, options).toFile(outputPath); + const pipeline = await imagePipeline(inputPath, target, options); + await pipeline.toFile(outputPath); return [outputPath]; } @@ -29,7 +92,13 @@ async function imageToPdf(inputPaths, outputPath, options = {}) { const { PDFDocument } = runtimeRequire("pdf-lib"); const document = await PDFDocument.create(); for (const input of inputPaths) { - const normalized = await sharp(input, { limitInputPixels: options.maxImagePixels || 100_000_000 }).rotate().png().toBuffer(); + const prepared = await prepareImageInput(input); + let pipeline = sharp(prepared.input, { + ...prepared.options, + limitInputPixels: options.maxImagePixels || 100_000_000 + }); + if (!prepared.options.raw) pipeline = pipeline.rotate(); + const normalized = await pipeline.png().toBuffer(); const image = await document.embedPng(normalized); const width = image.width; const height = image.height; @@ -68,7 +137,7 @@ async function textToImages(text, outputBase, target, options = {}) { const svg = `${body}${pageIndex + 1}`; const suffix = Math.ceil(lines.length / linesPerPage) > 1 ? `-page-${String(pageIndex + 1).padStart(4, "0")}` : ""; const output = `${outputBase}${suffix}.${target === "jpeg" ? "jpg" : target}`; - await imagePipeline(Buffer.from(svg), target, options).toFile(output); + await (await imagePipeline(Buffer.from(svg), target, options)).toFile(output); pages.push(output); } return pages; @@ -103,4 +172,4 @@ async function ocrImages(inputPaths, options = {}, onProgress) { } finally { if (worker) await worker.terminate().catch(() => undefined); } } -module.exports = { imagePipeline, convertImage, imageToPdf, textToImages, ocrImages, wrapLines }; +module.exports = { imagePipeline, convertImage, imageToPdf, textToImages, ocrImages, wrapLines, isHeicBuffer, prepareImageInput }; diff --git a/plugins/format-converter/src/App.tsx b/plugins/format-converter/src/App.tsx index 5d2ad0315..39a04042c 100644 --- a/plugins/format-converter/src/App.tsx +++ b/plugins/format-converter/src/App.tsx @@ -27,7 +27,7 @@ import { Trash2, XCircle } from "lucide-react"; -import { bytesLabel, formatDefinition, FORMAT_DEFINITIONS, PROFILE_COPY, TARGET_GROUPS } from "./lib/formats"; +import { bytesLabel, formatDefinition, FORMAT_DEFINITIONS, TARGET_FORMATS, PROFILE_COPY, TARGET_GROUPS } from "./lib/formats"; import type { ApiEnvelope, CollisionPolicy, @@ -295,7 +295,7 @@ function FormatConverterApp() {

先检查格式和本机引擎,再执行批量转换。保真、可编辑和 OCR 路线会明确标注,不把有损转换藏起来。

-
{FORMAT_DEFINITIONS.length}目标格式
+
{TARGET_FORMATS.length}目标格式
{capabilities?.runtimes.filter(item => item.available).length ?? "—"}可用引擎
200单批文件
diff --git a/plugins/format-converter/src/lib/formats.ts b/plugins/format-converter/src/lib/formats.ts index 1368366f6..163d187c0 100644 --- a/plugins/format-converter/src/lib/formats.ts +++ b/plugins/format-converter/src/lib/formats.ts @@ -12,6 +12,7 @@ export const FORMAT_DEFINITIONS: FormatDefinition[] = [ { id: "tiff", label: "TIFF", family: "image", extensions: ["tif", "tiff"], color: "#4d6674" }, { id: "gif", label: "GIF", family: "image", extensions: ["gif"], color: "#8d4b9d" }, { id: "bmp", label: "BMP", family: "image", extensions: ["bmp"], color: "#596a7d" }, + { id: "heic", label: "HEIC", family: "image", extensions: ["heic", "heif"], color: "#1a8a99" }, { id: "txt", label: "纯文本", family: "text", extensions: ["txt"], color: "#53606e" }, { id: "md", label: "Markdown", family: "text", extensions: ["md"], color: "#202b36" }, { id: "html", label: "HTML", family: "text", extensions: ["html", "htm"], color: "#e35c30" }, @@ -26,6 +27,8 @@ export const TARGET_GROUPS = [ { label: "文本与数据", ids: ["txt", "md", "html", "csv", "tsv", "json"] as FormatId[] } ]; +export const TARGET_FORMATS = TARGET_GROUPS.flatMap(group => group.ids); + export const PROFILE_COPY: Record = { visual: { label: "视觉保真", short: "外观优先", description: "优先保持页面外观;转回 Office 时内容可能以整页图片呈现。" }, editable: { label: "可编辑重建", short: "结构优先", description: "重建文本、表格和页面结构;复杂版式可能发生变化。" }, diff --git a/plugins/format-converter/src/types.ts b/plugins/format-converter/src/types.ts index ed667c234..096f658b8 100644 --- a/plugins/format-converter/src/types.ts +++ b/plugins/format-converter/src/types.ts @@ -1,6 +1,6 @@ export type FormatId = | "docx" | "xlsx" | "pptx" | "pdf" - | "png" | "jpeg" | "webp" | "avif" | "tiff" | "gif" | "bmp" + | "png" | "jpeg" | "webp" | "avif" | "tiff" | "gif" | "bmp" | "heic" | "txt" | "md" | "html" | "csv" | "tsv" | "json"; export type ConversionProfile = "visual" | "editable" | "extract"; diff --git a/plugins/format-converter/tests/fixtures/sample.heic b/plugins/format-converter/tests/fixtures/sample.heic new file mode 100644 index 0000000000000000000000000000000000000000..858e7d0c9ae701a79b1045e73cacafe74575c870 GIT binary patch literal 56854 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 { assert.equal(detectFormatByPath("/tmp/report.docx"), "docx"); assert.equal(detectFormatByPath("/tmp/scan.gif"), "gif"); assert.equal(detectFormatByPath("/tmp/legacy.bmp"), "bmp"); + assert.equal(detectFormatByPath("/tmp/photo.heic"), "heic"); + assert.equal(detectFormatByPath("/tmp/photo.heif"), "heic"); + assert.equal(normalizeFormat("heif"), "heic"); }); test("uses magic bytes to reject disguised binary content", () => { assert.equal(sniffMagic(Buffer.from("%PDF-1.7"), "pdf"), "pdf"); assert.equal(sniffMagic(Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]), "png"), "png"); + assert.equal(sniffMagic(Buffer.from([0, 0, 0, 24, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63]), "heic"), "heic"); assert.equal(sniffMagic(Buffer.from("MZ\0\0"), "txt"), "txt"); assert.equal(sniffMagic(Buffer.from([0,0,0,0,0,0,0,0]), "txt"), null); }); @@ -31,9 +35,9 @@ test("routes expose quality, dependencies, availability and multi-output", () => assert.equal(pdfImages.multiOutput, true); assert.deepEqual(pdfImages.engines, ["pdf"]); - const officePdf = buildRoute("docx", "pdf", "visual", runtimes); - assert.equal(officePdf.available, true); - assert.equal(officePdf.quality, "visual"); + const heicJpeg = buildRoute("heic", "jpeg", "visual", runtimes); + assert.equal(heicJpeg.available, true); + assert.deepEqual(heicJpeg.engines, ["sharp"]); const missing = buildRoute("png", "txt", "editable", runtimes.map(item => item.id === "ocr" ? { ...item, available: false } : item)); assert.equal(missing.available, false); diff --git a/plugins/format-converter/tests/unit/formats.test.ts b/plugins/format-converter/tests/unit/formats.test.ts index dd85a47bc..b50099449 100644 --- a/plugins/format-converter/tests/unit/formats.test.ts +++ b/plugins/format-converter/tests/unit/formats.test.ts @@ -7,6 +7,7 @@ describe("renderer format metadata", () => { expect(new Set(ids).size).toBe(ids.length); ids.forEach(id => expect(formatDefinition(id).id).toBe(id)); expect(FORMAT_DEFINITIONS.some(item => item.id === "bmp")).toBe(true); + expect(FORMAT_DEFINITIONS.some(item => item.id === "heic")).toBe(true); }); it("explains every quality profile", () => { From a35cf14f1acb1eb34fccad84dd73bd84d824a195 Mon Sep 17 00:00:00 2001 From: wangzihao Date: Wed, 9 Sep 2026 14:21:57 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(format-converter):=20=E5=B0=86=20packag?= =?UTF-8?q?e-lock.json=20=E4=B8=AD=E7=9A=84=E5=86=85=E7=BD=91=E7=A7=81?= =?UTF-8?q?=E6=9C=89=E6=BA=90=E5=9C=B0=E5=9D=80=E6=9B=BF=E6=8D=A2=E4=B8=BA?= =?UTF-8?q?=E5=AE=98=E6=96=B9=20npm=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/format-converter/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/format-converter/package-lock.json b/plugins/format-converter/package-lock.json index f6e5465a4..4810d71fa 100644 --- a/plugins/format-converter/package-lock.json +++ b/plugins/format-converter/package-lock.json @@ -2055,7 +2055,7 @@ }, "node_modules/heic-decode": { "version": "2.1.0", - "resolved": "https://nexus-zcool.meitu-int.com/nexus/repository/npm-group/heic-decode/-/heic-decode-2.1.0.tgz", + "resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz", "integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==", "license": "ISC", "dependencies": { @@ -2248,7 +2248,7 @@ }, "node_modules/libheif-js": { "version": "1.23.2", - "resolved": "https://nexus-zcool.meitu-int.com/nexus/repository/npm-group/libheif-js/-/libheif-js-1.23.2.tgz", + "resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.23.2.tgz", "integrity": "sha512-qvHIXtggEsw1lCNCWBYKloL2Z36DJBm0R9ThGiH2JnhKYdeZFLPFkP30Lw4yMskxxhx0bKg1gLrBHX1D2w2pSw==", "license": "LGPL-3.0", "engines": {