release: 发布 v0.5.0

新增共享 Preview Engine,统一 Web 连续预览、快速分页、Playwright PDF 与 Electron PDF;实现稳定前缀复用和修改位置后的增量分页,保留媒体块按文档顺序串行回填与单次重排。

完善跨端链接与桌面文档工作流:Web 受控处理锚点和 HTTP/HTTPS 外链;Desktop 支持本地路径、file URI、系统协议、多窗口、同文件单例、Markdown 当前或新窗口打开,以及聚焦时外部文件变化提示。

统一四套内置主题名称并默认使用 Typora Github;修复连续预览双滚动条、ECharts 尺寸、PDF 本地链接、围栏代码块 Typora DOM 与重复行内样式;桌面发行链强制完整重建内嵌 Web,避免安装包携带陈旧资源。

发布 Web/Compose 与 Windows NSIS/ZIP:镜像 yixiong/md-to-pdf:v0.5.0 已健康部署;NSIS SHA-256 为 60992D1FDCA513F46346C78478537EB4159D8C0E76B41ECF3CDC25BE77707D92,ZIP SHA-256 为 D74F82293FB67126E583546CBA894569EFC9A0B1B6343FAC648CCC95F1D188D8,本机安装版已升级至 v0.5.0。

验证:全项目 238 项测试通过,类型检查、生产构建和 git diff --check 通过;Web 快速/连续/精确预览、Compose、Desktop 多窗口、窗口状态、文件关联、链接与代码块均完成真实环境验收。
This commit is contained in:
SkyJourney
2026-07-28 18:01:22 +08:00
parent 925b0d1485
commit 58087d0c7e
86 changed files with 4564 additions and 1204 deletions
+53 -451
View File
@@ -1,79 +1,32 @@
import {
app,
BrowserWindow,
dialog,
ipcMain,
Menu,
net,
protocol,
screen,
session,
shell,
type IpcMainInvokeEvent
session
} from "electron";
import {
access,
mkdir,
writeFile
} from "node:fs/promises";
import { access, mkdir } 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 { parseThemeResourceUrl } from "./application-contract.js";
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_SAVE_PDF,
DESKTOP_START_NEW_MARKDOWN
} from "./channels.js";
import {
parseMarkdownRenderRequest,
parseThemeId,
parseThemeResourceUrl
} from "./application-contract.js";
import {
DESKTOP_PDF_PARTITION,
ElectronPdfGenerator
} from "./electron-pdf-generator.js";
import { parsePagedDocumentPayload } from "./pdf-contract.js";
DesktopApplicationController
} from "./desktop-application-controller.js";
import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js";
import {
findMarkdownFileArgument,
isMarkdownFilePath,
readMarkdownDocument,
type OpenedMarkdownDocument
isMarkdownFilePath
} 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 applicationController: DesktopApplicationController | undefined;
let pendingExternalMarkdownPath = findMarkdownFileArgument(
process.argv,
process.cwd()
@@ -130,29 +83,16 @@ 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(
void applicationController.openExternalMarkdown(filePath).catch(
(error: unknown) => {
console.warn("无法打开系统传入的 Markdown 文件", error);
}
);
focusApplicationWindow();
}
function getDesktopThemeDirectory() {
@@ -226,12 +166,10 @@ async function registerApplicationProtocol(
} catch {
return new Response("Bad request", { status: 400 });
}
const target = path.resolve(webRoot, relativePath);
if (!isContainedPath(webRoot, target)) {
return new Response("Forbidden", { status: 403 });
}
try {
await access(target);
} catch {
@@ -248,376 +186,6 @@ async function registerApplicationProtocol(
]);
}
function lockDownWindow(window: BrowserWindow, allowedOrigin: string) {
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
window.webContents.on("will-navigate", (event, targetUrl) => {
if (new URL(targetUrl).origin !== allowedOrigin) {
event.preventDefault();
}
});
const applicationSession = window.webContents.session;
applicationSession.setPermissionCheckHandler(() => false);
applicationSession.setPermissionRequestHandler(
(_webContents, _permission, callback) => callback(false)
);
}
async function createApplication(
applicationService: ApplicationService
) {
const developmentUrl = getCommandLineValue("web-url");
const applicationUrl =
developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`;
const renderUrl = new URL(
"/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(),
...initialBounds,
minWidth: Math.min(MINIMUM_WINDOW_SIZE.width, initialBounds.width),
minHeight: Math.min(MINIMUM_WINDOW_SIZE.height, initialBounds.height),
autoHideMenuBar: true,
show: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
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({
renderUrl,
preloadPath: path.join(currentDirectory, "pdf-preload.cjs")
});
const validateSender = (event: IpcMainInvokeEvent) =>
event.sender === window.webContents &&
event.senderFrame !== null &&
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)) {
throw new Error("拒绝未授权的桌面文件请求");
}
const selection = await dialog.showOpenDialog(window, {
title: "打开 Markdown",
properties: ["openFile"],
filters: [
{ name: "Markdown 文件", extensions: ["md", "markdown"] }
]
});
const filePath = selection.filePaths[0];
if (selection.canceled || !filePath) {
return undefined;
}
const opened = await readMarkdownDocument(
filePath,
MAXIMUM_MARKDOWN_LENGTH
);
documentRoot = path.dirname(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,
async (event, unsafeRequest: unknown) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面渲染请求");
}
return applicationService.render(
parseMarkdownRenderRequest(unsafeRequest),
documentRoot ? { localRoot: documentRoot } : {}
);
}
);
ipcMain.handle(DESKTOP_LIST_THEMES, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面主题请求");
}
return applicationService.listThemes();
});
ipcMain.handle(
DESKTOP_GET_THEME_CSS,
async (event, unsafeThemeId: unknown) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面主题请求");
}
const css = await applicationService.getThemeCss(
parseThemeId(unsafeThemeId)
);
if (css === undefined) {
throw new Error("未找到指定主题");
}
return css;
}
);
ipcMain.handle(
DESKTOP_GENERATE_PDF,
async (event, unsafePayload: unknown) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面 PDF 请求");
}
const payload = parsePagedDocumentPayload(unsafePayload);
const result = await pdfGenerator.generate(payload);
return {
pdf: result.pdf,
pageCount: result.pageCount,
echartsErrors: result.echartsErrors,
mermaidErrors: result.mermaidErrors
};
}
);
ipcMain.handle(
DESKTOP_SAVE_PDF,
async (
event,
unsafeFileName: unknown,
unsafePdf: unknown
) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面 PDF 保存请求");
}
if (
typeof unsafeFileName !== "string" ||
unsafeFileName.length === 0 ||
unsafeFileName.length > 500 ||
!(unsafePdf instanceof Uint8Array) ||
unsafePdf.byteLength === 0 ||
unsafePdf.byteLength > 200 * 1024 * 1024
) {
throw new Error("PDF 保存参数无效");
}
const suggestedName = path.basename(unsafeFileName).endsWith(".pdf")
? path.basename(unsafeFileName)
: `${path.basename(unsafeFileName)}.pdf`;
const selection = await dialog.showSaveDialog(window, {
title: "导出 PDF",
defaultPath: path.join(app.getPath("documents"), suggestedName),
filters: [{ name: "PDF 文件", extensions: ["pdf"] }]
});
if (selection.canceled || !selection.filePath) {
return false;
}
await writeFile(selection.filePath, unsafePdf);
return true;
}
);
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);
ipcMain.removeHandler(DESKTOP_GENERATE_PDF);
ipcMain.removeHandler(DESKTOP_SAVE_PDF);
void pdfGenerator.close();
});
await window.loadURL(applicationUrl);
return {
window,
openMarkdownFile: queueOpenedMarkdown,
flushWindowState
};
}
const hasSingleInstanceLock = app.requestSingleInstanceLock();
let windowStateReadyToQuit = false;
@@ -631,8 +199,8 @@ if (!hasSingleInstanceLock) {
);
if (filePath) {
dispatchExternalMarkdown(filePath);
} else {
focusApplicationWindow();
} else if (!applicationController?.focusMostRecentWindow()) {
void applicationController?.createWindow();
}
});
app.on("open-file", (event, filePath) => {
@@ -641,6 +209,11 @@ if (!hasSingleInstanceLock) {
dispatchExternalMarkdown(filePath);
}
});
app.on("activate", () => {
if (!applicationController?.focusMostRecentWindow()) {
void applicationController?.createWindow();
}
});
app.on("before-quit", (event) => {
if (windowStateReadyToQuit || !applicationController) {
return;
@@ -649,8 +222,14 @@ if (!hasSingleInstanceLock) {
void applicationController.flushWindowState().finally(() => {
windowStateReadyToQuit = true;
app.quit();
setTimeout(() => {
windowStateReadyToQuit = false;
}, 1_000);
});
});
app.on("will-quit", () => {
void applicationController?.closeResources();
});
app
.whenReady()
.then(async () => {
@@ -658,18 +237,41 @@ if (!hasSingleInstanceLock) {
if (process.platform === "win32") {
app.setAppUserModelId("com.md-to-pdf.desktop");
}
await mkdir(getDesktopThemeDirectory(), { recursive: true });
const themeDirectory = getDesktopThemeDirectory();
await mkdir(themeDirectory, { recursive: true });
const applicationService = createDesktopApplicationService();
await registerApplicationProtocol(applicationService);
applicationController =
await createApplication(applicationService);
const developmentUrl = getCommandLineValue("web-url");
const applicationUrl =
developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`;
applicationController = new DesktopApplicationController({
applicationService,
applicationUrl,
renderUrl: new URL(
"/preview-frame.html?target=pdf",
applicationUrl
).href,
preloadPath: path.join(
currentDirectory,
"app-preload.cjs"
),
pdfPreloadPath: path.join(
currentDirectory,
"pdf-preload.cjs"
),
iconPath: getApplicationIcon(),
themeDirectory,
windowStatePath: path.join(
app.getPath("userData"),
"window-state.json"
)
});
await applicationController.initialize();
const startupMarkdownPath = pendingExternalMarkdownPath;
pendingExternalMarkdownPath = undefined;
if (startupMarkdownPath) {
await applicationController.openMarkdownFile(
startupMarkdownPath
);
}
await applicationController.createWindow(startupMarkdownPath);
})
.catch((error: unknown) => {
console.error("桌面应用启动失败", error);