release: 发布 v0.4.5

新增能力:Web 与 Desktop 支持新建和保存 Markdown;桌面端注册 .md/.markdown 打开程序,支持单实例文件转交、自定义主题目录以及窗口位置、尺寸和最大化状态的延迟原子持久化与屏幕越界修正。

界面与兼容性:保留打开 Markdown 为独立主操作,将新建、保存和主题目录收纳到更多菜单;Web 移除本地素材目录入口并明确默认不支持本地素材;新文档默认使用一级标题或首行合法化生成文件名,可不保存直接导出 PDF。

渲染修复:图片、Mermaid 和 ECharts 按文档顺序串行执行媒体分页回填,每个媒体元素至多重排一次;图片以限宽后的高度作为缩放基准,只缩放媒体主体并保持标题尺寸;ECharts 冻结为 SVG 图片后参与统一分页。

示例与文档:默认示例仅保留一张网络图片,增加有效 Base64 横图、竖图、边界图片及多尺寸 Mermaid/ECharts;更新根 README、Desktop README、部署文档、四个 packages README、PROGRESS 和 AGENTS 发布规范;登记 v0.4.6 超链接导航问题。

部署与验证:构建并运行 yixiong/md-to-pdf:v0.4.5 正式镜像,Compose 健康且 PDF 冒烟无 Mermaid/ECharts 错误;生成 NSIS 安装包和免安装 ZIP,本机已升级到 NSIS v0.4.5 并清理旧 Squirrel 安装;202 项测试、类型检查、生产构建和 git diff --check 全部通过。

发布产物:Setup.exe SHA-256 9CBD6362E15AA745185805E753D5A7749434E0E142E6CE0F66463E6A19468F1D;ZIP SHA-256 97B2D44E72F626A3396E5AB5B330FFB7F9BECAF945A1707808C876940A82E850;当前 Windows 产物未配置代码签名。
This commit is contained in:
SkyJourney
2026-07-28 13:31:42 +08:00
parent ac54ce46be
commit 925b0d1485
35 changed files with 3125 additions and 603 deletions
+16 -6
View File
@@ -1,9 +1,19 @@
# Desktop 桌面端
`apps/desktop` `v0.4.0` 新增的 Electron 桌面应用。当前已经完成
Electron 壳、受限 preload、进程内应用服务、原生 Markdown 文件选择
同目录图片解析、隐藏分页窗口、Electron Chromium PDF 输出、原生 PDF
另存为和 Windows 安装包验证
`apps/desktop` 是 Electron 桌面应用。当前已经完成 Electron 壳、受限
preload、进程内应用服务、原生 Markdown 打开与保存、同目录图片解析
本地主题目录、隐藏分页窗口、Electron Chromium PDF 输出和 Windows
安装包。
桌面端会记住上次窗口的位置、尺寸和最大化状态;窗口变化后延迟写入,
异常退出后也能恢复。恢复时会按当前显示器工作区修正位置与尺寸,避免窗口
落在屏幕外。Windows 安装包会注册 `.md``.markdown` 文件关联,
双击文件或使用“打开方式”会复用已有应用窗口并加载文档。
“打开 Markdown”是顶部独立主操作;新建文档、保存 Markdown 和打开
自定义主题目录收纳在“更多”菜单中。新建文档未保存时显示“未命名文档”,
首次保存默认使用一级标题作为文件名;没有一级标题时使用首行文本,并会
自动清理 Windows 非法文件名字符。新文档无需先保存即可直接导出 PDF。
## 开发
@@ -27,8 +37,8 @@ npm run make -w @md-to-pdf/desktop
```
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
`apps/desktop/out/v0.4.4/`。该目录被 Git 忽略。公司内部分发以
`md-to-pdf-0.4.4-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
`apps/desktop/out/v0.4.5/`。该目录被 Git 忽略。公司内部分发以
`md-to-pdf-0.4.5-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
提示。
+44
View File
@@ -1,4 +1,36 @@
!macro customInstall
WriteRegStr SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown" \
"" \
"Markdown 文档"
WriteRegStr SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown\DefaultIcon" \
"" \
'"$INSTDIR\md-to-pdf.exe",0'
WriteRegStr SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown\shell\open\command" \
"" \
'"$INSTDIR\md-to-pdf.exe" "%1"'
WriteRegStr SHCTX \
"Software\Classes\.md\OpenWithProgids" \
"MarkdownPdfExporter.Markdown" \
""
WriteRegStr SHCTX \
"Software\Classes\.markdown\OpenWithProgids" \
"MarkdownPdfExporter.Markdown" \
""
WriteRegStr SHCTX \
"Software\Classes\Applications\md-to-pdf.exe\shell\open\command" \
"" \
'"$INSTDIR\md-to-pdf.exe" "%1"'
WriteRegStr SHCTX \
"Software\Classes\Applications\md-to-pdf.exe\SupportedTypes" \
".md" \
""
WriteRegStr SHCTX \
"Software\Classes\Applications\md-to-pdf.exe\SupportedTypes" \
".markdown" \
""
CreateDirectory "$SMPROGRAMS\Markdown PDF 导出器"
CreateShortCut \
"$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk" \
@@ -11,10 +43,22 @@
"" \
"$INSTDIR\md-to-pdf.exe"
StrCpy $launchLink "$INSTDIR\md-to-pdf.exe"
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
!macroend
!macro customUnInstall
DeleteRegValue SHCTX \
"Software\Classes\.md\OpenWithProgids" \
"MarkdownPdfExporter.Markdown"
DeleteRegValue SHCTX \
"Software\Classes\.markdown\OpenWithProgids" \
"MarkdownPdfExporter.Markdown"
DeleteRegKey SHCTX \
"Software\Classes\Applications\md-to-pdf.exe"
DeleteRegKey SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown"
Delete "$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk"
RMDir "$SMPROGRAMS\Markdown PDF 导出器"
Delete "$DESKTOP\Markdown PDF 导出器.lnk"
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
!macroend
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@md-to-pdf/desktop",
"version": "0.4.4",
"version": "0.4.5",
"private": true,
"productName": "Markdown PDF 导出器",
"description": "Markdown PDF 导出器桌面端",
+24
View File
@@ -2,16 +2,40 @@ import { contextBridge, ipcRenderer } from "electron";
import type { PagedDocumentPayload } from "@md-to-pdf/core";
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
import {
DESKTOP_CONSUME_PENDING_MARKDOWN,
DESKTOP_DISCARD_PENDING_MARKDOWN,
DESKTOP_GET_THEME_CSS,
DESKTOP_GENERATE_PDF,
DESKTOP_LIST_THEMES,
DESKTOP_MARKDOWN_OPENED,
DESKTOP_OPEN_MARKDOWN,
DESKTOP_OPEN_THEME_DIRECTORY,
DESKTOP_REFRESH_THEMES,
DESKTOP_RENDER_MARKDOWN,
DESKTOP_SAVE_MARKDOWN,
DESKTOP_START_NEW_MARKDOWN,
DESKTOP_SAVE_PDF
} from "./channels.js";
contextBridge.exposeInMainWorld("mdToPdfDesktop", {
openMarkdown: () => ipcRenderer.invoke(DESKTOP_OPEN_MARKDOWN),
consumePendingMarkdown: () =>
ipcRenderer.invoke(DESKTOP_CONSUME_PENDING_MARKDOWN),
discardPendingMarkdown: () =>
ipcRenderer.invoke(DESKTOP_DISCARD_PENDING_MARKDOWN),
onMarkdownOpened: (listener: () => void) => {
const handleOpened = () => listener();
ipcRenderer.on(DESKTOP_MARKDOWN_OPENED, handleOpened);
return () =>
ipcRenderer.removeListener(DESKTOP_MARKDOWN_OPENED, handleOpened);
},
startNewMarkdown: () =>
ipcRenderer.invoke(DESKTOP_START_NEW_MARKDOWN),
saveMarkdown: (fileName: string, markdown: string) =>
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN, fileName, markdown),
openThemeDirectory: () =>
ipcRenderer.invoke(DESKTOP_OPEN_THEME_DIRECTORY),
refreshThemes: () => ipcRenderer.invoke(DESKTOP_REFRESH_THEMES),
renderMarkdown: (input: MarkdownRenderRequest) =>
ipcRenderer.invoke(DESKTOP_RENDER_MARKDOWN, input),
listThemes: () => ipcRenderer.invoke(DESKTOP_LIST_THEMES),
+14
View File
@@ -2,6 +2,20 @@ export const DESKTOP_RENDER_MARKDOWN =
"md-to-pdf:desktop:render-markdown";
export const DESKTOP_OPEN_MARKDOWN =
"md-to-pdf:desktop:open-markdown";
export const DESKTOP_CONSUME_PENDING_MARKDOWN =
"md-to-pdf:desktop:consume-pending-markdown";
export const DESKTOP_DISCARD_PENDING_MARKDOWN =
"md-to-pdf:desktop:discard-pending-markdown";
export const DESKTOP_MARKDOWN_OPENED =
"md-to-pdf:desktop:markdown-opened";
export const DESKTOP_START_NEW_MARKDOWN =
"md-to-pdf:desktop:start-new-markdown";
export const DESKTOP_SAVE_MARKDOWN =
"md-to-pdf:desktop:save-markdown";
export const DESKTOP_OPEN_THEME_DIRECTORY =
"md-to-pdf:desktop:open-theme-directory";
export const DESKTOP_REFRESH_THEMES =
"md-to-pdf:desktop:refresh-themes";
export const DESKTOP_LIST_THEMES =
"md-to-pdf:desktop:list-themes";
export const DESKTOP_GET_THEME_CSS =
+335 -30
View File
@@ -6,23 +6,37 @@ import {
Menu,
net,
protocol,
screen,
session,
shell,
type IpcMainInvokeEvent
} from "electron";
import { access, readFile, writeFile } from "node:fs/promises";
import {
access,
mkdir,
writeFile
} from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
createApplicationService,
MAXIMUM_MARKDOWN_LENGTH,
type ApplicationService
} from "@md-to-pdf/application";
import {
DESKTOP_CONSUME_PENDING_MARKDOWN,
DESKTOP_DISCARD_PENDING_MARKDOWN,
DESKTOP_GET_THEME_CSS,
DESKTOP_GENERATE_PDF,
DESKTOP_LIST_THEMES,
DESKTOP_MARKDOWN_OPENED,
DESKTOP_OPEN_MARKDOWN,
DESKTOP_OPEN_THEME_DIRECTORY,
DESKTOP_REFRESH_THEMES,
DESKTOP_RENDER_MARKDOWN,
DESKTOP_SAVE_PDF
DESKTOP_SAVE_MARKDOWN,
DESKTOP_SAVE_PDF,
DESKTOP_START_NEW_MARKDOWN
} from "./channels.js";
import {
parseMarkdownRenderRequest,
@@ -34,11 +48,36 @@ import {
ElectronPdfGenerator
} from "./electron-pdf-generator.js";
import { parsePagedDocumentPayload } from "./pdf-contract.js";
import {
findMarkdownFileArgument,
isMarkdownFilePath,
readMarkdownDocument,
type OpenedMarkdownDocument
} from "./markdown-file.js";
import {
loadWindowState,
resolveWindowBounds,
saveWindowState,
type WindowState
} from "./window-state.js";
const APP_SCHEME = "mdpdf";
const APP_HOST = "bundle";
const THEME_HOST = "theme";
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 };
const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 };
let applicationController:
| {
window: BrowserWindow;
openMarkdownFile(filePath: string): Promise<void>;
flushWindowState(): Promise<void>;
}
| undefined;
let pendingExternalMarkdownPath = findMarkdownFileArgument(
process.argv,
process.cwd()
);
protocol.registerSchemesAsPrivileged([
{
@@ -91,15 +130,44 @@ function encodeThemeAssetPath(assetPath: string) {
.join("/");
}
function focusApplicationWindow() {
const window = applicationController?.window;
if (!window || window.isDestroyed()) {
return;
}
if (window.isMinimized()) {
window.restore();
}
window.show();
window.focus();
}
function dispatchExternalMarkdown(filePath: string) {
if (!applicationController) {
pendingExternalMarkdownPath = filePath;
return;
}
void applicationController.openMarkdownFile(filePath).catch(
(error: unknown) => {
console.warn("无法打开系统传入的 Markdown 文件", error);
}
);
focusApplicationWindow();
}
function getDesktopThemeDirectory() {
return app.isPackaged
? path.join(app.getPath("userData"), "themes")
: path.resolve(currentDirectory, "../../..", ".local", "themes");
}
function createDesktopApplicationService() {
const projectRoot = path.resolve(currentDirectory, "../../..");
return createApplicationService({
bundledRoot: app.isPackaged
? path.join(process.resourcesPath, "themes")
: path.join(projectRoot, "themes"),
localRoot: app.isPackaged
? path.join(app.getPath("userData"), "themes")
: path.join(projectRoot, ".local", "themes"),
localRoot: getDesktopThemeDirectory(),
createAssetUrl: (themeId, assetPath) =>
`${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`,
onWarning: (message) => console.warn(message)
@@ -204,14 +272,26 @@ async function createApplication(
"/preview-frame.html?target=pdf",
applicationUrl
).href;
const windowStatePath = path.join(
app.getPath("userData"),
"window-state.json"
);
const savedWindowState = await loadWindowState(windowStatePath);
const primaryWorkArea = screen.getPrimaryDisplay().workArea;
const initialBounds = resolveWindowBounds(
savedWindowState?.bounds,
screen.getAllDisplays().map((display) => display.workArea),
primaryWorkArea,
DEFAULT_WINDOW_SIZE,
MINIMUM_WINDOW_SIZE
);
const window = new BrowserWindow({
title: `Markdown PDF 导出器 v${app.getVersion()}`,
icon: getApplicationIcon(),
width: 1440,
height: 960,
minWidth: 1024,
minHeight: 720,
...initialBounds,
minWidth: Math.min(MINIMUM_WINDOW_SIZE.width, initialBounds.width),
minHeight: Math.min(MINIMUM_WINDOW_SIZE.height, initialBounds.height),
autoHideMenuBar: true,
show: false,
webPreferences: {
@@ -221,6 +301,55 @@ async function createApplication(
preload: path.join(currentDirectory, "app-preload.cjs")
}
});
let saveWindowStateTimer: NodeJS.Timeout | undefined;
let pendingWindowStateWrite = Promise.resolve();
let latestWindowState: WindowState = {
version: 1,
bounds: window.getNormalBounds(),
maximized: window.isMaximized()
};
const captureWindowState = () => {
if (window.isDestroyed()) {
return;
}
latestWindowState = {
version: 1,
bounds: window.getNormalBounds(),
maximized: window.isMaximized()
};
};
const persistWindowState = () => {
const state = structuredClone(latestWindowState);
pendingWindowStateWrite = pendingWindowStateWrite
.catch(() => undefined)
.then(() => saveWindowState(windowStatePath, state))
.catch((error: unknown) => {
console.warn("无法保存窗口状态", error);
});
return pendingWindowStateWrite;
};
const scheduleWindowStateSave = () => {
captureWindowState();
if (saveWindowStateTimer) {
clearTimeout(saveWindowStateTimer);
}
saveWindowStateTimer = setTimeout(() => {
saveWindowStateTimer = undefined;
void persistWindowState();
}, 2_000);
};
const flushWindowState = async () => {
if (saveWindowStateTimer) {
clearTimeout(saveWindowStateTimer);
saveWindowStateTimer = undefined;
}
captureWindowState();
await persistWindowState();
};
window.on("move", scheduleWindowStateSave);
window.on("resize", scheduleWindowStateSave);
window.on("maximize", scheduleWindowStateSave);
window.on("unmaximize", scheduleWindowStateSave);
lockDownWindow(window, new URL(applicationUrl).origin);
const pdfGenerator = new ElectronPdfGenerator({
@@ -233,6 +362,24 @@ async function createApplication(
new URL(event.senderFrame.url).origin ===
new URL(applicationUrl).origin;
let documentRoot: string | undefined;
let currentMarkdownPath: string | undefined;
let pendingOpenedMarkdown:
| {
document: OpenedMarkdownDocument;
filePath: string;
}
| undefined;
const queueOpenedMarkdown = async (filePath: string) => {
const opened = await readMarkdownDocument(
filePath,
MAXIMUM_MARKDOWN_LENGTH
);
pendingOpenedMarkdown = {
document: opened,
filePath
};
window.webContents.send(DESKTOP_MARKDOWN_OPENED);
};
ipcMain.handle(DESKTOP_OPEN_MARKDOWN, async (event) => {
if (!validateSender(event)) {
@@ -242,20 +389,116 @@ async function createApplication(
title: "打开 Markdown",
properties: ["openFile"],
filters: [
{ name: "Markdown 文件", extensions: ["md", "markdown"] },
{ name: "文本文件", extensions: ["txt"] }
{ name: "Markdown 文件", extensions: ["md", "markdown"] }
]
});
const filePath = selection.filePaths[0];
if (selection.canceled || !filePath) {
return undefined;
}
const content = await readFile(filePath, "utf8");
const opened = await readMarkdownDocument(
filePath,
MAXIMUM_MARKDOWN_LENGTH
);
documentRoot = path.dirname(filePath);
return {
markdown: content,
fileName: path.basename(filePath)
};
currentMarkdownPath = filePath;
return opened;
});
ipcMain.handle(DESKTOP_CONSUME_PENDING_MARKDOWN, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面文件请求");
}
const pending = pendingOpenedMarkdown;
pendingOpenedMarkdown = undefined;
if (!pending) {
return undefined;
}
currentMarkdownPath = pending.filePath;
documentRoot = path.dirname(pending.filePath);
return pending.document;
});
ipcMain.handle(DESKTOP_DISCARD_PENDING_MARKDOWN, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面文件请求");
}
pendingOpenedMarkdown = undefined;
});
ipcMain.handle(DESKTOP_START_NEW_MARKDOWN, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面文件请求");
}
currentMarkdownPath = undefined;
documentRoot = undefined;
pendingOpenedMarkdown = undefined;
});
ipcMain.handle(
DESKTOP_SAVE_MARKDOWN,
async (
event,
unsafeFileName: unknown,
unsafeMarkdown: unknown
) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面文件请求");
}
if (
typeof unsafeFileName !== "string" ||
unsafeFileName.length === 0 ||
unsafeFileName.length > 500 ||
typeof unsafeMarkdown !== "string" ||
unsafeMarkdown.length > MAXIMUM_MARKDOWN_LENGTH
) {
throw new Error("Markdown 保存参数无效");
}
let targetPath = currentMarkdownPath;
if (!targetPath) {
const suggestedName = /\.(?:md|markdown)$/iu.test(
path.basename(unsafeFileName)
)
? path.basename(unsafeFileName)
: `${path.basename(unsafeFileName)}.md`;
const selection = await dialog.showSaveDialog(window, {
title: "保存 Markdown",
defaultPath: path.join(
app.getPath("documents"),
suggestedName
),
filters: [
{ name: "Markdown 文件", extensions: ["md", "markdown"] }
]
});
if (selection.canceled || !selection.filePath) {
return undefined;
}
targetPath = /\.(?:md|markdown)$/iu.test(selection.filePath)
? selection.filePath
: `${selection.filePath}.md`;
}
await writeFile(targetPath, unsafeMarkdown, "utf8");
currentMarkdownPath = targetPath;
documentRoot = path.dirname(targetPath);
return { fileName: path.basename(targetPath) };
}
);
ipcMain.handle(DESKTOP_OPEN_THEME_DIRECTORY, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面主题请求");
}
const themeDirectory = getDesktopThemeDirectory();
await mkdir(themeDirectory, { recursive: true });
const errorMessage = await shell.openPath(themeDirectory);
if (errorMessage) {
throw new Error(`无法打开自定义主题目录:${errorMessage}`);
}
return themeDirectory;
});
ipcMain.handle(DESKTOP_REFRESH_THEMES, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面主题请求");
}
applicationService.invalidateThemes();
});
ipcMain.handle(
DESKTOP_RENDER_MARKDOWN,
@@ -343,9 +586,23 @@ async function createApplication(
}
);
window.once("ready-to-show", () => window.show());
window.once("ready-to-show", () => {
if (savedWindowState?.maximized) {
window.maximize();
}
window.show();
});
window.on("close", () => {
void flushWindowState();
});
window.on("closed", () => {
ipcMain.removeHandler(DESKTOP_OPEN_MARKDOWN);
ipcMain.removeHandler(DESKTOP_CONSUME_PENDING_MARKDOWN);
ipcMain.removeHandler(DESKTOP_DISCARD_PENDING_MARKDOWN);
ipcMain.removeHandler(DESKTOP_START_NEW_MARKDOWN);
ipcMain.removeHandler(DESKTOP_SAVE_MARKDOWN);
ipcMain.removeHandler(DESKTOP_OPEN_THEME_DIRECTORY);
ipcMain.removeHandler(DESKTOP_REFRESH_THEMES);
ipcMain.removeHandler(DESKTOP_RENDER_MARKDOWN);
ipcMain.removeHandler(DESKTOP_LIST_THEMES);
ipcMain.removeHandler(DESKTOP_GET_THEME_CSS);
@@ -354,23 +611,71 @@ async function createApplication(
void pdfGenerator.close();
});
await window.loadURL(applicationUrl);
return {
window,
openMarkdownFile: queueOpenedMarkdown,
flushWindowState
};
}
app
.whenReady()
.then(async () => {
Menu.setApplicationMenu(null);
if (process.platform === "win32") {
app.setAppUserModelId("com.md-to-pdf.desktop");
const hasSingleInstanceLock = app.requestSingleInstanceLock();
let windowStateReadyToQuit = false;
if (!hasSingleInstanceLock) {
app.quit();
} else {
app.on("second-instance", (_event, arguments_, workingDirectory) => {
const filePath = findMarkdownFileArgument(
arguments_,
workingDirectory
);
if (filePath) {
dispatchExternalMarkdown(filePath);
} else {
focusApplicationWindow();
}
const applicationService = createDesktopApplicationService();
await registerApplicationProtocol(applicationService);
await createApplication(applicationService);
})
.catch((error: unknown) => {
console.error("桌面应用启动失败", error);
app.quit();
});
app.on("open-file", (event, filePath) => {
event.preventDefault();
if (isMarkdownFilePath(filePath)) {
dispatchExternalMarkdown(filePath);
}
});
app.on("before-quit", (event) => {
if (windowStateReadyToQuit || !applicationController) {
return;
}
event.preventDefault();
void applicationController.flushWindowState().finally(() => {
windowStateReadyToQuit = true;
app.quit();
});
});
app
.whenReady()
.then(async () => {
Menu.setApplicationMenu(null);
if (process.platform === "win32") {
app.setAppUserModelId("com.md-to-pdf.desktop");
}
await mkdir(getDesktopThemeDirectory(), { recursive: true });
const applicationService = createDesktopApplicationService();
await registerApplicationProtocol(applicationService);
applicationController =
await createApplication(applicationService);
const startupMarkdownPath = pendingExternalMarkdownPath;
pendingExternalMarkdownPath = undefined;
if (startupMarkdownPath) {
await applicationController.openMarkdownFile(
startupMarkdownPath
);
}
})
.catch((error: unknown) => {
console.error("桌面应用启动失败", error);
app.quit();
});
}
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
+60
View File
@@ -0,0 +1,60 @@
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
const markdownExtensions = new Set([".md", ".markdown"]);
export interface OpenedMarkdownDocument {
markdown: string;
fileName: string;
}
export function isMarkdownFilePath(filePath: string) {
return markdownExtensions.has(path.extname(filePath).toLowerCase());
}
export function findMarkdownFileArgument(
arguments_: string[],
workingDirectory: string
) {
for (const argument of arguments_) {
if (!argument || argument.startsWith("-")) {
continue;
}
const candidate = path.isAbsolute(argument)
? path.normalize(argument)
: path.resolve(workingDirectory, argument);
if (isMarkdownFilePath(candidate)) {
return candidate;
}
}
return undefined;
}
export async function readMarkdownDocument(
filePath: string,
maximumLength: number
): Promise<OpenedMarkdownDocument> {
if (!isMarkdownFilePath(filePath)) {
throw new Error("只允许打开 .md 或 .markdown 文件");
}
if (!Number.isSafeInteger(maximumLength) || maximumLength <= 0) {
throw new Error("Markdown 长度限制无效");
}
const fileStats = await stat(filePath);
if (!fileStats.isFile()) {
throw new Error("Markdown 路径不是文件");
}
if (fileStats.size > maximumLength * 4) {
throw new Error("Markdown 文件过大");
}
const markdown = await readFile(filePath, "utf8");
if (markdown.length > maximumLength) {
throw new Error("Markdown 文件过大");
}
return {
markdown,
fileName: path.basename(filePath)
};
}
+200
View File
@@ -0,0 +1,200 @@
import {
mkdir,
readFile,
rename,
rm,
writeFile
} from "node:fs/promises";
import path from "node:path";
export interface WindowBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface WindowState {
version: 1;
bounds: WindowBounds;
maximized: boolean;
}
export interface WindowSize {
width: number;
height: number;
}
const WINDOW_STATE_VERSION = 1;
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function parseWindowBounds(value: unknown): WindowBounds | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const bounds = value as Partial<WindowBounds>;
if (
!isFiniteNumber(bounds.x) ||
!isFiniteNumber(bounds.y) ||
!isFiniteNumber(bounds.width) ||
!isFiniteNumber(bounds.height) ||
bounds.width <= 0 ||
bounds.height <= 0
) {
return undefined;
}
return {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.round(bounds.width),
height: Math.round(bounds.height)
};
}
export function parseWindowState(value: unknown): WindowState | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const state = value as Partial<WindowState>;
const bounds = parseWindowBounds(state.bounds);
if (
state.version !== WINDOW_STATE_VERSION ||
!bounds ||
typeof state.maximized !== "boolean"
) {
return undefined;
}
return {
version: WINDOW_STATE_VERSION,
bounds,
maximized: state.maximized
};
}
function intersectionArea(left: WindowBounds, right: WindowBounds) {
const width = Math.max(
0,
Math.min(left.x + left.width, right.x + right.width) -
Math.max(left.x, right.x)
);
const height = Math.max(
0,
Math.min(left.y + left.height, right.y + right.height) -
Math.max(left.y, right.y)
);
return width * height;
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(Math.max(value, minimum), maximum);
}
function centerBounds(size: WindowSize, workArea: WindowBounds): WindowBounds {
const width = Math.min(
Math.max(1, Math.round(size.width)),
workArea.width
);
const height = Math.min(
Math.max(1, Math.round(size.height)),
workArea.height
);
return {
x: Math.round(workArea.x + (workArea.width - width) / 2),
y: Math.round(workArea.y + (workArea.height - height) / 2),
width,
height
};
}
export function resolveWindowBounds(
savedBounds: WindowBounds | undefined,
workAreas: WindowBounds[],
primaryWorkArea: WindowBounds,
defaultSize: WindowSize,
minimumSize: WindowSize
): WindowBounds {
if (!savedBounds) {
return centerBounds(defaultSize, primaryWorkArea);
}
let selectedArea = primaryWorkArea;
let selectedIntersection = 0;
for (const workArea of workAreas) {
const area = intersectionArea(savedBounds, workArea);
if (area > selectedIntersection) {
selectedArea = workArea;
selectedIntersection = area;
}
}
if (selectedIntersection === 0) {
return centerBounds(
{
width: Math.min(
Math.max(savedBounds.width, minimumSize.width),
primaryWorkArea.width
),
height: Math.min(
Math.max(savedBounds.height, minimumSize.height),
primaryWorkArea.height
)
},
primaryWorkArea
);
}
const width = Math.min(
Math.max(savedBounds.width, minimumSize.width),
selectedArea.width
);
const height = Math.min(
Math.max(savedBounds.height, minimumSize.height),
selectedArea.height
);
return {
x: clamp(
savedBounds.x,
selectedArea.x,
selectedArea.x + selectedArea.width - width
),
y: clamp(
savedBounds.y,
selectedArea.y,
selectedArea.y + selectedArea.height - height
),
width,
height
};
}
export async function loadWindowState(filePath: string) {
try {
return parseWindowState(
JSON.parse(await readFile(filePath, "utf8")) as unknown
);
} catch {
return undefined;
}
}
export async function saveWindowState(
filePath: string,
state: WindowState
) {
await mkdir(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.${process.pid}.tmp`;
try {
await writeFile(
temporaryPath,
`${JSON.stringify(state, null, 2)}\n`,
"utf8"
);
await rename(temporaryPath, filePath);
} catch (error) {
await rm(temporaryPath, { force: true }).catch(() => undefined);
throw error;
}
}
+75
View File
@@ -0,0 +1,75 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
findMarkdownFileArgument,
isMarkdownFilePath,
readMarkdownDocument
} from "../src/markdown-file.js";
describe("桌面 Markdown 文件参数", () => {
it("识别两种 Markdown 扩展名且忽略大小写", () => {
expect(isMarkdownFilePath("C:\\docs\\周报.md")).toBe(true);
expect(isMarkdownFilePath("C:\\docs\\说明.MARKDOWN")).toBe(true);
expect(isMarkdownFilePath("C:\\docs\\说明.txt")).toBe(false);
});
it("从启动参数中提取绝对 Markdown 路径", () => {
expect(
findMarkdownFileArgument(
[
"C:\\Program Files\\md-to-pdf\\md-to-pdf.exe",
"--original-process-start-time=1",
"D:\\文档\\项目周报.md"
],
"C:\\Windows"
)
).toBe(path.normalize("D:\\文档\\项目周报.md"));
});
it("根据第二实例的工作目录解析相对路径", () => {
expect(
findMarkdownFileArgument(
["md-to-pdf.exe", "docs\\readme.markdown"],
"D:\\项目"
)
).toBe(path.resolve("D:\\项目", "docs\\readme.markdown"));
});
it("忽略非 Markdown 文件及 Chromium 参数", () => {
expect(
findMarkdownFileArgument(
["md-to-pdf.exe", "--inspect", "notes.txt"],
"C:\\项目"
)
).toBeUndefined();
});
it("统一读取 Markdown 并限制解码后的字符数", async () => {
const directory = await mkdtemp(
path.join(os.tmpdir(), "md-to-pdf-markdown-")
);
const filePath = path.join(directory, "示例.md");
try {
await writeFile(filePath, "# 示例", "utf8");
await expect(
readMarkdownDocument(filePath, 20)
).resolves.toEqual({
markdown: "# 示例",
fileName: "示例.md"
});
await expect(
readMarkdownDocument(filePath, 2)
).rejects.toThrow("Markdown 文件过大");
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it("拒绝非 Markdown 扩展名", async () => {
await expect(
readMarkdownDocument("C:\\docs\\说明.txt", 100)
).rejects.toThrow("只允许打开 .md 或 .markdown 文件");
});
});
+165
View File
@@ -0,0 +1,165 @@
import { mkdtemp, readdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
loadWindowState,
parseWindowState,
resolveWindowBounds,
saveWindowState
} from "../src/window-state.js";
const primaryWorkArea = {
x: 0,
y: 0,
width: 1920,
height: 1040
};
const defaultSize = { width: 1440, height: 960 };
const minimumSize = { width: 1024, height: 720 };
describe("桌面窗口状态", () => {
it("校验并规范化持久化状态", () => {
expect(
parseWindowState({
version: 1,
bounds: {
x: 120.4,
y: 80.6,
width: 1280.2,
height: 800.8
},
maximized: true
})
).toEqual({
version: 1,
bounds: {
x: 120,
y: 81,
width: 1280,
height: 801
},
maximized: true
});
});
it("拒绝损坏或不受支持的状态", () => {
expect(parseWindowState(undefined)).toBeUndefined();
expect(
parseWindowState({
version: 2,
bounds: primaryWorkArea,
maximized: false
})
).toBeUndefined();
expect(
parseWindowState({
version: 1,
bounds: { x: 0, y: 0, width: -1, height: 720 },
maximized: false
})
).toBeUndefined();
});
it("首次启动时在主显示器居中", () => {
expect(
resolveWindowBounds(
undefined,
[primaryWorkArea],
primaryWorkArea,
defaultSize,
minimumSize
)
).toEqual({
x: 240,
y: 40,
width: 1440,
height: 960
});
});
it("将部分溢出的窗口完整收回原显示器", () => {
expect(
resolveWindowBounds(
{ x: 1700, y: 900, width: 1280, height: 800 },
[primaryWorkArea],
primaryWorkArea,
defaultSize,
minimumSize
)
).toEqual({
x: 640,
y: 240,
width: 1280,
height: 800
});
});
it("显示器移除后在主显示器居中恢复", () => {
expect(
resolveWindowBounds(
{ x: 3000, y: 120, width: 1200, height: 780 },
[primaryWorkArea],
primaryWorkArea,
defaultSize,
minimumSize
)
).toEqual({
x: 360,
y: 130,
width: 1200,
height: 780
});
});
it("窗口大于工作区时缩小到可完整展示", () => {
const compactWorkArea = {
x: 1920,
y: 0,
width: 1280,
height: 680
};
expect(
resolveWindowBounds(
{ x: 2000, y: -100, width: 1800, height: 1000 },
[primaryWorkArea, compactWorkArea],
primaryWorkArea,
defaultSize,
minimumSize
)
).toEqual({
x: 1920,
y: 0,
width: 1280,
height: 680
});
});
it("通过临时文件原子保存并可重新载入", async () => {
const directory = await mkdtemp(
path.join(os.tmpdir(), "md-to-pdf-window-")
);
const filePath = path.join(directory, "window-state.json");
const state = {
version: 1 as const,
bounds: { x: 10, y: 20, width: 1200, height: 800 },
maximized: false
};
try {
await saveWindowState(filePath, state);
await expect(loadWindowState(filePath)).resolves.toEqual(state);
const updatedState = {
...state,
bounds: { x: 30, y: 40, width: 1280, height: 900 },
maximized: true
};
await saveWindowState(filePath, updatedState);
await expect(loadWindowState(filePath)).resolves.toEqual(
updatedState
);
expect(await readdir(directory)).toEqual(["window-state.json"]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});