feat: 实现 Windows 可选字体包安装链

This commit is contained in:
SkyJourney
2026-07-31 22:29:23 +08:00
parent ae9fde3ac6
commit 6f62cfebd6
14 changed files with 725 additions and 5 deletions
+82
View File
@@ -1,3 +1,67 @@
!ifdef FONT_PACK_COMPANION_FILE
!ifndef BUILD_UNINSTALLER
!include "FileFunc.nsh"
!include "nsDialogs.nsh"
Var fontPackCompanionAvailable
Var fontPackCompanionCheckbox
Var fontPackCompanionExitCode
Var fontPackCompanionHash
Var fontPackCompanionPath
Var fontPackCompanionSelected
!macro customInit
StrCpy $fontPackCompanionAvailable "0"
StrCpy $fontPackCompanionSelected "0"
StrCpy $fontPackCompanionPath "$EXEDIR\${FONT_PACK_COMPANION_FILE}"
IfFileExists "$fontPackCompanionPath" 0 font_pack_init_done
${StdUtils.HashFile} $fontPackCompanionHash "SHA2-256" "$fontPackCompanionPath"
StrCmp $fontPackCompanionHash "${FONT_PACK_COMPANION_SHA256}" 0 font_pack_init_done
StrCpy $fontPackCompanionAvailable "1"
IfSilent font_pack_silent font_pack_interactive
font_pack_interactive:
StrCpy $fontPackCompanionSelected "1"
Goto font_pack_init_done
font_pack_silent:
${GetParameters} $R0
${GetOptions} $R0 "/WITHFONTPACK=" $R1
StrCmp $R1 "1" 0 font_pack_init_done
StrCpy $fontPackCompanionSelected "1"
font_pack_init_done:
!macroend
Function FontPackCompanionPage
StrCmp $fontPackCompanionAvailable "1" 0 font_pack_page_skip
nsDialogs::Create 1018
Pop $R0
StrCmp $R0 "error" 0 +2
Abort
${NSD_CreateLabel} 0 0 100% 28u "检测到经过完整性校验的可选字体包。安装后,政企公文主题可使用与 PDF 相同的原生中文字体资产。"
Pop $R0
${NSD_CreateCheckbox} 0 38u 100% 14u "安装 ${FONT_PACK_COMPANION_NAME}"
Pop $fontPackCompanionCheckbox
${NSD_Check} $fontPackCompanionCheckbox
nsDialogs::Show
Return
font_pack_page_skip:
Abort
FunctionEnd
Function FontPackCompanionPageLeave
${NSD_GetState} $fontPackCompanionCheckbox $R0
StrCmp $R0 ${BST_CHECKED} 0 font_pack_page_unchecked
StrCpy $fontPackCompanionSelected "1"
Return
font_pack_page_unchecked:
StrCpy $fontPackCompanionSelected "0"
FunctionEnd
!macro customPageAfterChangeDir
Page custom FontPackCompanionPage FontPackCompanionPageLeave
!macroend
!endif
!endif
!macro customInstall !macro customInstall
WriteRegStr SHCTX \ WriteRegStr SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown" \ "Software\Classes\MarkdownPdfExporter.Markdown" \
@@ -43,6 +107,24 @@
"" \ "" \
"$INSTDIR\md-to-pdf.exe" "$INSTDIR\md-to-pdf.exe"
StrCpy $launchLink "$INSTDIR\md-to-pdf.exe" StrCpy $launchLink "$INSTDIR\md-to-pdf.exe"
!ifdef FONT_PACK_COMPANION_FILE
StrCmp $fontPackCompanionAvailable "1" 0 font_pack_install_done
StrCmp $fontPackCompanionSelected "1" 0 font_pack_install_done
${StdUtils.HashFile} $fontPackCompanionHash "SHA2-256" "$fontPackCompanionPath"
StrCmp $fontPackCompanionHash "${FONT_PACK_COMPANION_SHA256}" 0 font_pack_integrity_failed
DetailPrint "正在安装可选字体包:${FONT_PACK_COMPANION_NAME}"
ExecWait '"$fontPackCompanionPath" /S' $fontPackCompanionExitCode
StrCmp $fontPackCompanionExitCode "0" font_pack_install_done
DetailPrint "可选字体包安装失败,退出码:$fontPackCompanionExitCode"
IfSilent font_pack_install_done 0
MessageBox MB_ICONEXCLAMATION|MB_OK "主程序已安装,但可选字体包安装失败(退出码 $fontPackCompanionExitCode)。您可以稍后单独运行字体包安装器。"
Goto font_pack_install_done
font_pack_integrity_failed:
DetailPrint "可选字体包完整性复核失败,已跳过安装。"
IfSilent font_pack_install_done 0
MessageBox MB_ICONEXCLAMATION|MB_OK "可选字体包完整性复核失败,主程序已安装,但字体包已跳过。"
font_pack_install_done:
!endif
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)' System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
!macroend !macroend
+3 -1
View File
@@ -5,6 +5,8 @@ const windowsIcon = path.resolve(
__dirname, __dirname,
"../../logos/desktop/windows/app.ico" "../../logos/desktop/windows/app.ico"
); );
const nsisInclude = process.env.MD_TO_PDF_NSIS_INCLUDE?.trim() ||
"build/installer.nsh";
module.exports = { module.exports = {
appId: "com.md-to-pdf.desktop", appId: "com.md-to-pdf.desktop",
@@ -71,7 +73,7 @@ module.exports = {
installerIcon: windowsIcon, installerIcon: windowsIcon,
uninstallerIcon: windowsIcon, uninstallerIcon: windowsIcon,
installerLanguages: ["zh_CN"], installerLanguages: ["zh_CN"],
include: "build/installer.nsh", include: nsisInclude,
artifactName: `md-to-pdf-${version}-x86_64-Setup.\${ext}` artifactName: `md-to-pdf-${version}-x86_64-Setup.\${ext}`
} }
}; };
+22
View File
@@ -58,6 +58,28 @@ describe("桌面发行构建链", () => {
expect(builderConfig).toContain('to: "samples"'); expect(builderConfig).toContain('to: "samples"');
}); });
it("仅在组合构建时接入经过哈希校验的同目录字体包", () => {
const builderConfig = readFileSync(
`${desktopRoot}/electron-builder.config.cjs`,
"utf8"
);
const installerInclude = readFileSync(
`${desktopRoot}/build/installer.nsh`,
"utf8"
);
const companionBuild = readFileSync(
`${projectRoot}/scripts/build-desktop-with-font-pack.mjs`,
"utf8"
);
expect(builderConfig).toContain("MD_TO_PDF_NSIS_INCLUDE");
expect(installerInclude).toContain("FONT_PACK_COMPANION_SHA256");
expect(installerInclude).toContain("StdUtils.HashFile");
expect(installerInclude).toContain("/WITHFONTPACK=");
expect(companionBuild).toContain("createDesktopCompanionInclude");
expect(companionBuild).toContain("MD_TO_PDF_NSIS_INCLUDE");
});
it("将 DOCX Lua Filter 复制到桌面主进程资源目录", () => { it("将 DOCX Lua Filter 复制到桌面主进程资源目录", () => {
const buildScript = readFileSync( const buildScript = readFileSync(
`${desktopRoot}/scripts/build-main.mjs`, `${desktopRoot}/scripts/build-main.mjs`,
+27 -2
View File
@@ -323,6 +323,29 @@ FP3 只处理独立 NSIS 字体包、Docker 可选挂载及其安装/升级/卸
本阶段未修改 NSIS 或 Docker;下一步 FP3-B 实现独立用户级字体包安装、 本阶段未修改 NSIS 或 Docker;下一步 FP3-B 实现独立用户级字体包安装、
升级、卸载和主安装器同目录可选入口。 升级、卸载和主安装器同目录可选入口。
阶段 12D-FP3-B 已实现 Windows 独立字体包安装与 Desktop 同目录可选入口。
字体包构建器复用 electron-builder `26.15.3` 锁定并校验的 NSIS 3.0.4.1
不要求系统安装 NSIS,也不引入第二套 Electron。独立安装器以当前用户权限
将完整 `mdtp-serif-sc` ID 安装到
`%LOCALAPPDATA%/md-to-pdf/font-packs/`,不注册系统字体;安装使用暂存、
备份、原子提升和失败回滚,同版本重装幂等,独立卸载只清理该 ID。隔离
根目录验收中,首次静默安装、同版本重装和卸载退出码均为 0,注册器复核
的字体包指纹仍为
`67adac137fa74ca54b4ab07dd584ae87657a1aec413b7f125141c5351fa5b89d`
主安装器通过生成的 NSIS include 绑定同目录字体包安装器的精确文件名与
SHA-256;交互模式仅在文件存在且哈希正确时显示默认勾选项,并在执行前
再次复核哈希。静默主安装默认跳过,只有 `/WITHFONTPACK=1` 才安装;字体
包失败不会回滚主程序。统一组合命令为
`npm run desktop:make:with-font-pack`,普通 Desktop 构建不启用伴随逻辑。
真实组合产物中,主安装器为 115,328,133 字节,独立字体包安装器为
24,489,062 字节;桌面解包目录未包含字体二进制或字体包安装器。Windows
实机安装向导确认可选页位于安装目录页之后,说明文字完整且字体包默认
勾选;未执行主程序重装。全仓 465 项测试、类型检查、生产构建和组合发行
构建通过。下一步 FP3-C 实现 Docker 可选字体包挂载与部署门禁。
本阶段按版本策略未提前修改应用的 `0.5.1` 版本号;正式 `v0.6.0` 发布
提交升级版本后,字体包才满足其 `minimumAppVersion=0.6.0` 运行时门禁。
## 2. 已完成 ## 2. 已完成
### 2.1 项目骨架 ### 2.1 项目骨架
@@ -1279,8 +1302,10 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
- 阶段 12D-FP2:已完成 Preview/PDF WOFF2 与 DOCX 静态 TrueType 的 - 阶段 12D-FP2:已完成 Preview/PDF WOFF2 与 DOCX 静态 TrueType 的
同源字体解析、跨端受限资源入口、缓存指纹、降级诊断和真实字体包验证; 同源字体解析、跨端受限资源入口、缓存指纹、降级诊断和真实字体包验证;
- 阶段 12D-FP3-A:已完成独立字体包构建器、冻结配方、许可证与来源追踪、 - 阶段 12D-FP3-A:已完成独立字体包构建器、冻结配方、许可证与来源追踪、
同版本不可变门禁和真实资产构建;下一步 FP3-B 实现独立 NSIS 字体包, 同版本不可变门禁和真实资产构建;
FP3-C 再实现 Docker 可选挂载,最终由 R4 完成 14 套主题深度视觉回归; - 阶段 12D-FP3-B:已完成独立用户级 NSIS 字体包、原子安装/升级/卸载、
主安装器同目录哈希校验和显式静默开关;下一步 FP3-C 实现 Docker 可选
挂载,最终由 R4 完成 14 套主题深度视觉回归;
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。 - 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
+49
View File
@@ -45,3 +45,52 @@ root/<pack-id>/<semver>/font-pack.json
当前 `mdtp-serif-sc@1.0.0` 只替换 `FandolSong` 当前 `mdtp-serif-sc@1.0.0` 只替换 `FandolSong`
`Mdpdf Fandol Song` 的 400/700 字重,不匹配 `SimSun` 等通用别名,避免 `Mdpdf Fandol Song` 的 400/700 字重,不匹配 `SimSun` 等通用别名,避免
错误替换仿宋、黑体、楷体或普通外部主题字体。 错误替换仿宋、黑体、楷体或普通外部主题字体。
## Windows 独立安装器
发布人员可以使用 electron-builder 锁定并校验的 NSIS 工具链构建独立
字体包安装器,不需要额外安装系统 NSIS:
```powershell
npm run build:font-pack:windows
```
默认产物为:
```text
output/font-packs/windows/
└── md-to-pdf-font-pack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe
```
安装器使用当前用户权限,将整个 `mdtp-serif-sc` ID 安装到:
```text
%LOCALAPPDATA%/md-to-pdf/font-packs/mdtp-serif-sc/1.0.0/
```
它不向 Windows 系统字体目录注册字体。同一版本重装保持幂等,升级时先把
该 ID 的现有目录移入备份,再原子提升新版本;失败会回滚。独立卸载项只
删除安装器拥有的 `mdtp-serif-sc` ID,不影响其他字体包 ID。安装器支持
`/S` 静默安装和卸载。
## Desktop 伴随发行
组合构建命令为:
```powershell
npm run desktop:make:with-font-pack
```
该命令把主安装器和字体包安装器作为两个同目录文件发行。主安装器只编译
字体包文件名与 SHA-256,不嵌入字体二进制;仅当同目录文件存在且哈希完全
一致时,交互安装向导才显示默认勾选的字体包选项。字体包失败不回滚已安装
的主程序。
主安装器静默运行时默认不安装伴随字体包;只有显式传入以下参数才会安装:
```powershell
md-to-pdf-<version>-x86_64-Setup.exe /S /WITHFONTPACK=1
```
普通 `npm run make -w @md-to-pdf/desktop` 不启用伴随逻辑,避免本地残留的
生成文件影响常规桌面构建。
+132
View File
@@ -0,0 +1,132 @@
Unicode true
RequestExecutionLevel user
SetCompressor /SOLID lzma
SetCompressorDictSize 64
!include "MUI2.nsh"
!include "LogicLib.nsh"
!ifndef PACK_DIRECTORY
!error "PACK_DIRECTORY is required"
!endif
!ifndef PACK_ID
!error "PACK_ID is required"
!endif
!ifndef PACK_VERSION
!error "PACK_VERSION is required"
!endif
!ifndef PACK_NAME
!error "PACK_NAME is required"
!endif
!ifndef PACK_LICENSE_FILE
!error "PACK_LICENSE_FILE is required"
!endif
!ifndef OUTPUT_FILE
!error "OUTPUT_FILE is required"
!endif
!ifndef FONT_PACK_ROOT
!define FONT_PACK_ROOT "$LOCALAPPDATA\md-to-pdf\font-packs"
!endif
!ifndef MANAGER_ROOT
!define MANAGER_ROOT "$LOCALAPPDATA\md-to-pdf\font-pack-installers"
!endif
Name "Markdown PDF 导出器字体包 - ${PACK_NAME}"
OutFile "${OUTPUT_FILE}"
InstallDir "${MANAGER_ROOT}\${PACK_ID}"
ShowInstDetails show
ShowUninstDetails show
BrandingText "Markdown PDF 导出器"
!ifdef ICON_FILE
Icon "${ICON_FILE}"
UninstallIcon "${ICON_FILE}"
!define MUI_ICON "${ICON_FILE}"
!define MUI_UNICON "${ICON_FILE}"
!endif
!define MUI_ABORTWARNING
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_LICENSE "${PACK_LICENSE_FILE}"
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "SimpChinese"
VIProductVersion "1.0.0.0"
VIAddVersionKey /LANG=${LANG_SIMPCHINESE} "ProductName" "Markdown PDF 导出器字体包"
VIAddVersionKey /LANG=${LANG_SIMPCHINESE} "FileDescription" "${PACK_NAME}"
VIAddVersionKey /LANG=${LANG_SIMPCHINESE} "FileVersion" "${PACK_VERSION}"
VIAddVersionKey /LANG=${LANG_SIMPCHINESE} "LegalCopyright" "Copyright © YIXIONG Tech.ltd"
Section "安装字体包" SEC_FONT_PACK
SetShellVarContext current
StrCpy $R0 "${FONT_PACK_ROOT}\.installing-${PACK_ID}"
StrCpy $R1 "${FONT_PACK_ROOT}\${PACK_ID}"
StrCpy $R2 "${FONT_PACK_ROOT}\.backup-${PACK_ID}"
CreateDirectory "${FONT_PACK_ROOT}"
RMDir /r "$R0"
RMDir /r "$R2"
CreateDirectory "$R0\${PACK_ID}\${PACK_VERSION}"
SetOutPath "$R0\${PACK_ID}\${PACK_VERSION}"
File /r "${PACK_DIRECTORY}\*.*"
SetOutPath "$TEMP"
IfFileExists "$R0\${PACK_ID}\${PACK_VERSION}\font-pack.json" +3 0
RMDir /r "$R0"
Abort "字体包暂存校验失败:缺少 font-pack.json"
ClearErrors
IfFileExists "$R1\*.*" 0 promote_pack
Rename "$R1" "$R2"
IfErrors install_failed
promote_pack:
ClearErrors
Rename "$R0\${PACK_ID}" "$R1"
IfErrors rollback_pack
RMDir /r "$R0"
RMDir /r "$R2"
SetOutPath "$INSTDIR"
WriteUninstaller "$INSTDIR\Uninstall.exe"
FileOpen $R3 "$INSTDIR\installed-version.txt" w
FileWrite $R3 "${PACK_VERSION}$\r$\n"
FileClose $R3
!ifdef REGISTER_UNINSTALL
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\md-to-pdf-font-pack-${PACK_ID}" "DisplayName" "Markdown PDF 导出器字体包 - ${PACK_NAME}"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\md-to-pdf-font-pack-${PACK_ID}" "DisplayVersion" "${PACK_VERSION}"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\md-to-pdf-font-pack-${PACK_ID}" "Publisher" "YIXIONG Tech.ltd"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\md-to-pdf-font-pack-${PACK_ID}" "InstallLocation" "$INSTDIR"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\md-to-pdf-font-pack-${PACK_ID}" "UninstallString" '"$INSTDIR\Uninstall.exe"'
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\md-to-pdf-font-pack-${PACK_ID}" "NoModify" 1
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\md-to-pdf-font-pack-${PACK_ID}" "NoRepair" 1
!endif
Goto install_done
rollback_pack:
RMDir /r "$R1"
IfFileExists "$R2\*.*" 0 install_failed
Rename "$R2" "$R1"
install_failed:
RMDir /r "$R0"
Abort "字体包安装失败,原版本已保留。"
install_done:
SectionEnd
Section "Uninstall"
SetShellVarContext current
RMDir /r "${FONT_PACK_ROOT}\${PACK_ID}"
RMDir /r "${FONT_PACK_ROOT}\.installing-${PACK_ID}"
RMDir /r "${FONT_PACK_ROOT}\.backup-${PACK_ID}"
!ifdef REGISTER_UNINSTALL
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\md-to-pdf-font-pack-${PACK_ID}"
!endif
Delete "$INSTDIR\installed-version.txt"
Delete "$INSTDIR\Uninstall.exe"
RMDir "$INSTDIR"
SectionEnd
+1 -1
View File
@@ -5631,7 +5631,6 @@
"integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "26.15.3", "app-builder-lib": "26.15.3",
"builder-util": "26.15.3", "builder-util": "26.15.3",
@@ -10037,6 +10036,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"app-builder-lib": "26.15.3",
"vitest": "^4.1.10" "vitest": "^4.1.10"
} }
}, },
+2
View File
@@ -28,6 +28,8 @@
"desktop:package": "npm run package -w @md-to-pdf/desktop", "desktop:package": "npm run package -w @md-to-pdf/desktop",
"build:font-pack": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/font-pack-builder && node packages/font-pack-builder/dist/cli.js build", "build:font-pack": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/font-pack-builder && node packages/font-pack-builder/dist/cli.js build",
"verify:font-pack": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/font-pack-builder && node packages/font-pack-builder/dist/cli.js verify", "verify:font-pack": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/font-pack-builder && node packages/font-pack-builder/dist/cli.js verify",
"build:font-pack:windows": "npm run verify:font-pack && node packages/font-pack-builder/dist/cli.js windows-installer",
"desktop:make:with-font-pack": "npm run build:font-pack:windows && node scripts/build-desktop-with-font-pack.mjs",
"theme:import-typora": "node scripts/import-typora-theme.mjs", "theme:import-typora": "node scripts/import-typora-theme.mjs",
"verify:docx-reference": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:pandoc -w @md-to-pdf/docx-engine", "verify:docx-reference": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:pandoc -w @md-to-pdf/docx-engine",
"verify:docx-conversion": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine", "verify:docx-conversion": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine",
+1
View File
@@ -26,6 +26,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"app-builder-lib": "26.15.3",
"vitest": "^4.1.10" "vitest": "^4.1.10"
} }
} }
+26 -1
View File
@@ -1,5 +1,6 @@
import { resolve } from "node:path"; import { resolve } from "node:path";
import { buildFontPack, verifyFontPack } from "./builder.js"; import { buildFontPack, verifyFontPack } from "./builder.js";
import { buildWindowsFontPackInstaller } from "./windows-installer.js";
function argumentsMap(values: string[]) { function argumentsMap(values: string[]) {
const result = new Map<string, string>(); const result = new Map<string, string>();
@@ -61,7 +62,31 @@ async function main() {
packVersion: option(values, "pack-version", "1.0.0") packVersion: option(values, "pack-version", "1.0.0")
}); });
} }
throw new Error("命令必须是 build 或 verify"); if (command === "windows-installer") {
return buildWindowsFontPackInstaller({
packRoot: resolve(option(values, "root", "output/font-packs/root")),
appVersion: option(values, "app-version", "0.6.0"),
packId: option(values, "pack-id", "mdtp-serif-sc"),
packVersion: option(values, "pack-version", "1.0.0"),
scriptPath: resolve(
option(values, "script", "font-packs/windows/font-pack-installer.nsi")
),
outputDirectory: resolve(
option(values, "output", "output/font-packs/windows")
),
iconPath: resolve(
option(values, "icon", "logos/desktop/windows/app.ico")
),
reportPath: resolve(
option(
values,
"report",
"output/font-packs/windows-installer-report.json"
)
)
});
}
throw new Error("命令必须是 build、verify 或 windows-installer");
} }
main() main()
+1
View File
@@ -1,2 +1,3 @@
export * from "./builder.js"; export * from "./builder.js";
export * from "./recipe.js"; export * from "./recipe.js";
export * from "./windows-installer.js";
@@ -0,0 +1,219 @@
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import { lstat, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { getMakeNsisPath } from "app-builder-lib/out/toolsets/windows.js";
import { fontPackManifestSchema } from "@md-to-pdf/font-pack-registry";
import { verifyFontPack } from "./builder.js";
export const WINDOWS_FONT_PACK_ARCHITECTURE = "x86_64";
export interface BuildWindowsFontPackInstallerOptions {
packRoot: string;
appVersion: string;
packId: string;
packVersion: string;
scriptPath: string;
outputDirectory: string;
iconPath?: string;
reportPath?: string;
fontPackRoot?: string;
managerRoot?: string;
registerUninstall?: boolean;
}
export interface WindowsFontPackInstallerReport {
packId: string;
packVersion: string;
artifactPath: string;
artifactName: string;
bytes: number;
sha256: string;
nsisPath: string;
}
export interface CreateDesktopCompanionIncludeOptions {
installerPath: string;
baseIncludePath: string;
outputPath: string;
displayName: string;
}
function sha256(content: Uint8Array) {
return createHash("sha256").update(content).digest("hex").toUpperCase();
}
function assertDefineValue(value: string, label: string) {
if (value.includes('"') || value.includes("\r") || value.includes("\n")) {
throw new Error(`${label} 包含 NSIS 定义不支持的字符`);
}
return value;
}
function define(name: string, value: string) {
return `/D${name}=${assertDefineValue(value, name)}`;
}
export function windowsFontPackInstallerName(packId: string, version: string) {
return `md-to-pdf-font-pack-${packId}-${version}-${WINDOWS_FONT_PACK_ARCHITECTURE}-Setup.exe`;
}
export function createWindowsFontPackInstallerArguments(options: {
packDirectory: string;
packId: string;
packVersion: string;
packName: string;
licensePath: string;
outputPath: string;
scriptPath: string;
iconPath?: string;
fontPackRoot?: string;
managerRoot?: string;
registerUninstall?: boolean;
}) {
const argumentsList = [
"/V2",
"/INPUTCHARSET",
"UTF8",
define("PACK_DIRECTORY", resolve(options.packDirectory)),
define("PACK_ID", options.packId),
define("PACK_VERSION", options.packVersion),
define("PACK_NAME", options.packName),
define("PACK_LICENSE_FILE", resolve(options.licensePath)),
define("OUTPUT_FILE", resolve(options.outputPath))
];
if (options.iconPath) {
argumentsList.push(define("ICON_FILE", resolve(options.iconPath)));
}
if (options.fontPackRoot) {
argumentsList.push(define("FONT_PACK_ROOT", resolve(options.fontPackRoot)));
}
if (options.managerRoot) {
argumentsList.push(define("MANAGER_ROOT", resolve(options.managerRoot)));
}
if (options.registerUninstall !== false) {
argumentsList.push("/DREGISTER_UNINSTALL");
}
argumentsList.push(resolve(options.scriptPath));
return argumentsList;
}
async function runProcess(
executable: string,
argumentsList: string[],
environment?: NodeJS.ProcessEnv
) {
await new Promise<void>((resolvePromise, reject) => {
const child = spawn(executable, argumentsList, {
env: environment,
stdio: "inherit",
windowsHide: true
});
child.once("error", reject);
child.once("exit", (code, signal) => {
if (code === 0) {
resolvePromise();
} else {
reject(
new Error(
`NSIS 编译失败:${signal ? `signal ${signal}` : `exit ${code ?? "unknown"}`}`
)
);
}
});
});
}
export async function buildWindowsFontPackInstaller(
options: BuildWindowsFontPackInstallerOptions
): Promise<WindowsFontPackInstallerReport> {
if (process.platform !== "win32") {
throw new Error("字体包 NSIS 安装器只能在 Windows 上构建");
}
const verified = await verifyFontPack({
root: options.packRoot,
appVersion: options.appVersion,
packId: options.packId,
packVersion: options.packVersion
});
const packDirectory = resolve(verified.outputDirectory);
const manifestPath = join(packDirectory, "font-pack.json");
const manifest = fontPackManifestSchema.parse(
JSON.parse(await readFile(manifestPath, "utf8")) as unknown
);
const scriptPath = resolve(options.scriptPath);
const scriptStatus = await lstat(scriptPath);
if (!scriptStatus.isFile() || scriptStatus.isSymbolicLink()) {
throw new Error("字体包 NSIS 脚本不是受信任的普通文件");
}
const outputDirectory = resolve(options.outputDirectory);
await mkdir(outputDirectory, { recursive: true });
const artifactName = windowsFontPackInstallerName(
options.packId,
options.packVersion
);
const artifactPath = join(outputDirectory, artifactName);
const nsis = await getMakeNsisPath(null);
const argumentsList = createWindowsFontPackInstallerArguments({
packDirectory,
packId: manifest.id,
packVersion: manifest.version,
packName: manifest.name,
licensePath: join(packDirectory, manifest.licenseFile),
outputPath: artifactPath,
scriptPath,
...(options.iconPath ? { iconPath: options.iconPath } : {}),
...(options.fontPackRoot ? { fontPackRoot: options.fontPackRoot } : {}),
...(options.managerRoot ? { managerRoot: options.managerRoot } : {}),
...(options.registerUninstall !== undefined
? { registerUninstall: options.registerUninstall }
: {})
});
await runProcess(nsis.path, argumentsList, {
...process.env,
...nsis.env
});
const artifact = await readFile(artifactPath);
const artifactStatus = await stat(artifactPath);
const report: WindowsFontPackInstallerReport = {
packId: manifest.id,
packVersion: manifest.version,
artifactPath,
artifactName,
bytes: artifactStatus.size,
sha256: sha256(artifact),
nsisPath: nsis.path
};
if (options.reportPath) {
const reportPath = resolve(options.reportPath);
await mkdir(dirname(reportPath), { recursive: true });
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
}
return report;
}
export async function createDesktopCompanionInclude(
options: CreateDesktopCompanionIncludeOptions
) {
const installerPath = resolve(options.installerPath);
const status = await lstat(installerPath);
if (!status.isFile() || status.isSymbolicLink()) {
throw new Error("字体包伴随安装器不是受信任的普通文件");
}
const installerHash = sha256(await readFile(installerPath));
const include = [
`!define FONT_PACK_COMPANION_FILE "${assertDefineValue(basename(installerPath), "伴随安装器文件名")}"`,
`!define FONT_PACK_COMPANION_SHA256 "${installerHash}"`,
`!define FONT_PACK_COMPANION_NAME "${assertDefineValue(options.displayName, "伴随安装器名称")}"`,
`!include "${assertDefineValue(resolve(options.baseIncludePath), "基础安装脚本路径")}"`,
""
].join("\n");
const outputPath = resolve(options.outputPath);
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, include, "utf8");
return {
outputPath,
installerPath,
installerHash
};
}
@@ -0,0 +1,89 @@
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import {
createDesktopCompanionInclude,
createWindowsFontPackInstallerArguments,
windowsFontPackInstallerName
} from "../src/index.js";
const temporaryDirectories: string[] = [];
const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url));
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
);
});
describe("Windows 字体包安装器构建", () => {
it("保持用户级原子安装和精确卸载边界", async () => {
const script = await readFile(
join(repositoryRoot, "font-packs", "windows", "font-pack-installer.nsi"),
"utf8"
);
expect(script).toContain(
'$LOCALAPPDATA\\md-to-pdf\\font-packs'
);
expect(script).toContain(".installing-${PACK_ID}");
expect(script).toContain(".backup-${PACK_ID}");
expect(script).toContain('Rename "$R0\\${PACK_ID}" "$R1"');
expect(script).toContain(
'RMDir /r "${FONT_PACK_ROOT}\\${PACK_ID}"'
);
expect(script).not.toContain("Windows\\Fonts");
});
it("生成稳定发行文件名和完整 NSIS 定义", () => {
expect(windowsFontPackInstallerName("mdtp-serif-sc", "1.0.0")).toBe(
"md-to-pdf-font-pack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe"
);
const args = createWindowsFontPackInstallerArguments({
packDirectory: "C:/pack",
packId: "mdtp-serif-sc",
packVersion: "1.0.0",
packName: "测试字体包",
licensePath: "C:/pack/LICENSE.txt",
outputPath: "C:/out/setup.exe",
scriptPath: "C:/repo/font-pack-installer.nsi",
registerUninstall: false
});
expect(args).toContain("/DPACK_ID=mdtp-serif-sc");
expect(args).toContain("/DPACK_VERSION=1.0.0");
expect(args.slice(0, 3)).toEqual(["/V2", "/INPUTCHARSET", "UTF8"]);
expect(args).not.toContain("/DREGISTER_UNINSTALL");
expect(args.at(-1)).toMatch(/font-pack-installer\.nsi$/u);
});
it("生成绑定同目录文件名与 SHA-256 的桌面 include", async () => {
const root = await mkdtemp(join(tmpdir(), "mdtp-font-companion-"));
temporaryDirectories.push(root);
const installer = join(root, "font-setup.exe");
const baseInclude = join(root, "installer.nsh");
const output = join(root, "generated", "companion.nsh");
await Promise.all([
writeFile(installer, "installer", "utf8"),
writeFile(baseInclude, "!macro customInstall\n!macroend\n", "utf8")
]);
const result = await createDesktopCompanionInclude({
installerPath: installer,
baseIncludePath: baseInclude,
outputPath: output,
displayName: "测试字体包"
});
const expected = createHash("sha256")
.update("installer")
.digest("hex")
.toUpperCase();
const content = await readFile(output, "utf8");
expect(result.installerHash).toBe(expected);
expect(content).toContain('FONT_PACK_COMPANION_FILE "font-setup.exe"');
expect(content).toContain(`FONT_PACK_COMPANION_SHA256 "${expected}"`);
expect(content).toContain(baseInclude);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { copyFile, mkdir, readFile } from "node:fs/promises";
import { spawn } from "node:child_process";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createDesktopCompanionInclude } from "../packages/font-pack-builder/dist/index.js";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const desktopRoot = join(repositoryRoot, "apps", "desktop");
const desktopManifest = JSON.parse(
await readFile(join(desktopRoot, "package.json"), "utf8")
);
const fontInstallerName =
"md-to-pdf-font-pack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe";
const fontInstallerPath = join(
repositoryRoot,
"output",
"font-packs",
"windows",
fontInstallerName
);
const generatedInclude = join(
repositoryRoot,
"output",
"font-packs",
"desktop-companion.nsh"
);
await createDesktopCompanionInclude({
installerPath: fontInstallerPath,
baseIncludePath: join(desktopRoot, "build", "installer.nsh"),
outputPath: generatedInclude,
displayName: "MdTP 中文宋体兼容包 1.0.0"
});
const npmCli =
process.env.npm_execpath ||
join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
await new Promise((resolvePromise, reject) => {
const child = spawn(
process.execPath,
[npmCli, "run", "make", "-w", "@md-to-pdf/desktop"],
{
cwd: repositoryRoot,
env: {
...process.env,
MD_TO_PDF_NSIS_INCLUDE: generatedInclude
},
stdio: "inherit",
windowsHide: true
}
);
child.once("error", reject);
child.once("exit", (code, signal) => {
if (code === 0) {
resolvePromise();
} else {
reject(
new Error(
`桌面安装器构建失败:${signal ? `signal ${signal}` : `exit ${code ?? "unknown"}`}`
)
);
}
});
});
const desktopOutput = join(desktopRoot, "out", `v${desktopManifest.version}`);
await mkdir(desktopOutput, { recursive: true });
await copyFile(fontInstallerPath, join(desktopOutput, fontInstallerName));
process.stdout.write(
`主安装器与字体包伴随安装器已生成:${desktopOutput}\n`
);