diff --git a/.gitignore b/.gitignore index 727dea3..9222722 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ playwright-report/ test-results/ output/ /apps/desktop/out/ +/apps/desktop/.runtime/ *.tsbuildinfo diff --git a/README.md b/README.md index 984408f..8b773f9 100644 --- a/README.md +++ b/README.md @@ -139,8 +139,9 @@ npm run desktop:package npm run make -w @md-to-pdf/desktop ``` -桌面 `package` 和 `make` 会先完整重建内嵌 Web 及全部共享依赖,不能 -复用陈旧的 `apps/web/dist`。产物位于 `apps/desktop/out/v0.6.0/`。 +桌面 `package` 和 `make` 会先完整重建内嵌 Web 及全部共享依赖,再准备并 +校验固定 Pandoc `3.9.0.2` 最小运行时,不能复用陈旧的 +`apps/web/dist`。产物位于 `apps/desktop/out/v0.6.0/`。 正式安装包命名为 `MorphDoc-0.6.0-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包 未配置代码签名。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 63a65aa..9ba58c6 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -11,7 +11,7 @@ Apache License 2.0 仅是未来公开 GitHub 版本的候选许可证。当前 ## Pandoc -`v0.6.0` 计划将 Pandoc 作为独立可执行程序用于 DOCX 转换。 +`v0.6.0` 将未经修改的 Pandoc 作为独立可执行程序用于 DOCX 转换。 - 项目:Pandoc - 版权:John MacFarlane 及贡献者 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index dad7676..0af50f5 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -43,7 +43,18 @@ npm run make -w @md-to-pdf/desktop `package` 和 `make` 会先调用根级 `build:web-runtime`,完整重建 Markdown ECharts、Core、Renderer、Application、Preview Engine 和 Web, -然后才复制 `apps/web/dist`。不得绕过该链路直接调用 electron-builder。 +再按固定清单下载或复用 Pandoc `3.9.0.2` 缓存、校验官方 ZIP 的 +SHA-256,并执行版本与最小 DOCX 冒烟,最后才调用 electron-builder。 +不得绕过该链路直接调用 electron-builder。 + +Pandoc 构建缓存位于被 Git 忽略的 `apps/desktop/.runtime/`。发行目录只 +包含 `pandoc.exe`、`COPYING.rtf`、`COPYRIGHT.txt` 和生成的审计清单, +不会携带官方 MSI、手册、下载缓存或用户级 Pandoc 配置。已准备运行时可用 +以下命令离线复验: + +```powershell +npm run verify:pandoc-runtime -w @md-to-pdf/desktop +``` 版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到 `apps/desktop/out/v0.6.0/`。该目录被 Git 忽略。公司内部分发以 @@ -63,7 +74,8 @@ NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安 和 `MorphDoc.exe`。NSIS appId 保持 `com.md-to-pdf.desktop`,确保 v0.5.1 覆盖升级沿用原安装身份。 -当前目录包只包含一套 Electron Chromium,不携带 Playwright Chromium。 +当前目录包只包含一套 Electron Chromium,不携带 Playwright Chromium; +Pandoc 作为未修改的独立程序随 Desktop 内置,通过受控子进程执行。 Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过 `mdpdf://bundle/` 自定义协议加载。 diff --git a/apps/desktop/electron-builder.config.cjs b/apps/desktop/electron-builder.config.cjs index 023ff88..3b1af75 100644 --- a/apps/desktop/electron-builder.config.cjs +++ b/apps/desktop/electron-builder.config.cjs @@ -1,6 +1,9 @@ const path = require("node:path"); const { version } = require("./package.json"); const brand = require("../../product.json"); +const pandocRuntime = require( + "../../packages/docx-engine/src/pandoc-runtime.json" +); const windowsIcon = path.resolve( __dirname, @@ -38,6 +41,16 @@ module.exports = { to: "samples", filter: ["**/*"] }, + { + from: `.runtime/pandoc/${pandocRuntime.version}/windows-x86_64`, + to: `pandoc/${pandocRuntime.version}/windows-x86_64`, + filter: [ + "pandoc.exe", + "COPYING.rtf", + "COPYRIGHT.txt", + "runtime-manifest.json" + ] + }, { from: windowsIcon, to: "app.ico" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f3f599a..d528bf2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -10,12 +10,14 @@ "scripts": { "build": "npm run clean && npm run build:main && npm run build:preload", "build:embedded-web": "npm --prefix ../.. run build:web-runtime", + "prepare:pandoc": "node scripts/prepare-pandoc-runtime.mjs", + "verify:pandoc-runtime": "node scripts/prepare-pandoc-runtime.mjs --check", "build:main": "node scripts/build-main.mjs", "build:preload": "esbuild src/app-preload.ts src/pdf-preload.ts --bundle --platform=node --format=cjs --external:electron --outdir=dist --out-extension:.js=.cjs", "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", "dev": "npm run build && wait-on http://localhost:5173 && electron dist/main.js --web-url=http://localhost:5173", - "package": "npm run build:embedded-web && npm run build && electron-builder --dir --config electron-builder.config.cjs --x64", - "make": "npm run build:embedded-web && npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64", + "package": "npm run build:embedded-web && npm run prepare:pandoc && npm run build && electron-builder --dir --config electron-builder.config.cjs --x64", + "make": "npm run build:embedded-web && npm run prepare:pandoc && npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64", "test": "vitest run", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "verify:docx-theme-styles": "electron scripts/verify-docx-theme-styles.cjs" @@ -28,6 +30,7 @@ "electron": "43.2.0", "electron-builder": "26.15.3", "esbuild": "^0.25.0", + "fflate": "0.8.3", "typescript": "^5.9.3", "vitest": "^4.1.10", "wait-on": "^9.0.1" diff --git a/apps/desktop/scripts/prepare-pandoc-runtime.d.mts b/apps/desktop/scripts/prepare-pandoc-runtime.d.mts new file mode 100644 index 0000000..9183807 --- /dev/null +++ b/apps/desktop/scripts/prepare-pandoc-runtime.d.mts @@ -0,0 +1,20 @@ +export interface MinimalPandocArtifact { + executableRelativePath: string; + licenseFiles: string[]; +} + +export function sha256(content: Uint8Array): string; + +export function extractRequiredFiles( + archive: Uint8Array, + artifact: MinimalPandocArtifact +): Map; + +export function verifyPandocVersion( + executablePath: string, + expectedVersion: string +): void; + +export function preparePandocRuntime(options?: { + checkOnly?: boolean; +}): Promise; diff --git a/apps/desktop/scripts/prepare-pandoc-runtime.mjs b/apps/desktop/scripts/prepare-pandoc-runtime.mjs new file mode 100644 index 0000000..6f4d55f --- /dev/null +++ b/apps/desktop/scripts/prepare-pandoc-runtime.mjs @@ -0,0 +1,359 @@ +import { createHash } from "node:crypto"; +import { + access, + mkdir, + readFile, + rename, + rm, + writeFile +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { unzipSync } from "fflate"; + +const projectRoot = fileURLToPath(new URL("../../../", import.meta.url)); +const desktopRoot = path.join(projectRoot, "apps", "desktop"); +const manifestPath = path.join( + projectRoot, + "packages", + "docx-engine", + "src", + "pandoc-runtime.json" +); +const maximumArchiveBytes = 64 * 1024 * 1024; +const maximumExtractedBytes = 256 * 1024 * 1024; +const downloadTimeoutMs = 120_000; + +function sha256(content) { + return createHash("sha256").update(content).digest("hex").toUpperCase(); +} + +function normalizedBaseName(fileName) { + return fileName.replaceAll("\\", "/").split("/").filter(Boolean).at(-1); +} + +async function fileExists(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +async function readRuntimeManifest() { + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + const artifact = manifest.artifacts?.find( + (candidate) => + candidate.platform === "win32" && candidate.architecture === "x64" + ); + if (!artifact || artifact.archiveType !== "zip") { + throw new Error("Pandoc 清单缺少 Windows x64 ZIP 发行项"); + } + const requiredNames = [ + normalizedBaseName(artifact.executableRelativePath), + ...artifact.licenseFiles.map(normalizedBaseName) + ]; + if ( + requiredNames.some( + (fileName) => + !fileName || + !Number.isInteger(artifact.files?.[fileName]?.bytes) || + !/^[A-F0-9]{64}$/u.test(artifact.files?.[fileName]?.sha256 ?? "") + ) + ) { + throw new Error("Pandoc 清单缺少最小运行时文件哈希或长度"); + } + return { manifest, artifact }; +} + +async function downloadArchive(downloadUrl, destination) { + const response = await fetch(downloadUrl, { + redirect: "follow", + signal: AbortSignal.timeout(downloadTimeoutMs) + }); + if (!response.ok) { + throw new Error(`Pandoc 下载失败:HTTP ${response.status}`); + } + const declaredLength = Number(response.headers.get("content-length")); + if ( + Number.isFinite(declaredLength) && + declaredLength > maximumArchiveBytes + ) { + throw new Error("Pandoc 归档超过允许的下载大小"); + } + const content = new Uint8Array(await response.arrayBuffer()); + if (content.byteLength > maximumArchiveBytes) { + throw new Error("Pandoc 归档超过允许的下载大小"); + } + await writeFile(destination, content); + return content; +} + +async function loadVerifiedArchive(artifact, archivePath) { + if (await fileExists(archivePath)) { + const cached = new Uint8Array(await readFile(archivePath)); + if (sha256(cached) === artifact.sha256) { + return cached; + } + await rm(archivePath, { force: true }); + } + + const partialPath = `${archivePath}.${process.pid}.partial`; + await rm(partialPath, { force: true }); + try { + const downloaded = await downloadArchive( + artifact.downloadUrl, + partialPath + ); + const actualHash = sha256(downloaded); + if (actualHash !== artifact.sha256) { + throw new Error( + `Pandoc 归档 SHA-256 不匹配:期望 ${artifact.sha256},实际 ${actualHash}` + ); + } + await rename(partialPath, archivePath); + return downloaded; + } finally { + await rm(partialPath, { force: true }); + } +} + +function extractRequiredFiles(archive, artifact) { + const requiredNames = new Set([ + normalizedBaseName(artifact.executableRelativePath), + ...artifact.licenseFiles.map(normalizedBaseName) + ]); + let selectedBytes = 0; + const entries = unzipSync(archive, { + filter(file) { + const selected = requiredNames.has(normalizedBaseName(file.name)); + if (selected) { + selectedBytes += file.originalSize; + if (selectedBytes > maximumExtractedBytes) { + throw new Error("Pandoc 最小运行时超过允许的解压大小"); + } + } + return selected; + } + }); + const files = new Map(); + for (const [entryName, content] of Object.entries(entries)) { + const baseName = normalizedBaseName(entryName); + if (!baseName || !requiredNames.has(baseName)) { + continue; + } + if (files.has(baseName)) { + throw new Error(`Pandoc 归档包含重复文件:${baseName}`); + } + files.set(baseName, content); + } + for (const requiredName of requiredNames) { + if (!files.has(requiredName)) { + throw new Error(`Pandoc 归档缺少必须文件:${requiredName}`); + } + } + return files; +} + +function verifyPandocVersion(executablePath, expectedVersion) { + const result = spawnSync(executablePath, ["--version"], { + encoding: "utf8", + maxBuffer: 64 * 1024, + windowsHide: true + }); + if (result.error || result.status !== 0) { + throw new Error(`内置 Pandoc 无法执行:${result.error?.message ?? result.stderr}`); + } + const firstLine = result.stdout.split(/\r?\n/u)[0]?.trim(); + if (firstLine !== `pandoc ${expectedVersion}`) { + throw new Error( + `内置 Pandoc 版本不匹配:期望 pandoc ${expectedVersion},实际 ${firstLine ?? "未知"}` + ); + } +} + +async function smokeTestPandoc(executablePath) { + const smokeRoot = path.join( + tmpdir(), + `morphdoc-pandoc-smoke-${process.pid}-${Date.now()}` + ); + const dataDirectory = path.join(smokeRoot, "data"); + const outputPath = path.join(smokeRoot, "smoke.docx"); + await mkdir(dataDirectory, { recursive: true }); + try { + const result = spawnSync( + executablePath, + [ + "--from=gfm", + "--to=docx", + `--data-dir=${dataDirectory}`, + `--output=${outputPath}` + ], + { + encoding: "utf8", + input: "# 墨呈\n\nPandoc 运行时冒烟测试。\n", + maxBuffer: 1024 * 1024, + windowsHide: true + } + ); + if (result.error || result.status !== 0) { + throw new Error(`内置 Pandoc DOCX 冒烟失败:${result.error?.message ?? result.stderr}`); + } + const output = await readFile(outputPath); + if (output.byteLength < 1024 || output[0] !== 0x50 || output[1] !== 0x4b) { + throw new Error("内置 Pandoc 生成的 DOCX 不是有效 ZIP 包"); + } + } finally { + await rm(smokeRoot, { recursive: true, force: true }); + } +} + +async function verifyPreparedRuntime(targetDirectory, manifest, artifact) { + const auditPath = path.join(targetDirectory, "runtime-manifest.json"); + if (!(await fileExists(auditPath))) { + return false; + } + try { + const audit = JSON.parse(await readFile(auditPath, "utf8")); + if ( + audit.version !== manifest.version || + audit.archiveSha256 !== artifact.sha256 || + audit.downloadUrl !== artifact.downloadUrl + ) { + return false; + } + const expectedFiles = [ + normalizedBaseName(artifact.executableRelativePath), + ...artifact.licenseFiles.map(normalizedBaseName) + ]; + for (const fileName of expectedFiles) { + const content = await readFile(path.join(targetDirectory, fileName)); + const expected = artifact.files[fileName]; + if ( + !expected || + content.byteLength !== expected.bytes || + sha256(content) !== expected.sha256 || + audit.files?.[fileName]?.sha256 !== expected.sha256 + ) { + return false; + } + } + const executablePath = path.join( + targetDirectory, + normalizedBaseName(artifact.executableRelativePath) + ); + verifyPandocVersion(executablePath, manifest.version); + await smokeTestPandoc(executablePath); + return true; + } catch { + return false; + } +} + +async function preparePandocRuntime({ checkOnly = false } = {}) { + const { manifest, artifact } = await readRuntimeManifest(); + const platformDirectory = "windows-x86_64"; + const runtimeRoot = path.join(desktopRoot, ".runtime", "pandoc"); + const targetDirectory = path.join( + runtimeRoot, + manifest.version, + platformDirectory + ); + if (await verifyPreparedRuntime(targetDirectory, manifest, artifact)) { + console.log(`Pandoc ${manifest.version} 最小运行时已通过校验`); + return targetDirectory; + } + if (checkOnly) { + throw new Error("Pandoc 最小运行时缺失或未通过完整性校验"); + } + + const downloadDirectory = path.join(runtimeRoot, "downloads"); + await mkdir(downloadDirectory, { recursive: true }); + const archiveName = new URL(artifact.downloadUrl).pathname.split("/").at(-1); + if (!archiveName) { + throw new Error("Pandoc 下载地址缺少归档文件名"); + } + const archivePath = path.join(downloadDirectory, archiveName); + const archive = await loadVerifiedArchive(artifact, archivePath); + const files = extractRequiredFiles(archive, artifact); + const temporaryDirectory = path.join( + runtimeRoot, + manifest.version, + `.${platformDirectory}-${process.pid}-${Date.now()}` + ); + await rm(temporaryDirectory, { recursive: true, force: true }); + await mkdir(temporaryDirectory, { recursive: true }); + try { + const auditFiles = {}; + for (const [fileName, content] of files) { + const expected = artifact.files[fileName]; + const actualHash = sha256(content); + if ( + !expected || + content.byteLength !== expected.bytes || + actualHash !== expected.sha256 + ) { + throw new Error(`Pandoc 文件完整性校验失败:${fileName}`); + } + await writeFile(path.join(temporaryDirectory, fileName), content); + auditFiles[fileName] = { + bytes: content.byteLength, + sha256: actualHash + }; + } + const executablePath = path.join( + temporaryDirectory, + normalizedBaseName(artifact.executableRelativePath) + ); + verifyPandocVersion(executablePath, manifest.version); + await smokeTestPandoc(executablePath); + await writeFile( + path.join(temporaryDirectory, "runtime-manifest.json"), + `${JSON.stringify( + { + version: manifest.version, + platform: "win32", + architecture: "x64", + license: manifest.license, + projectUrl: manifest.projectUrl, + sourceArchiveUrl: manifest.sourceArchiveUrl, + downloadUrl: artifact.downloadUrl, + archiveSha256: artifact.sha256, + files: auditFiles + }, + null, + 2 + )}\n`, + "utf8" + ); + await rm(targetDirectory, { recursive: true, force: true }); + await rename(temporaryDirectory, targetDirectory); + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } + console.log(`Pandoc ${manifest.version} 最小运行时已准备:${targetDirectory}`); + return targetDirectory; +} + +const isDirectInvocation = + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isDirectInvocation) { + preparePandocRuntime({ checkOnly: process.argv.includes("--check") }).catch( + (error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } + ); +} + +export { + extractRequiredFiles, + preparePandocRuntime, + sha256, + verifyPandocVersion +}; diff --git a/apps/desktop/tests/pandoc-runtime-build.test.ts b/apps/desktop/tests/pandoc-runtime-build.test.ts new file mode 100644 index 0000000..b312f78 --- /dev/null +++ b/apps/desktop/tests/pandoc-runtime-build.test.ts @@ -0,0 +1,76 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { strToU8, zipSync } from "fflate"; +import { + extractRequiredFiles, + sha256 +} from "../scripts/prepare-pandoc-runtime.mjs"; + +const artifact = { + executableRelativePath: "pandoc.exe", + licenseFiles: ["COPYING.rtf", "COPYRIGHT.txt"] +}; + +function archive(files: Record) { + return zipSync( + Object.fromEntries( + Object.entries(files).map(([name, content]) => [ + name, + strToU8(content) + ]) + ) + ); +} + +describe("Desktop Pandoc 最小运行时构建", () => { + it("仅提取可执行文件和两份许可证文件", () => { + const files = extractRequiredFiles( + archive({ + "pandoc-3.9.0.2/pandoc.exe": "executable", + "pandoc-3.9.0.2/COPYING.rtf": "license", + "pandoc-3.9.0.2/COPYRIGHT.txt": "copyright", + "pandoc-3.9.0.2/MANUAL.html": "manual" + }), + artifact + ); + + expect([...files.keys()].sort()).toEqual([ + "COPYING.rtf", + "COPYRIGHT.txt", + "pandoc.exe" + ]); + }); + + it("拒绝缺失许可证文件的归档", () => { + expect(() => + extractRequiredFiles( + archive({ + "pandoc.exe": "executable", + "COPYING.rtf": "license" + }), + artifact + ) + ).toThrow("COPYRIGHT.txt"); + }); + + it("拒绝用不同目录伪造的重复文件", () => { + expect(() => + extractRequiredFiles( + archive({ + "official/pandoc.exe": "official", + "shadow/pandoc.exe": "shadow", + "official/COPYING.rtf": "license", + "official/COPYRIGHT.txt": "copyright" + }), + artifact + ) + ).toThrow("重复文件:pandoc.exe"); + }); + + it("使用大写十六进制输出稳定 SHA-256", () => { + const content = strToU8("MorphDoc"); + expect(sha256(content)).toBe( + createHash("sha256").update(content).digest("hex").toUpperCase() + ); + }); +}); diff --git a/apps/desktop/tests/release-build.test.ts b/apps/desktop/tests/release-build.test.ts index 6ecfd95..93b4acf 100644 --- a/apps/desktop/tests/release-build.test.ts +++ b/apps/desktop/tests/release-build.test.ts @@ -17,6 +17,17 @@ interface ProductBrand { artifactPrefix: string; } +interface PandocRuntimeManifest { + version: string; + artifacts: Array<{ + platform: string; + architecture: string; + sha256: string; + licenseFiles: string[]; + files?: Record; + }>; +} + const desktopRoot = fileURLToPath(new URL("../", import.meta.url)); const projectRoot = fileURLToPath(new URL("../../../", import.meta.url)); @@ -54,11 +65,62 @@ describe("桌面发行构建链", () => { expect(desktopManifest.scripts?.make).toMatch( /^npm run build:embedded-web && / ); + expect(desktopManifest.scripts?.package).toContain( + "npm run prepare:pandoc" + ); + expect(desktopManifest.scripts?.make).toContain( + "npm run prepare:pandoc" + ); expect(rootManifest.scripts?.["desktop:package"]).toBe( "npm run package -w @md-to-pdf/desktop" ); }); + it("将固定 Pandoc 最小运行时作为 Desktop 独立资源打包", () => { + const desktopManifest = readManifest(`${desktopRoot}/package.json`); + const runtimeManifest = readManifest( + `${projectRoot}/packages/docx-engine/src/pandoc-runtime.json` + ); + const builderConfig = readFileSync( + `${desktopRoot}/electron-builder.config.cjs`, + "utf8" + ); + const prepareScript = readFileSync( + `${desktopRoot}/scripts/prepare-pandoc-runtime.mjs`, + "utf8" + ); + const windowsArtifact = runtimeManifest.artifacts.find( + (artifact) => + artifact.platform === "win32" && artifact.architecture === "x64" + ); + + expect(runtimeManifest.version).toBe("3.9.0.2"); + expect(windowsArtifact).toMatchObject({ + sha256: + "C97542F2800F446E788D9F74237856D995421AD1BB3CC8324286840C5F272D3A", + licenseFiles: ["COPYING.rtf", "COPYRIGHT.txt"], + files: { + "pandoc.exe": { + bytes: 231056136, + sha256: + "E83F8354C0F507222B5684797B9C5AE766F03889785995D14AAC27816EC456BA" + } + } + }); + expect(desktopManifest.scripts?.["prepare:pandoc"]).toBe( + "node scripts/prepare-pandoc-runtime.mjs" + ); + expect(builderConfig).toContain( + "`.runtime/pandoc/${pandocRuntime.version}/windows-x86_64`" + ); + expect(builderConfig).toContain('"pandoc.exe"'); + expect(builderConfig).toContain('"COPYING.rtf"'); + expect(builderConfig).toContain('"COPYRIGHT.txt"'); + expect(prepareScript).toContain("sha256(downloaded)"); + expect(prepareScript).toContain("smokeTestPandoc"); + expect(prepareScript).toContain('process.argv.includes("--check")'); + }); + it("将内置主题示例作为独立资源复制到发行目录", () => { const builderConfig = readFileSync( `${desktopRoot}/electron-builder.config.cjs`, diff --git a/docs/PANDOC_DISTRIBUTION.md b/docs/PANDOC_DISTRIBUTION.md index bf68a4c..b3cba15 100644 --- a/docs/PANDOC_DISTRIBUTION.md +++ b/docs/PANDOC_DISTRIBUTION.md @@ -1,6 +1,6 @@ # Pandoc 运行时与分发规范 -状态:`v0.6.0` 阶段 2 冻结。 +状态:`v0.6.0` 阶段 2 冻结,Desktop 内置运行时已实现。 ## 1. 固定版本 @@ -26,6 +26,7 @@ DOCX OOXML、Word/WPS 互操作和包体回归。 SHA-256:C97542F2800F446E788D9F74237856D995421AD1BB3CC8324286840C5F272D3A 压缩大小:41,250,425 字节 pandoc.exe:231,056,136 字节 +pandoc.exe SHA-256:E83F8354C0F507222B5684797B9C5AE766F03889785995D14AAC27816EC456BA ``` 发行包来源: @@ -136,8 +137,8 @@ Pandoc 采用 GNU GPL version 2 or later。项目将其作为未修改的独立 项目发布资产计划至少包含: ```text -md-to-pdf-0.6.0-x86_64-Setup.exe -md-to-pdf-0.6.0-x86_64.zip +MorphDoc-0.6.0-x86_64-Setup.exe +MorphDoc-0.6.0-x86_64.zip pandoc-3.9.0.2-source.tar.gz THIRD_PARTY_NOTICES.md ``` @@ -154,17 +155,18 @@ Docker 镜像必须包含许可证、版权声明和源码归档位置说明, 基线为本机已有 `v0.5.1` 正式产物。以下数值使用官方压缩包和解压程序 实测,NSIS 与 ZIP 最终值仍以 `v0.6.0` 构建结果为准。 -| 产物 | v0.5.1 基线 | v0.6.0 估算 | 增量 | +| 产物 | v0.5.1 基线 | v0.6.0 实测/估算 | 增量 | | --- | ---: | ---: | ---: | -| Desktop 运行目录 | 333.98 MiB | 554.33 MiB | 220.35 MiB | -| NSIS 安装包 | 109.38 MiB | 约 148.71 MiB | 约 39.34 MiB | -| Desktop ZIP | 145.13 MiB | 约 184.47 MiB | 约 39.34 MiB | +| Desktop 运行目录 | 333.98 MiB | 556.94 MiB | 222.96 MiB | +| NSIS 安装包 | 109.38 MiB | 133.78 MiB | 24.40 MiB | +| Desktop ZIP | 145.13 MiB | 183.38 MiB | 38.25 MiB | | Docker 内容大小 | 615.91 MiB | 约 770.05 MiB | 154.13 MiB | | Docker 压缩拉取层 | — | — | 约 32.92 MiB | 结论: -- Desktop 解压目录增长明显,但安装包和 ZIP 受压缩后增量约 39 MiB; +- Desktop 解压目录增长明显;实测安装包增加 24.40 MiB,ZIP 增加 + 38.25 MiB; - Docker 运行时内容增长约 154 MiB,镜像传输增量约 33 MiB; - 不采用首次使用时联网下载,以保持内网和离线 Desktop 能力; - 不使用 UPX 或二次修改官方二进制,避免兼容性、杀毒误报和合规风险; @@ -182,6 +184,11 @@ Docker 镜像必须包含许可证、版权声明和源码归档位置说明, - 许可证和版权文件; - 对应源码归档地址。 +Desktop 清单位于 +`packages/docx-engine/src/pandoc-runtime.json`;准备脚本位于 +`apps/desktop/scripts/prepare-pandoc-runtime.mjs`。下载缓存和解压目录 +位于被 Git 忽略的 `apps/desktop/.runtime/`,不会进入源码提交。 + 构建流程必须: 1. 根据清单下载或读取缓存; diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 2b4d9f1..f6591af 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -83,6 +83,13 @@ Windows ZIP 和 Linux amd64 tarball 的 SHA-256 均已核验;Linux 静态程 使用 `MorphDoc`。Electron `appId` 和 Markdown 文件关联 ProgID 保持不变, 并为旧版用户数据、快捷方式和应用注册项提供兼容迁移与清理。 +`v0.6.0` 发布收口阶段 B 已完成 Desktop Pandoc 内置链路:发行构建根据 +受版本控制的清单准备官方 Pandoc `3.9.0.2` Windows x64 ZIP,严格校验 +SHA-256,只提取 `pandoc.exe`、`COPYING.rtf`、`COPYRIGHT.txt` 并生成 +审计清单;打包前执行精确版本、默认模板和最小 DOCX 冒烟。生产环境优先 +使用 `process.resourcesPath` 下的固定程序,无需用户安装 Pandoc,显式 +开发配置和系统 PATH 降级仍保持兼容。 + `v0.6.0` 阶段 3 已完成 DOCX 共享协议:`packages/core` 统一定义请求 Schema、资源、Pandoc capability、错误码、结果、诊断、耗时、MIME 和 安全文件名;`packages/application` 新增 `prepareDocxExport()`,复用 @@ -1420,6 +1427,8 @@ ECharts 第二阶段浏览器与 PDF 验证结果: 差异保留为诊断项,不再误判为样式失败; - 发布收口阶段 A:已完成 v0.6.0 版本、墨呈/MorphDoc 品牌、NSIS 名称、 文件关联兼容和旧版用户数据迁移; +- 发布收口阶段 B:已完成 Desktop 固定 Pandoc 最小运行时的清单、下载 + 哈希门禁、许可证保留、打包接入、离线复验和真实 DOCX 冒烟; - 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 diff --git a/package-lock.json b/package-lock.json index b4450a1..2e385f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "electron": "43.2.0", "electron-builder": "26.15.3", "esbuild": "^0.25.0", + "fflate": "0.8.3", "typescript": "^5.9.3", "vitest": "^4.1.10", "wait-on": "^9.0.1" diff --git a/packages/docx-engine/src/pandoc-runtime-manifest.ts b/packages/docx-engine/src/pandoc-runtime-manifest.ts index 66ff282..8a2d722 100644 --- a/packages/docx-engine/src/pandoc-runtime-manifest.ts +++ b/packages/docx-engine/src/pandoc-runtime-manifest.ts @@ -1,4 +1,5 @@ import { DOCX_PANDOC_VERSION } from "@md-to-pdf/core"; +import runtimeManifest from "./pandoc-runtime.json" with { type: "json" }; export interface PandocRuntimeArtifact { platform: "win32" | "linux"; @@ -8,39 +9,23 @@ export interface PandocRuntimeArtifact { sha256: string; executableRelativePath: string; licenseFiles: readonly string[]; + files?: Readonly< + Record + >; } -export const pandocRuntimeManifest = { - version: DOCX_PANDOC_VERSION, - license: "GPL-2.0-or-later", - projectUrl: "https://pandoc.org/", - sourceArchiveUrl: - "https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-source.tar.gz", - artifacts: [ - { - platform: "win32", - architecture: "x64", - archiveType: "zip", - downloadUrl: - "https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-windows-x86_64.zip", - sha256: - "C97542F2800F446E788D9F74237856D995421AD1BB3CC8324286840C5F272D3A", - executableRelativePath: "pandoc.exe", - licenseFiles: ["COPYING.rtf", "COPYRIGHT.txt"] - }, - { - platform: "linux", - architecture: "x64", - archiveType: "tar.gz", - downloadUrl: - "https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-linux-amd64.tar.gz", - sha256: - "A69ABFABABDA8A56969A254B09F9553A7BE89DDEC00D4E0FE9FD585D71A67508", - executableRelativePath: "bin/pandoc", - licenseFiles: ["COPYING.md", "COPYRIGHT"] - } - ] satisfies readonly PandocRuntimeArtifact[] -} as const; +if (runtimeManifest.version !== DOCX_PANDOC_VERSION) { + throw new Error( + `Pandoc 运行时清单版本 ${runtimeManifest.version} 与 DOCX 协议版本 ${DOCX_PANDOC_VERSION} 不一致` + ); +} + +export const pandocRuntimeManifest = runtimeManifest as Omit< + typeof runtimeManifest, + "artifacts" +> & { + artifacts: readonly PandocRuntimeArtifact[]; +}; export function findPandocRuntimeArtifact( platform: NodeJS.Platform, diff --git a/packages/docx-engine/src/pandoc-runtime.json b/packages/docx-engine/src/pandoc-runtime.json new file mode 100644 index 0000000..8a84a96 --- /dev/null +++ b/packages/docx-engine/src/pandoc-runtime.json @@ -0,0 +1,40 @@ +{ + "version": "3.9.0.2", + "license": "GPL-2.0-or-later", + "projectUrl": "https://pandoc.org/", + "sourceArchiveUrl": "https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-source.tar.gz", + "artifacts": [ + { + "platform": "win32", + "architecture": "x64", + "archiveType": "zip", + "downloadUrl": "https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-windows-x86_64.zip", + "sha256": "C97542F2800F446E788D9F74237856D995421AD1BB3CC8324286840C5F272D3A", + "executableRelativePath": "pandoc.exe", + "licenseFiles": ["COPYING.rtf", "COPYRIGHT.txt"], + "files": { + "pandoc.exe": { + "bytes": 231056136, + "sha256": "E83F8354C0F507222B5684797B9C5AE766F03889785995D14AAC27816EC456BA" + }, + "COPYING.rtf": { + "bytes": 20401, + "sha256": "DA9A2C9D0C3A3327D18684635424A13CDA9FC65D406A0FFC40AE8D016B476669" + }, + "COPYRIGHT.txt": { + "bytes": 9823, + "sha256": "43575D901FD50CC06F49042B82F114667A82C17D63B429E99635AE9430C977C4" + } + } + }, + { + "platform": "linux", + "architecture": "x64", + "archiveType": "tar.gz", + "downloadUrl": "https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-linux-amd64.tar.gz", + "sha256": "A69ABFABABDA8A56969A254B09F9553A7BE89DDEC00D4E0FE9FD585D71A67508", + "executableRelativePath": "bin/pandoc", + "licenseFiles": ["COPYING.md", "COPYRIGHT"] + } + ] +} diff --git a/packages/docx-engine/tsconfig.json b/packages/docx-engine/tsconfig.json index c6bd117..5d28f8d 100644 --- a/packages/docx-engine/tsconfig.json +++ b/packages/docx-engine/tsconfig.json @@ -4,6 +4,7 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", + "resolveJsonModule": true, "rootDir": "src", "outDir": "dist", "declaration": true,