feat: 内置 Desktop Pandoc 最小运行时
This commit is contained in:
@@ -16,4 +16,5 @@ playwright-report/
|
||||
test-results/
|
||||
output/
|
||||
/apps/desktop/out/
|
||||
/apps/desktop/.runtime/
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -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;当前发行包
|
||||
未配置代码签名。
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Apache License 2.0 仅是未来公开 GitHub 版本的候选许可证。当前
|
||||
|
||||
## Pandoc
|
||||
|
||||
`v0.6.0` 计划将 Pandoc 作为独立可执行程序用于 DOCX 转换。
|
||||
`v0.6.0` 将未经修改的 Pandoc 作为独立可执行程序用于 DOCX 转换。
|
||||
|
||||
- 项目:Pandoc
|
||||
- 版权:John MacFarlane 及贡献者
|
||||
|
||||
+14
-2
@@ -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/` 自定义协议加载。
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<string, Uint8Array>;
|
||||
|
||||
export function verifyPandocVersion(
|
||||
executablePath: string,
|
||||
expectedVersion: string
|
||||
): void;
|
||||
|
||||
export function preparePandocRuntime(options?: {
|
||||
checkOnly?: boolean;
|
||||
}): Promise<string>;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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<string, string>) {
|
||||
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()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,17 @@ interface ProductBrand {
|
||||
artifactPrefix: string;
|
||||
}
|
||||
|
||||
interface PandocRuntimeManifest {
|
||||
version: string;
|
||||
artifacts: Array<{
|
||||
platform: string;
|
||||
architecture: string;
|
||||
sha256: string;
|
||||
licenseFiles: string[];
|
||||
files?: Record<string, { bytes: number; sha256: string }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
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<PandocRuntimeManifest>(
|
||||
`${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`,
|
||||
|
||||
@@ -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. 根据清单下载或读取缓存;
|
||||
|
||||
@@ -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 双向互存、外部主题兼容、体积和正式发布验收。
|
||||
|
||||
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
||||
|
||||
Generated
+1
@@ -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"
|
||||
|
||||
@@ -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<string, { bytes: number; sha256: string }>
|
||||
>;
|
||||
}
|
||||
|
||||
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"]
|
||||
if (runtimeManifest.version !== DOCX_PANDOC_VERSION) {
|
||||
throw new Error(
|
||||
`Pandoc 运行时清单版本 ${runtimeManifest.version} 与 DOCX 协议版本 ${DOCX_PANDOC_VERSION} 不一致`
|
||||
);
|
||||
}
|
||||
] satisfies readonly PandocRuntimeArtifact[]
|
||||
} as const;
|
||||
|
||||
export const pandocRuntimeManifest = runtimeManifest as Omit<
|
||||
typeof runtimeManifest,
|
||||
"artifacts"
|
||||
> & {
|
||||
artifacts: readonly PandocRuntimeArtifact[];
|
||||
};
|
||||
|
||||
export function findPandocRuntimeArtifact(
|
||||
platform: NodeJS.Platform,
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"resolveJsonModule": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
|
||||
Reference in New Issue
Block a user