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:
+335
-30
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user