feat: 内置 Desktop Pandoc 最小运行时

This commit is contained in:
SkyJourney
2026-08-02 10:44:40 +08:00
parent 9a2aa3c341
commit 3a8d2c1c2e
16 changed files with 636 additions and 46 deletions
+1
View File
@@ -16,4 +16,5 @@ playwright-report/
test-results/ test-results/
output/ output/
/apps/desktop/out/ /apps/desktop/out/
/apps/desktop/.runtime/
*.tsbuildinfo *.tsbuildinfo
+3 -2
View File
@@ -139,8 +139,9 @@ npm run desktop:package
npm run make -w @md-to-pdf/desktop npm run make -w @md-to-pdf/desktop
``` ```
桌面 `package``make` 会先完整重建内嵌 Web 及全部共享依赖,不能 桌面 `package``make` 会先完整重建内嵌 Web 及全部共享依赖,再准备并
复用陈旧的 `apps/web/dist`。产物位于 `apps/desktop/out/v0.6.0/` 校验固定 Pandoc `3.9.0.2` 最小运行时,不能复用陈旧的
`apps/web/dist`。产物位于 `apps/desktop/out/v0.6.0/`
正式安装包命名为 `MorphDoc-0.6.0-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包 正式安装包命名为 `MorphDoc-0.6.0-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
未配置代码签名。 未配置代码签名。
+1 -1
View File
@@ -11,7 +11,7 @@ Apache License 2.0 仅是未来公开 GitHub 版本的候选许可证。当前
## Pandoc ## Pandoc
`v0.6.0` 计划将 Pandoc 作为独立可执行程序用于 DOCX 转换。 `v0.6.0`未经修改的 Pandoc 作为独立可执行程序用于 DOCX 转换。
- 项目:Pandoc - 项目:Pandoc
- 版权:John MacFarlane 及贡献者 - 版权:John MacFarlane 及贡献者
+14 -2
View File
@@ -43,7 +43,18 @@ npm run make -w @md-to-pdf/desktop
`package``make` 会先调用根级 `build:web-runtime`,完整重建 `package``make` 会先调用根级 `build:web-runtime`,完整重建
Markdown ECharts、Core、Renderer、Application、Preview Engine 和 Web 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 输出到 版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
`apps/desktop/out/v0.6.0/`。该目录被 Git 忽略。公司内部分发以 `apps/desktop/out/v0.6.0/`。该目录被 Git 忽略。公司内部分发以
@@ -63,7 +74,8 @@ NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安
`MorphDoc.exe`。NSIS appId 保持 `com.md-to-pdf.desktop`,确保 `MorphDoc.exe`。NSIS appId 保持 `com.md-to-pdf.desktop`,确保
v0.5.1 覆盖升级沿用原安装身份。 v0.5.1 覆盖升级沿用原安装身份。
当前目录包只包含一套 Electron Chromium,不携带 Playwright Chromium 当前目录包只包含一套 Electron Chromium,不携带 Playwright Chromium
Pandoc 作为未修改的独立程序随 Desktop 内置,通过受控子进程执行。
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过 Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
`mdpdf://bundle/` 自定义协议加载。 `mdpdf://bundle/` 自定义协议加载。
+13
View File
@@ -1,6 +1,9 @@
const path = require("node:path"); const path = require("node:path");
const { version } = require("./package.json"); const { version } = require("./package.json");
const brand = require("../../product.json"); const brand = require("../../product.json");
const pandocRuntime = require(
"../../packages/docx-engine/src/pandoc-runtime.json"
);
const windowsIcon = path.resolve( const windowsIcon = path.resolve(
__dirname, __dirname,
@@ -38,6 +41,16 @@ module.exports = {
to: "samples", to: "samples",
filter: ["**/*"] 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, from: windowsIcon,
to: "app.ico" to: "app.ico"
+5 -2
View File
@@ -10,12 +10,14 @@
"scripts": { "scripts": {
"build": "npm run clean && npm run build:main && npm run build:preload", "build": "npm run clean && npm run build:main && npm run build:preload",
"build:embedded-web": "npm --prefix ../.. run build:web-runtime", "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: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", "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})\"", "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", "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", "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 build && electron-builder --win nsis zip --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", "test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
"verify:docx-theme-styles": "electron scripts/verify-docx-theme-styles.cjs" "verify:docx-theme-styles": "electron scripts/verify-docx-theme-styles.cjs"
@@ -28,6 +30,7 @@
"electron": "43.2.0", "electron": "43.2.0",
"electron-builder": "26.15.3", "electron-builder": "26.15.3",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fflate": "0.8.3",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.1.10", "vitest": "^4.1.10",
"wait-on": "^9.0.1" "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()
);
});
});
+62
View File
@@ -17,6 +17,17 @@ interface ProductBrand {
artifactPrefix: string; 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 desktopRoot = fileURLToPath(new URL("../", import.meta.url));
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url)); const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
@@ -54,11 +65,62 @@ describe("桌面发行构建链", () => {
expect(desktopManifest.scripts?.make).toMatch( expect(desktopManifest.scripts?.make).toMatch(
/^npm run build:embedded-web && / /^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( expect(rootManifest.scripts?.["desktop:package"]).toBe(
"npm run package -w @md-to-pdf/desktop" "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("将内置主题示例作为独立资源复制到发行目录", () => { it("将内置主题示例作为独立资源复制到发行目录", () => {
const builderConfig = readFileSync( const builderConfig = readFileSync(
`${desktopRoot}/electron-builder.config.cjs`, `${desktopRoot}/electron-builder.config.cjs`,
+15 -8
View File
@@ -1,6 +1,6 @@
# Pandoc 运行时与分发规范 # Pandoc 运行时与分发规范
状态:`v0.6.0` 阶段 2 冻结。 状态:`v0.6.0` 阶段 2 冻结Desktop 内置运行时已实现
## 1. 固定版本 ## 1. 固定版本
@@ -26,6 +26,7 @@ DOCX OOXML、Word/WPS 互操作和包体回归。
SHA-256C97542F2800F446E788D9F74237856D995421AD1BB3CC8324286840C5F272D3A SHA-256C97542F2800F446E788D9F74237856D995421AD1BB3CC8324286840C5F272D3A
压缩大小:41,250,425 字节 压缩大小:41,250,425 字节
pandoc.exe231,056,136 字节 pandoc.exe231,056,136 字节
pandoc.exe SHA-256E83F8354C0F507222B5684797B9C5AE766F03889785995D14AAC27816EC456BA
``` ```
发行包来源: 发行包来源:
@@ -136,8 +137,8 @@ Pandoc 采用 GNU GPL version 2 or later。项目将其作为未修改的独立
项目发布资产计划至少包含: 项目发布资产计划至少包含:
```text ```text
md-to-pdf-0.6.0-x86_64-Setup.exe MorphDoc-0.6.0-x86_64-Setup.exe
md-to-pdf-0.6.0-x86_64.zip MorphDoc-0.6.0-x86_64.zip
pandoc-3.9.0.2-source.tar.gz pandoc-3.9.0.2-source.tar.gz
THIRD_PARTY_NOTICES.md THIRD_PARTY_NOTICES.md
``` ```
@@ -154,17 +155,18 @@ Docker 镜像必须包含许可证、版权声明和源码归档位置说明,
基线为本机已有 `v0.5.1` 正式产物。以下数值使用官方压缩包和解压程序 基线为本机已有 `v0.5.1` 正式产物。以下数值使用官方压缩包和解压程序
实测,NSIS 与 ZIP 最终值仍以 `v0.6.0` 构建结果为准。 实测,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 | | Desktop 运行目录 | 333.98 MiB | 556.94 MiB | 222.96 MiB |
| NSIS 安装包 | 109.38 MiB | 约 148.71 MiB | 约 39.34 MiB | | NSIS 安装包 | 109.38 MiB | 133.78 MiB | 24.40 MiB |
| Desktop ZIP | 145.13 MiB | 约 184.47 MiB | 约 39.34 MiB | | Desktop ZIP | 145.13 MiB | 183.38 MiB | 38.25 MiB |
| Docker 内容大小 | 615.91 MiB | 约 770.05 MiB | 154.13 MiB | | Docker 内容大小 | 615.91 MiB | 约 770.05 MiB | 154.13 MiB |
| Docker 压缩拉取层 | — | — | 约 32.92 MiB | | Docker 压缩拉取层 | — | — | 约 32.92 MiB |
结论: 结论:
- Desktop 解压目录增长明显,但安装包和 ZIP 受压缩后增量约 39 MiB; - Desktop 解压目录增长明显;实测安装包增加 24.40 MiB,ZIP 增加
38.25 MiB
- Docker 运行时内容增长约 154 MiB,镜像传输增量约 33 MiB - Docker 运行时内容增长约 154 MiB,镜像传输增量约 33 MiB
- 不采用首次使用时联网下载,以保持内网和离线 Desktop 能力; - 不采用首次使用时联网下载,以保持内网和离线 Desktop 能力;
- 不使用 UPX 或二次修改官方二进制,避免兼容性、杀毒误报和合规风险; - 不使用 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. 根据清单下载或读取缓存; 1. 根据清单下载或读取缓存;
+9
View File
@@ -83,6 +83,13 @@ Windows ZIP 和 Linux amd64 tarball 的 SHA-256 均已核验;Linux 静态程
使用 `MorphDoc`。Electron `appId` 和 Markdown 文件关联 ProgID 保持不变, 使用 `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` 统一定义请求 `v0.6.0` 阶段 3 已完成 DOCX 共享协议:`packages/core` 统一定义请求
Schema、资源、Pandoc capability、错误码、结果、诊断、耗时、MIME 和 Schema、资源、Pandoc capability、错误码、结果、诊断、耗时、MIME 和
安全文件名;`packages/application` 新增 `prepareDocxExport()`,复用 安全文件名;`packages/application` 新增 `prepareDocxExport()`,复用
@@ -1420,6 +1427,8 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
差异保留为诊断项,不再误判为样式失败; 差异保留为诊断项,不再误判为样式失败;
- 发布收口阶段 A:已完成 v0.6.0 版本、墨呈/MorphDoc 品牌、NSIS 名称、 - 发布收口阶段 A:已完成 v0.6.0 版本、墨呈/MorphDoc 品牌、NSIS 名称、
文件关联兼容和旧版用户数据迁移; 文件关联兼容和旧版用户数据迁移;
- 发布收口阶段 B:已完成 Desktop 固定 Pandoc 最小运行时的清单、下载
哈希门禁、许可证保留、打包接入、离线复验和真实 DOCX 冒烟;
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。 - 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
+1
View File
@@ -31,6 +31,7 @@
"electron": "43.2.0", "electron": "43.2.0",
"electron-builder": "26.15.3", "electron-builder": "26.15.3",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fflate": "0.8.3",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.1.10", "vitest": "^4.1.10",
"wait-on": "^9.0.1" "wait-on": "^9.0.1"
@@ -1,4 +1,5 @@
import { DOCX_PANDOC_VERSION } from "@md-to-pdf/core"; import { DOCX_PANDOC_VERSION } from "@md-to-pdf/core";
import runtimeManifest from "./pandoc-runtime.json" with { type: "json" };
export interface PandocRuntimeArtifact { export interface PandocRuntimeArtifact {
platform: "win32" | "linux"; platform: "win32" | "linux";
@@ -8,39 +9,23 @@ export interface PandocRuntimeArtifact {
sha256: string; sha256: string;
executableRelativePath: string; executableRelativePath: string;
licenseFiles: readonly string[]; licenseFiles: readonly string[];
files?: Readonly<
Record<string, { bytes: number; sha256: string }>
>;
} }
export const pandocRuntimeManifest = { if (runtimeManifest.version !== DOCX_PANDOC_VERSION) {
version: DOCX_PANDOC_VERSION, throw new Error(
license: "GPL-2.0-or-later", `Pandoc 运行时清单版本 ${runtimeManifest.version} 与 DOCX 协议版本 ${DOCX_PANDOC_VERSION} 不一致`
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: [ export const pandocRuntimeManifest = runtimeManifest as Omit<
{ typeof runtimeManifest,
platform: "win32", "artifacts"
architecture: "x64", > & {
archiveType: "zip", artifacts: readonly PandocRuntimeArtifact[];
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;
export function findPandocRuntimeArtifact( export function findPandocRuntimeArtifact(
platform: NodeJS.Platform, 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"]
}
]
}
+1
View File
@@ -4,6 +4,7 @@
"target": "ES2022", "target": "ES2022",
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"resolveJsonModule": true,
"rootDir": "src", "rootDir": "src",
"outDir": "dist", "outDir": "dist",
"declaration": true, "declaration": true,