feat: 发布 v0.4.0 桌面端

This commit is contained in:
SkyJourney
2026-07-27 20:56:39 +08:00
parent 92a5bfd016
commit 4d60741f5a
90 changed files with 10431 additions and 223 deletions
+1
View File
@@ -13,4 +13,5 @@ tmp/
playwright-report/
test-results/
output/
/apps/desktop/out/
*.tsbuildinfo
+44
View File
@@ -0,0 +1,44 @@
# Desktop 桌面端
`apps/desktop``v0.4.0` 新增的 Electron 桌面应用。当前已经完成
Electron 壳、受限 preload、进程内应用服务、隐藏分页窗口、Electron
Chromium PDF 输出、原生 PDF 另存为和 Windows 安装包验证。
## 开发
从仓库根目录运行:
```powershell
npm run desktop:dev
```
桌面端不启动 Fastify,也不占用本地 HTTP 端口。React 界面通过受限 IPC
调用 `packages/application` 中的 Markdown 渲染和主题服务,PDF 由
Electron 自带 Chromium 生成。Web 与 Docker 继续通过 Fastify HTTP
适配器使用同一套应用服务。
## 构建与打包
```powershell
npm run build -w @md-to-pdf/desktop
npm run desktop:package
npm run make -w @md-to-pdf/desktop
```
版本化目录包、Squirrel `Setup.exe` 和 ZIP 输出到
`apps/desktop/out/v0.4.0/`。该目录被 Git 忽略。公司内部分发以
`Setup.exe` 为正式安装包,ZIP 作为免安装辅助包;当前未配置代码签名,
Windows 首次运行可能显示“未知发布者”提示。
当前目录包只包含一套 Electron Chromium,不携带 Playwright Chromium。
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
`mdpdf://bundle/` 自定义协议加载。
## 安全边界
- 应用窗口和 PDF 窗口均关闭 Node Integration
- 启用 Context Isolation 和 Chromium Sandbox
- preload 只暴露渲染、主题、生成 PDF 和保存 PDF 所需的窄接口;
- 主进程校验 IPC 发送者和分页载荷;
- PDF 窗口使用独立内存 Session,并阻止外部网络资源;
- 应用页面使用自定义协议,不使用 `file://`
+45
View File
@@ -0,0 +1,45 @@
const path = require("node:path");
const windowsIcon = path.resolve(
__dirname,
"../../logos/desktop/windows/app.ico"
);
const macosIcon = path.resolve(
__dirname,
"../../logos/desktop/macos/app.icns"
);
const { version } = require("../../package.json");
module.exports = {
buildIdentifier: `v${version}`,
packagerConfig: {
asar: true,
prune: false,
icon: process.platform === "darwin" ? macosIcon : windowsIcon,
ignore: [
/^\/node_modules(?:\/|$)/,
/^\/out(?:\/|$)/,
/^\/src(?:\/|$)/,
/^\/tests(?:\/|$)/,
/^\/tsconfig\.json$/
],
extraResource: [
path.resolve(__dirname, "../web/dist"),
path.resolve(__dirname, "../../themes"),
windowsIcon,
macosIcon
]
},
makers: [
{
name: "@electron-forge/maker-squirrel",
config: {
name: "markdown_pdf_exporter",
setupIcon: windowsIcon
}
},
{
name: "@electron-forge/maker-zip",
platforms: ["win32", "darwin"]
}
]
};
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@md-to-pdf/desktop",
"version": "0.4.0",
"private": true,
"productName": "Markdown PDF 导出器",
"description": "Markdown PDF 导出器桌面端",
"author": "md-to-pdf contributors",
"type": "module",
"main": "dist/main.js",
"dependencies": {
"@md-to-pdf/application": "0.4.0"
},
"scripts": {
"build": "npm run clean && npm run build:main && npm run build:preload",
"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",
"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",
"package": "npm run build && electron-forge package",
"make": "npm run build && electron-forge make",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
},
"devDependencies": {
"@electron-forge/cli": "7.11.2",
"@electron-forge/maker-squirrel": "7.11.2",
"@electron-forge/maker-zip": "7.11.2",
"@types/node": "^24.10.1",
"electron": "43.2.0",
"esbuild": "^0.25.0",
"typescript": "^5.9.3",
"vitest": "^4.1.10",
"wait-on": "^9.0.1"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { build } from "esbuild";
await build({
entryPoints: ["src/main.ts"],
bundle: true,
platform: "node",
format: "esm",
external: ["electron"],
outfile: "dist/main.js",
banner: {
js: [
'import { createRequire as __mdToPdfCreateRequire } from "node:module";',
"const require = __mdToPdfCreateRequire(import.meta.url);"
].join("\n")
}
});
+22
View File
@@ -0,0 +1,22 @@
import { contextBridge, ipcRenderer } from "electron";
import type { PagedDocumentPayload } from "@md-to-pdf/core";
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
import {
DESKTOP_GET_THEME_CSS,
DESKTOP_GENERATE_PDF,
DESKTOP_LIST_THEMES,
DESKTOP_RENDER_MARKDOWN,
DESKTOP_SAVE_PDF
} from "./channels.js";
contextBridge.exposeInMainWorld("mdToPdfDesktop", {
renderMarkdown: (input: MarkdownRenderRequest) =>
ipcRenderer.invoke(DESKTOP_RENDER_MARKDOWN, input),
listThemes: () => ipcRenderer.invoke(DESKTOP_LIST_THEMES),
getThemeCss: (themeId: string) =>
ipcRenderer.invoke(DESKTOP_GET_THEME_CSS, themeId),
generatePdf: (payload: PagedDocumentPayload) =>
ipcRenderer.invoke(DESKTOP_GENERATE_PDF, payload),
savePdf: (fileName: string, pdf: Uint8Array) =>
ipcRenderer.invoke(DESKTOP_SAVE_PDF, fileName, pdf)
});
+53
View File
@@ -0,0 +1,53 @@
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function parseMarkdownRenderRequest(
value: unknown
): MarkdownRenderRequest {
if (!isRecord(value)) {
return {};
}
return {
markdown: value.markdown,
language: value.language
};
}
export function parseThemeId(value: unknown) {
if (
typeof value !== "string" ||
!/^[a-z0-9][a-z0-9._-]{0,99}$/i.test(value)
) {
throw new Error("主题 ID 无效");
}
return value;
}
export function parseThemeResourceUrl(requestUrl: string) {
const url = new URL(requestUrl);
if (url.protocol !== "mdpdf:" || url.host !== "theme") {
return undefined;
}
let segments: string[];
try {
segments = url.pathname
.split("/")
.filter(Boolean)
.map((segment) => decodeURIComponent(segment));
} catch {
throw new Error("主题资源地址无效");
}
const [unsafeThemeId, ...assetSegments] = segments;
if (!unsafeThemeId || assetSegments.length === 0) {
throw new Error("主题资源地址无效");
}
return {
themeId: parseThemeId(unsafeThemeId),
assetPath: assetSegments.join("/")
};
}
+18
View File
@@ -0,0 +1,18 @@
export const DESKTOP_RENDER_MARKDOWN =
"md-to-pdf:desktop:render-markdown";
export const DESKTOP_LIST_THEMES =
"md-to-pdf:desktop:list-themes";
export const DESKTOP_GET_THEME_CSS =
"md-to-pdf:desktop:get-theme-css";
export const DESKTOP_GENERATE_PDF =
"md-to-pdf:desktop:generate-pdf";
export const DESKTOP_SAVE_PDF =
"md-to-pdf:desktop:save-pdf";
export const PDF_RUNTIME_READY =
"md-to-pdf:desktop-pdf:ready";
export const PDF_RUNTIME_RENDER =
"md-to-pdf:desktop-pdf:render";
export const PDF_RUNTIME_COMPLETE =
"md-to-pdf:desktop-pdf:complete";
export const PDF_RUNTIME_FAIL =
"md-to-pdf:desktop-pdf:fail";
+342
View File
@@ -0,0 +1,342 @@
import {
BrowserWindow,
ipcMain,
type IpcMainEvent
} from "electron";
import { performance } from "node:perf_hooks";
import type {
PagedDocumentPayload,
PagedDocumentRenderResult
} from "@md-to-pdf/core";
import {
PDF_RUNTIME_COMPLETE,
PDF_RUNTIME_FAIL,
PDF_RUNTIME_READY,
PDF_RUNTIME_RENDER
} from "./channels.js";
import {
createElectronPdfPrintOptions,
isAllowedPdfRuntimeUrl
} from "./pdf-contract.js";
export const DESKTOP_PDF_PARTITION =
"md-to-pdf-desktop-pdf";
export interface ElectronPdfGenerationResult {
pdf: Buffer;
pageCount: number;
echartsErrors: string[];
mermaidErrors: string[];
timings: {
documentRenderMs: number;
pdfPrintMs: number;
totalMs: number;
};
}
export interface ElectronPdfGeneratorOptions {
renderUrl: string;
preloadPath: string;
timeoutMs?: number;
}
interface PendingRender {
requestId: number;
resolve: (result: PagedDocumentRenderResult) => void;
reject: (reason: Error) => void;
}
async function waitWithTimeout<T>(
operation: Promise<T>,
timeoutMs: number,
message: string
) {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(
() => reject(new Error(message)),
timeoutMs
);
})
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
export class ElectronPdfGenerator {
private readonly renderUrl: string;
private readonly preloadPath: string;
private readonly timeoutMs: number;
private windowPromise: Promise<BrowserWindow> | undefined;
private renderWindow: BrowserWindow | undefined;
private pendingRender: PendingRender | undefined;
private nextRequestId = 1;
private queue = Promise.resolve();
private closed = false;
constructor(options: ElectronPdfGeneratorOptions) {
this.renderUrl = options.renderUrl;
this.preloadPath = options.preloadPath;
this.timeoutMs = options.timeoutMs ?? 60_000;
ipcMain.on(PDF_RUNTIME_COMPLETE, this.handleRenderComplete);
ipcMain.on(PDF_RUNTIME_FAIL, this.handleRenderFailure);
}
generate(payload: PagedDocumentPayload) {
if (this.closed) {
return Promise.reject(new Error("桌面 PDF 引擎已关闭"));
}
const operation = this.queue.then(() => this.generateNow(payload));
this.queue = operation.then(
() => undefined,
() => undefined
);
return operation;
}
async close() {
this.closed = true;
ipcMain.removeListener(
PDF_RUNTIME_COMPLETE,
this.handleRenderComplete
);
ipcMain.removeListener(PDF_RUNTIME_FAIL, this.handleRenderFailure);
this.pendingRender?.reject(new Error("桌面 PDF 引擎已关闭"));
this.pendingRender = undefined;
const windowPromise = this.windowPromise;
this.windowPromise = undefined;
this.renderWindow = undefined;
if (!windowPromise) {
return;
}
try {
const window = await windowPromise;
if (!window.isDestroyed()) {
window.destroy();
}
} catch {
// 创建失败时没有需要关闭的窗口。
}
}
private async generateNow(payload: PagedDocumentPayload) {
const startedAt = performance.now();
const window = await this.getWindow();
const documentRenderStartedAt = performance.now();
const renderResult = await this.requestRender(window, payload);
const documentRenderMs =
performance.now() - documentRenderStartedAt;
await window.webContents.executeJavaScript(
"new Promise((resolve) => setTimeout(resolve, 0))"
);
const pdfPrintStartedAt = performance.now();
window.showInactive();
await new Promise((resolve) => setTimeout(resolve, 50));
let pdf: Buffer;
try {
pdf = await waitWithTimeout(
window.webContents.printToPDF(
createElectronPdfPrintOptions(payload, renderResult)
),
this.timeoutMs,
`桌面 PDF 打印超过 ${this.timeoutMs}ms`
);
} catch (error) {
if (!window.isDestroyed()) {
window.destroy();
}
throw error;
} finally {
if (!window.isDestroyed()) {
window.hide();
}
}
const pdfPrintMs = performance.now() - pdfPrintStartedAt;
return {
pdf,
pageCount: renderResult.pageCount,
echartsErrors: renderResult.echartsErrors,
mermaidErrors: renderResult.mermaidErrors,
timings: {
documentRenderMs,
pdfPrintMs,
totalMs: performance.now() - startedAt
}
} satisfies ElectronPdfGenerationResult;
}
private getWindow() {
if (!this.windowPromise) {
this.windowPromise = this.createWindow().catch((error) => {
this.windowPromise = undefined;
throw error;
});
}
return this.windowPromise;
}
private async createWindow() {
const window = new BrowserWindow({
show: false,
focusable: false,
opacity: 0,
skipTaskbar: true,
x: -32_000,
y: -32_000,
width: 1280,
height: 900,
paintWhenInitiallyHidden: true,
webPreferences: {
backgroundThrottling: false,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: DESKTOP_PDF_PARTITION,
preload: this.preloadPath
}
});
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
window.webContents.on("will-navigate", (event, url) => {
const target = new URL(url);
const renderTarget = new URL(this.renderUrl);
if (
target.origin !== renderTarget.origin ||
target.pathname !== renderTarget.pathname
) {
event.preventDefault();
}
});
window.webContents.on("destroyed", () => {
if (this.windowPromise) {
this.windowPromise = undefined;
}
this.renderWindow = undefined;
this.pendingRender?.reject(
new Error("桌面 PDF 渲染窗口已关闭")
);
this.pendingRender = undefined;
});
window.webContents.session.webRequest.onBeforeRequest(
{ urls: ["*://*/*"] },
(details, callback) => {
callback({
cancel: !isAllowedPdfRuntimeUrl(details.url, this.renderUrl)
});
}
);
const ready = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error("桌面 PDF 分页运行时启动超时"));
}, this.timeoutMs);
const handleReady = (event: IpcMainEvent) => {
if (event.sender !== window.webContents) {
return;
}
cleanup();
resolve();
};
const cleanup = () => {
clearTimeout(timeout);
ipcMain.removeListener(PDF_RUNTIME_READY, handleReady);
};
ipcMain.on(PDF_RUNTIME_READY, handleReady);
});
try {
await Promise.all([window.loadURL(this.renderUrl), ready]);
window.webContents.setZoomFactor(1);
this.renderWindow = window;
return window;
} catch (error) {
window.destroy();
throw error;
}
}
private requestRender(
window: BrowserWindow,
payload: PagedDocumentPayload
) {
if (this.pendingRender) {
throw new Error("桌面 PDF 渲染器状态异常");
}
const requestId = this.nextRequestId++;
return new Promise<PagedDocumentRenderResult>((resolve, reject) => {
const timeout = setTimeout(() => {
if (this.pendingRender?.requestId === requestId) {
this.pendingRender = undefined;
reject(new Error(`桌面 PDF 生成超过 ${this.timeoutMs}ms`));
if (!window.isDestroyed()) {
window.destroy();
}
}
}, this.timeoutMs);
this.pendingRender = {
requestId,
resolve: (result) => {
clearTimeout(timeout);
resolve(result);
},
reject: (error) => {
clearTimeout(timeout);
reject(error);
}
};
window.webContents.send(PDF_RUNTIME_RENDER, requestId, payload);
});
}
private readonly handleRenderComplete = (
event: IpcMainEvent,
requestId: number,
result: PagedDocumentRenderResult
) => {
const pending = this.pendingRender;
if (
!pending ||
pending.requestId !== requestId ||
event.sender !== this.renderWindow?.webContents
) {
return;
}
this.pendingRender = undefined;
pending.resolve(result);
};
private readonly handleRenderFailure = (
event: IpcMainEvent,
requestId: number,
message: string
) => {
const pending = this.pendingRender;
if (
!pending ||
pending.requestId !== requestId ||
event.sender !== this.renderWindow?.webContents
) {
return;
}
this.pendingRender = undefined;
pending.reject(
new Error(
typeof message === "string" ? message : "桌面 PDF 分页失败"
)
);
};
}
+349
View File
@@ -0,0 +1,349 @@
import {
app,
BrowserWindow,
dialog,
ipcMain,
net,
protocol,
session,
type IpcMainInvokeEvent
} from "electron";
import { access, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
createApplicationService,
type ApplicationService
} from "@md-to-pdf/application";
import {
DESKTOP_GET_THEME_CSS,
DESKTOP_GENERATE_PDF,
DESKTOP_LIST_THEMES,
DESKTOP_RENDER_MARKDOWN,
DESKTOP_SAVE_PDF
} 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";
const APP_SCHEME = "mdpdf";
const APP_HOST = "bundle";
const THEME_HOST = "theme";
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
protocol.registerSchemesAsPrivileged([
{
scheme: APP_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
stream: true,
codeCache: true,
corsEnabled: true
}
}
]);
function getCommandLineValue(name: string) {
const prefix = `--${name}=`;
return process.argv
.find((argument) => argument.startsWith(prefix))
?.slice(prefix.length);
}
function getWebRoot() {
return app.isPackaged
? path.join(process.resourcesPath, "dist")
: path.resolve(currentDirectory, "../../web/dist");
}
function getApplicationIcon() {
if (app.isPackaged) {
return path.join(
process.resourcesPath,
process.platform === "darwin" ? "app.icns" : "app.ico"
);
}
const projectRoot = path.resolve(currentDirectory, "../../..");
return path.join(
projectRoot,
"logos",
"desktop",
process.platform === "darwin" ? "macos" : "windows",
process.platform === "darwin" ? "app.icns" : "app.ico"
);
}
function encodeThemeAssetPath(assetPath: string) {
return assetPath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
}
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"),
createAssetUrl: (themeId, assetPath) =>
`${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`,
onWarning: (message) => console.warn(message)
});
}
function isContainedPath(root: string, target: string) {
const relative = path.relative(root, target);
return (
relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
}
async function registerApplicationProtocol(
applicationService: ApplicationService
) {
const webRoot = getWebRoot();
const handleRequest = async (request: Request) => {
const url = new URL(request.url);
if (url.host === THEME_HOST) {
try {
const resource = parseThemeResourceUrl(request.url);
if (!resource) {
return new Response("Not found", { status: 404 });
}
const asset = await applicationService.getThemeAsset(
resource.themeId,
resource.assetPath
);
if (!asset) {
return new Response("Not found", { status: 404 });
}
return new Response(asset.content, {
headers: {
"access-control-allow-origin": "*",
"cache-control": "public, max-age=300",
"content-type": asset.contentType,
"x-content-type-options": "nosniff"
}
});
} catch {
return new Response("Not found", { status: 404 });
}
}
if (url.host !== APP_HOST) {
return new Response("Not found", { status: 404 });
}
let relativePath: string;
try {
relativePath =
decodeURIComponent(url.pathname).replace(/^\/+/, "") ||
"index.html";
} 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 {
return new Response("Not found", { status: 404 });
}
return net.fetch(pathToFileURL(target).href);
};
await Promise.all([
protocol.handle(APP_SCHEME, handleRequest),
session
.fromPartition(DESKTOP_PDF_PARTITION)
.protocol.handle(APP_SCHEME, handleRequest)
]);
}
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 window = new BrowserWindow({
title: `Markdown PDF 导出器 v${app.getVersion()}`,
icon: getApplicationIcon(),
width: 1440,
height: 960,
minWidth: 1024,
minHeight: 720,
show: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: path.join(currentDirectory, "app-preload.cjs")
}
});
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;
ipcMain.handle(
DESKTOP_RENDER_MARKDOWN,
async (event, unsafeRequest: unknown) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面渲染请求");
}
return applicationService.render(
parseMarkdownRenderRequest(unsafeRequest)
);
}
);
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", () => window.show());
window.on("closed", () => {
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);
}
app
.whenReady()
.then(async () => {
if (process.platform === "win32") {
app.setAppUserModelId("com.md-to-pdf.desktop");
}
const applicationService = createDesktopApplicationService();
await registerApplicationProtocol(applicationService);
await createApplication(applicationService);
})
.catch((error: unknown) => {
console.error("桌面应用启动失败", error);
app.quit();
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
+124
View File
@@ -0,0 +1,124 @@
import {
exportConfigSchema,
themeFeatureSchema,
type PagedDocumentPayload,
type PagedDocumentRenderResult
} from "@md-to-pdf/core";
const MAX_ARTICLE_HTML_LENGTH = 20 * 1024 * 1024;
const MAX_THEME_CSS_LENGTH = 10 * 1024 * 1024;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function readString(
record: Record<string, unknown>,
key: string,
maximum: number
) {
const value = record[key];
if (typeof value !== "string" || value.length > maximum) {
throw new Error(`${key} 无效`);
}
return value;
}
function readStringArray(value: unknown, key: string, maximum: number) {
if (
!Array.isArray(value) ||
value.length > maximum ||
value.some((item) => typeof item !== "string")
) {
throw new Error(`${key} 无效`);
}
return value as string[];
}
export function parsePagedDocumentPayload(
value: unknown
): PagedDocumentPayload {
if (!isRecord(value) || !isRecord(value.metadata)) {
throw new Error("PDF 分页载荷无效");
}
const metadata = value.metadata;
const features = readStringArray(value.features, "features", 20);
const parsedFeatures = features.map((feature) =>
themeFeatureSchema.parse(feature)
);
return {
articleHtml: readString(
value,
"articleHtml",
MAX_ARTICLE_HTML_LENGTH
),
fileName: readString(value, "fileName", 500),
metadata: {
title: readString(metadata, "title", 500),
author: readString(metadata, "author", 500),
subject: readString(metadata, "subject", 1_000),
keywords: readStringArray(
metadata.keywords,
"metadata.keywords",
100
),
language: readString(metadata, "language", 50)
},
features: parsedFeatures,
themeCss: readString(
value,
"themeCss",
MAX_THEME_CSS_LENGTH
),
exportConfig: exportConfigSchema.parse(value.exportConfig)
};
}
export interface ElectronPdfPrintOptions {
displayHeaderFooter: false;
margins: {
top: number;
right: number;
bottom: number;
left: number;
};
pageRanges: string;
preferCSSPageSize: true;
printBackground: boolean;
scale: number;
}
export function createElectronPdfPrintOptions(
payload: PagedDocumentPayload,
renderResult: PagedDocumentRenderResult
): ElectronPdfPrintOptions {
return {
displayHeaderFooter: false,
margins: {
top: 0,
right: 0,
bottom: 0,
left: 0
},
pageRanges: `1-${Math.max(renderResult.pageCount, 1)}`,
preferCSSPageSize: true,
printBackground: payload.exportConfig.print.background,
scale: payload.exportConfig.print.scale
};
}
export function isAllowedPdfRuntimeUrl(
requestUrl: string,
renderUrl: string
) {
const requested = new URL(requestUrl);
if (["about:", "blob:", "data:"].includes(requested.protocol)) {
return true;
}
if (requested.protocol === "mdpdf:" && requested.host === "theme") {
return true;
}
return requested.origin === new URL(renderUrl).origin;
}
+41
View File
@@ -0,0 +1,41 @@
import {
contextBridge,
ipcRenderer,
type IpcRendererEvent
} from "electron";
import type {
PagedDocumentPayload,
PagedDocumentRenderResult
} from "@md-to-pdf/core";
import {
PDF_RUNTIME_COMPLETE,
PDF_RUNTIME_FAIL,
PDF_RUNTIME_READY,
PDF_RUNTIME_RENDER
} from "./channels.js";
type RenderListener = (
requestId: number,
payload: PagedDocumentPayload
) => Promise<void> | void;
contextBridge.exposeInMainWorld("__mdToPdfDesktopPdf", {
ready: () => ipcRenderer.send(PDF_RUNTIME_READY),
onRender: (listener: RenderListener) => {
const handler = (
_event: IpcRendererEvent,
requestId: number,
payload: PagedDocumentPayload
) => {
void listener(requestId, payload);
};
ipcRenderer.on(PDF_RUNTIME_RENDER, handler);
return () => ipcRenderer.removeListener(PDF_RUNTIME_RENDER, handler);
},
complete: (
requestId: number,
result: PagedDocumentRenderResult
) => ipcRenderer.send(PDF_RUNTIME_COMPLETE, requestId, result),
fail: (requestId: number, message: string) =>
ipcRenderer.send(PDF_RUNTIME_FAIL, requestId, message)
});
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import {
createPagedDocumentPayload,
defaultExportConfig
} from "@md-to-pdf/core";
import {
createElectronPdfPrintOptions,
isAllowedPdfRuntimeUrl,
parsePagedDocumentPayload
} from "../src/pdf-contract.js";
import {
parseMarkdownRenderRequest,
parseThemeId,
parseThemeResourceUrl
} from "../src/application-contract.js";
const payload = createPagedDocumentPayload({
document: {
rendererVersion: 1,
articleHtml: '<article id="write"><h1>测试</h1></article>',
bodyHtml: "<h1>测试</h1>",
metadata: {
title: "测试",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
features: ["mermaid"],
warnings: []
},
fileName: "测试.md",
themeCss: "#write { color: black; }",
exportConfig: defaultExportConfig
});
describe("桌面 PDF 载荷", () => {
it("校验并保留合法的共享分页载荷", () => {
expect(parsePagedDocumentPayload(payload)).toEqual(payload);
});
it("拒绝无效功能标识", () => {
expect(() =>
parsePagedDocumentPayload({
...payload,
features: ["remote-script"]
})
).toThrow();
});
it("映射为与 Playwright 对齐的 Electron 打印参数", () => {
expect(
createElectronPdfPrintOptions(payload, {
pageCount: 3,
echartsErrors: [],
mermaidErrors: [],
timings: {
setupMs: 0,
echartsMs: 0,
echartsFitMs: 0,
mermaidMs: 0,
mermaidFitMs: 0,
mermaidConversionMs: 0,
resourceWaitMs: 0,
paginationMs: 0,
finalizeMs: 0,
totalMs: 0
}
})
).toEqual({
displayHeaderFooter: false,
margins: { top: 0, right: 0, bottom: 0, left: 0 },
pageRanges: "1-3",
preferCSSPageSize: true,
printBackground: true,
scale: 1
});
});
});
describe("桌面 PDF 网络边界", () => {
const renderUrl =
"http://localhost:5173/preview-frame.html?target=pdf";
it("允许同源和内嵌资源", () => {
expect(
isAllowedPdfRuntimeUrl(
"http://localhost:5173/assets/runtime.js",
renderUrl
)
).toBe(true);
expect(isAllowedPdfRuntimeUrl("data:image/png;base64,AA", renderUrl))
.toBe(true);
expect(
isAllowedPdfRuntimeUrl(
"mdpdf://theme/typora-like/font.woff2",
renderUrl
)
).toBe(true);
});
it("拒绝外部网络资源", () => {
expect(
isAllowedPdfRuntimeUrl("https://example.com/image.png", renderUrl)
).toBe(false);
});
});
describe("桌面应用服务 IPC 载荷", () => {
it("只提取 Markdown 渲染所需字段", () => {
expect(
parseMarkdownRenderRequest({
markdown: "# 测试",
language: "zh-CN",
ignored: true
})
).toEqual({
markdown: "# 测试",
language: "zh-CN"
});
});
it("校验主题 ID 与主题资源地址", () => {
expect(parseThemeId("typora-like")).toBe("typora-like");
expect(
parseThemeResourceUrl(
"mdpdf://theme/typora-like/fonts/test.woff2"
)
).toEqual({
themeId: "typora-like",
assetPath: "fonts/test.woff2"
});
expect(() => parseThemeId("../outside")).toThrow("主题 ID 无效");
expect(
parseThemeResourceUrl("mdpdf://bundle/index.html")
).toBeUndefined();
});
});
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"ES2022",
"DOM"
],
"types": [
"node"
],
"noEmit": true
},
"include": [
"src",
"tests"
]
}
+1
View File
@@ -11,6 +11,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
},
"dependencies": {
"@md-to-pdf/application": "0.4.0",
"@md-to-pdf/core": "0.1.0",
"@md-to-pdf/renderer": "0.1.0",
"fastify": "^5.6.2",
+44 -121
View File
@@ -10,10 +10,11 @@ import {
supportedPaperFormats
} from "@md-to-pdf/core";
import {
MarkdownDocumentParseError,
RENDERER_VERSION,
renderMarkdown
} from "@md-to-pdf/renderer";
ApplicationRequestError,
createApplicationService,
type ApplicationService
} from "@md-to-pdf/application";
import { RENDERER_VERSION } from "@md-to-pdf/renderer";
import {
createPdfGenerator,
PdfEngineClosedError,
@@ -21,7 +22,6 @@ import {
PdfRenderTimeoutError,
type PdfGenerator
} from "./pdf-engine.js";
import { createThemeRegistry } from "./theme-registry.js";
interface RenderRequestBody {
markdown?: unknown;
@@ -34,66 +34,6 @@ interface PdfRequestBody extends RenderRequestBody {
}
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
const maximumMarkdownLength = 1_500_000;
function validateMarkdownRequest(
body: RenderRequestBody | undefined
):
| { markdown: string; language?: string }
| { statusCode: 400 | 413; error: string; message: string } {
const { markdown, language } = body ?? {};
if (typeof markdown !== "string") {
return {
statusCode: 400,
error: "INVALID_MARKDOWN",
message: "markdown 必须是字符串"
};
}
if (markdown.length > maximumMarkdownLength) {
return {
statusCode: 413,
error: "MARKDOWN_TOO_LARGE",
message: "Markdown 内容不能超过 1.5 MB"
};
}
if (language !== undefined && typeof language !== "string") {
return {
statusCode: 400,
error: "INVALID_LANGUAGE",
message: "language 必须是字符串"
};
}
return {
markdown,
...(typeof language === "string" ? { language } : {})
};
}
function renderMarkdownRequest(
markdown: string,
language: string | undefined
) {
try {
return {
success: true as const,
document: renderMarkdown(markdown, {
...(language ? { language } : {})
})
};
} catch (error) {
if (error instanceof MarkdownDocumentParseError) {
return {
success: false as const,
error: error.code,
message: error.message
};
}
throw error;
}
}
function encodeRfc5987(value: string) {
return encodeURIComponent(value).replace(
/[!'()*]/g,
@@ -127,6 +67,7 @@ export interface BuildAppOptions {
logger?: boolean;
pdfGenerator?: PdfGenerator;
prewarmPdfBrowser?: boolean;
applicationService?: ApplicationService;
}
function milliseconds(value: number) {
@@ -169,12 +110,14 @@ export function buildApp(options: BuildAppOptions = {}) {
logger: options.logger ?? true,
bodyLimit: 2 * 1024 * 1024
});
const themes = createThemeRegistry({
bundledRoot: resolve(projectRoot, "themes"),
localRoot:
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"),
onWarning: (message) => app.log.warn(message)
});
const applicationService =
options.applicationService ??
createApplicationService({
bundledRoot: resolve(projectRoot, "themes"),
localRoot:
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"),
onWarning: (message) => app.log.warn(message)
});
const pdfGenerator =
options.pdfGenerator ?? createPdfGenerator();
@@ -213,23 +156,16 @@ export function buildApp(options: BuildAppOptions = {}) {
planned: []
}));
app.get("/api/themes", async () => ({
themes: (await themes.list()).map(({ manifest, source }) => ({
id: manifest.id,
name: manifest.name,
version: manifest.version,
description: manifest.description,
bundled: manifest.bundled,
source
}))
}));
app.get("/api/themes", async () =>
applicationService.listThemes()
);
app.get<{ Params: { themeId: string } }>(
"/api/themes/:themeId/css",
async (request, reply) => {
const { themeId } = request.params;
const css = await themes.getCss(themeId);
const css = await applicationService.getThemeCss(themeId);
if (css === undefined) {
return reply.code(404).send({
error: "THEME_NOT_FOUND",
@@ -248,7 +184,7 @@ export function buildApp(options: BuildAppOptions = {}) {
"/api/themes/:themeId/assets/*",
async (request, reply) => {
try {
const asset = await themes.getAsset(
const asset = await applicationService.getThemeAsset(
request.params.themeId,
request.params["*"]
);
@@ -277,25 +213,17 @@ export function buildApp(options: BuildAppOptions = {}) {
app.post<{ Body: RenderRequestBody }>(
"/api/render",
async (request, reply) => {
const validated = validateMarkdownRequest(request.body);
if ("statusCode" in validated) {
return reply.code(validated.statusCode).send({
error: validated.error,
message: validated.message
});
try {
return applicationService.render(request.body ?? {});
} catch (error) {
if (error instanceof ApplicationRequestError) {
return reply.code(error.statusCode).send({
error: error.code,
message: error.message
});
}
throw error;
}
const rendered = renderMarkdownRequest(
validated.markdown,
validated.language
);
if (!rendered.success) {
return reply.code(400).send({
error: rendered.error,
message: rendered.message
});
}
return rendered.document;
}
);
@@ -303,14 +231,6 @@ export function buildApp(options: BuildAppOptions = {}) {
"/api/pdf",
async (request, reply) => {
const requestStartedAt = performance.now();
const validated = validateMarkdownRequest(request.body);
if ("statusCode" in validated) {
return reply.code(validated.statusCode).send({
error: validated.error,
message: validated.message
});
}
const parsedConfig = exportConfigSchema.safeParse(
request.body?.exportConfig
);
@@ -333,7 +253,9 @@ export function buildApp(options: BuildAppOptions = {}) {
}
const themeStartedAt = performance.now();
const themeCss = await themes.getCss(parsedConfig.data.themeId);
const themeCss = await applicationService.getThemeCss(
parsedConfig.data.themeId
);
const themeMs = performance.now() - themeStartedAt;
if (themeCss === undefined) {
return reply.code(404).send({
@@ -343,18 +265,19 @@ export function buildApp(options: BuildAppOptions = {}) {
}
const markdownStartedAt = performance.now();
const renderedResult = renderMarkdownRequest(
validated.markdown,
validated.language
);
const markdownMs = performance.now() - markdownStartedAt;
if (!renderedResult.success) {
return reply.code(400).send({
error: renderedResult.error,
message: renderedResult.message
});
let rendered;
try {
rendered = applicationService.render(request.body ?? {});
} catch (error) {
if (error instanceof ApplicationRequestError) {
return reply.code(error.statusCode).send({
error: error.code,
message: error.message
});
}
throw error;
}
const rendered = renderedResult.document;
const markdownMs = performance.now() - markdownStartedAt;
try {
const generated = await pdfGenerator.generate(
+1 -1
View File
@@ -8,7 +8,7 @@ import {
createPdfContentDisposition,
createPdfFileName
} from "../src/app.js";
import { createThemeRegistry } from "../src/theme-registry.js";
import { createThemeRegistry } from "@md-to-pdf/application";
import {
PdfEngineOverloadedError,
type PdfGenerator
+12
View File
@@ -2,6 +2,18 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link
rel="apple-touch-icon"
href="/apple-touch-icon.png"
sizes="180x180"
/>
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#1f2937" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data: mdpdf:; img-src 'self' data: blob: mdpdf:; connect-src 'self' ws://localhost:5173; frame-src 'self' blob:; worker-src 'self' blob:;"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; connect-src 'self';"
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self' data: mdpdf:; img-src 'self' data: mdpdf:; connect-src 'self';"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
+15 -3
View File
@@ -37,9 +37,11 @@ import {
downloadPdf,
getCachedPdfExport,
getOrRequestPdfExport,
requestDesktopPdfExport,
type CachedPdfExport,
type PdfExportRequest
} from "./pdf-export";
import { APP_VERSION_LABEL } from "./app-version";
import { getSyncedScrollTop } from "./scroll-sync";
import {
loadPreviewZoom,
@@ -832,7 +834,10 @@ export function App() {
try {
const resolved = await getOrRequestPdfExport(
pdfRequest,
pdfCache
pdfCache,
previewPayload && window.mdToPdfDesktop
? () => requestDesktopPdfExport(previewPayload)
: undefined
);
if (pdfRequestKeyRef.current !== requestedKey) {
setStatus("文档已更新,已丢弃过期 PDF");
@@ -841,7 +846,11 @@ export function App() {
setPdfCache(resolved);
const exported = resolved.result;
if (download) {
downloadPdf(exported.blob, exported.fileName);
const saved = await downloadPdf(exported.blob, exported.fileName);
if (!saved) {
setStatus("已取消 PDF 保存");
return;
}
}
const pageDescription =
exported.pageCount === undefined
@@ -893,7 +902,10 @@ export function App() {
<div className="topbar-primary">
<div className="topbar-brand">
<p className="eyebrow"></p>
<h1>Markdown PDF </h1>
<div className="topbar-brand-title">
<h1>Markdown PDF </h1>
<span className="app-version">{APP_VERSION_LABEL}</span>
</div>
</div>
<div className="document-summary">
<span className="panel-kicker"></span>
+2
View File
@@ -0,0 +1,2 @@
export const APP_VERSION = __APP_VERSION__;
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
+96
View File
@@ -0,0 +1,96 @@
import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
export interface ThemeSummary {
id: string;
name: string;
version: string;
description: string;
bundled: boolean;
source: "bundled" | "local";
}
export interface MarkdownRenderInput {
markdown: string;
language: string;
}
function throwIfAborted(signal?: AbortSignal) {
signal?.throwIfAborted();
}
async function readErrorMessage(
response: Response,
fallback: string
) {
try {
const payload = (await response.json()) as { message?: unknown };
return typeof payload.message === "string"
? payload.message
: fallback;
} catch {
return fallback;
}
}
export async function renderMarkdownDocument(
input: MarkdownRenderInput,
signal?: AbortSignal
) {
throwIfAborted(signal);
if (window.mdToPdfDesktop) {
const result = await window.mdToPdfDesktop.renderMarkdown(input);
throwIfAborted(signal);
return result;
}
const response = await fetch("/api/render", {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify(input),
...(signal ? { signal } : {})
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, "渲染失败"));
}
return response.json() as Promise<RenderedMarkdownDocument>;
}
export async function listThemes(signal?: AbortSignal) {
throwIfAborted(signal);
if (window.mdToPdfDesktop) {
const result = await window.mdToPdfDesktop.listThemes();
throwIfAborted(signal);
return result;
}
const response = await fetch("/api/themes", {
...(signal ? { signal } : {})
});
if (!response.ok) {
throw new Error("无法加载主题清单");
}
return response.json() as Promise<{ themes: ThemeSummary[] }>;
}
export async function getThemeCss(
themeId: string,
signal?: AbortSignal
) {
throwIfAborted(signal);
if (window.mdToPdfDesktop) {
const result = await window.mdToPdfDesktop.getThemeCss(themeId);
throwIfAborted(signal);
return result;
}
const response = await fetch(
`/api/themes/${encodeURIComponent(themeId)}/css`,
{ ...(signal ? { signal } : {}) }
);
if (!response.ok) {
throw new Error("无法加载主题");
}
return response.text();
}
+24
View File
@@ -87,6 +87,30 @@ window.__mdToPdfRender = async (
};
document.documentElement.dataset.runtimeReady = "true";
const desktopPdfBridge = window.__mdToPdfDesktopPdf;
if (desktopPdfBridge) {
desktopPdfBridge.onRender(async (requestId, payload) => {
const requestAnimationFrame = window.requestAnimationFrame;
window.requestAnimationFrame = (callback) =>
window.setTimeout(() => callback(performance.now()), 0);
try {
const result = await window.__mdToPdfRender?.(payload, "pdf");
if (!result) {
throw new Error("PDF 分页运行时不可用");
}
desktopPdfBridge.complete(requestId, result);
} catch (reason: unknown) {
desktopPdfBridge.fail(
requestId,
reason instanceof Error ? reason.message : "PDF 分页失败"
);
} finally {
window.requestAnimationFrame = requestAnimationFrame;
}
});
desktopPdfBridge.ready();
}
window.addEventListener("message", (event: MessageEvent<unknown>) => {
if (
event.origin !== window.location.origin ||
+32 -2
View File
@@ -1,4 +1,7 @@
import type { ExportConfig } from "@md-to-pdf/core";
import type {
ExportConfig,
PagedDocumentPayload
} from "@md-to-pdf/core";
export interface PdfExportRequest {
markdown: string;
@@ -129,11 +132,38 @@ export async function requestPdfExport(
};
}
export function downloadPdf(blob: Blob, fileName: string) {
export async function requestDesktopPdfExport(
payload: PagedDocumentPayload
): Promise<PdfExportResult> {
const bridge = window.mdToPdfDesktop;
if (!bridge) {
throw new Error("桌面 PDF 引擎不可用");
}
const result = await bridge.generatePdf(payload);
const pdfBytes = Uint8Array.from(result.pdf);
return {
blob: new Blob([pdfBytes.buffer], { type: "application/pdf" }),
fileName: payload.fileName.replace(/\.(md|markdown)$/i, "") + ".pdf",
pageCount: result.pageCount,
echartsErrorCount: result.echartsErrors.length,
mermaidErrorCount: result.mermaidErrors.length
};
}
export async function downloadPdf(blob: Blob, fileName: string) {
if (window.mdToPdfDesktop) {
return window.mdToPdfDesktop.savePdf(
fileName,
new Uint8Array(await blob.arrayBuffer())
);
}
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
return true;
}
+24
View File
@@ -75,6 +75,24 @@ button:disabled {
flex: none;
}
.topbar-brand-title {
display: flex;
align-items: baseline;
gap: 9px;
}
.app-version {
padding: 2px 7px;
border: 1px solid #c8d5cf;
border-radius: 999px;
background: #edf3f0;
color: #527061;
font-size: 0.66rem;
font-weight: 800;
letter-spacing: 0.035em;
white-space: nowrap;
}
.document-summary {
display: flex;
flex-direction: column;
@@ -1029,6 +1047,12 @@ textarea:focus {
}
@media (max-width: 520px) {
.topbar-brand-title {
align-items: flex-start;
flex-direction: column;
gap: 5px;
}
.settings-drawer {
width: 100vw;
}
+5 -21
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
import { renderMarkdownDocument } from "./application-backend";
export interface MarkdownRenderCallbacks {
onRenderStart?: () => void;
@@ -23,27 +24,10 @@ export function useMarkdownRender(
onRenderStart?.();
setError("");
void fetch("/api/render", {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify({ markdown, language }),
signal: controller.signal
})
.then(async (response) => {
const payload = (await response.json()) as
| RenderedMarkdownDocument
| { message?: string };
if (!response.ok) {
throw new Error(
"message" in payload
? payload.message ?? "渲染失败"
: "渲染失败"
);
}
return payload as RenderedMarkdownDocument;
})
void renderMarkdownDocument(
{ markdown, language },
controller.signal
)
.then((payload) => {
setResult(payload);
onStatusChange?.("预览已更新");
+8 -26
View File
@@ -1,13 +1,11 @@
import { useEffect, useState } from "react";
import {
getThemeCss,
listThemes,
type ThemeSummary
} from "./application-backend";
export interface ThemeSummary {
id: string;
name: string;
version: string;
description: string;
bundled: boolean;
source: "bundled" | "local";
}
export type { ThemeSummary } from "./application-backend";
export function useThemeResources(
themeId: string,
@@ -21,15 +19,7 @@ export function useThemeResources(
useEffect(() => {
const controller = new AbortController();
void fetch("/api/themes", {
signal: controller.signal
})
.then(async (response) => {
if (!response.ok) {
throw new Error("无法加载主题清单");
}
return response.json() as Promise<{ themes: ThemeSummary[] }>;
})
void listThemes(controller.signal)
.then(({ themes: availableThemes }) => {
setThemes(availableThemes);
setCatalogError("");
@@ -51,15 +41,7 @@ export function useThemeResources(
setThemeCss("");
setCssError("");
void fetch(`/api/themes/${encodeURIComponent(themeId)}/css`, {
signal: controller.signal
})
.then(async (response) => {
if (!response.ok) {
throw new Error("无法加载主题");
}
return response.text();
})
void getThemeCss(themeId, controller.signal)
.then(setThemeCss)
.catch((reason: unknown) => {
if (!controller.signal.aborted) {
+57
View File
@@ -1 +1,58 @@
/// <reference types="vite/client" />
import type {
PagedDocumentPayload,
PagedDocumentRenderResult,
RenderedMarkdownDocument
} from "@md-to-pdf/core";
declare global {
const __APP_VERSION__: string;
interface DesktopPdfResult {
pdf: Uint8Array;
pageCount: number;
echartsErrors: string[];
mermaidErrors: string[];
}
interface DesktopApplicationBridge {
renderMarkdown(input: {
markdown: string;
language: string;
}): Promise<RenderedMarkdownDocument>;
listThemes(): Promise<{
themes: Array<{
id: string;
name: string;
version: string;
description: string;
bundled: boolean;
source: "bundled" | "local";
}>;
}>;
getThemeCss(themeId: string): Promise<string>;
generatePdf(payload: PagedDocumentPayload): Promise<DesktopPdfResult>;
savePdf(fileName: string, pdf: Uint8Array): Promise<boolean>;
}
interface DesktopPdfRuntimeBridge {
ready(): void;
onRender(
listener: (
requestId: number,
payload: PagedDocumentPayload
) => Promise<void> | void
): () => void;
complete(
requestId: number,
result: PagedDocumentRenderResult
): void;
fail(requestId: number, message: string): void;
}
interface Window {
mdToPdfDesktop?: DesktopApplicationBridge;
__mdToPdfDesktopPdf?: DesktopPdfRuntimeBridge;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import {
APP_VERSION,
APP_VERSION_LABEL
} from "../src/app-version";
describe("应用版本", () => {
it("从统一构建版本生成标题徽标", () => {
expect(APP_VERSION).toBe("0.4.0");
expect(APP_VERSION_LABEL).toBe("v0.4.0");
});
});
+124
View File
@@ -0,0 +1,124 @@
import {
afterEach,
describe,
expect,
it,
vi
} from "vitest";
import {
getThemeCss,
listThemes,
renderMarkdownDocument
} from "../src/application-backend";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("应用后端适配器", () => {
it("在桌面环境通过窄 IPC 调用共享服务", async () => {
const rendered = {
rendererVersion: 1 as const,
articleHtml: '<article id="write">测试</article>',
bodyHtml: "测试",
metadata: {
title: "测试",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
features: [],
warnings: []
};
const renderMarkdown = vi.fn(async () => rendered);
const listDesktopThemes = vi.fn(async () => ({
themes: [
{
id: "test",
name: "测试",
version: "1",
description: "",
bundled: true,
source: "bundled" as const
}
]
}));
const getDesktopThemeCss = vi.fn(async () => "#write {}");
vi.stubGlobal("window", {
mdToPdfDesktop: {
renderMarkdown,
listThemes: listDesktopThemes,
getThemeCss: getDesktopThemeCss
}
});
await expect(
renderMarkdownDocument({
markdown: "# 测试",
language: "zh-CN"
})
).resolves.toBe(rendered);
await expect(listThemes()).resolves.toEqual({
themes: [expect.objectContaining({ id: "test" })]
});
await expect(getThemeCss("test")).resolves.toBe("#write {}");
expect(renderMarkdown).toHaveBeenCalledWith({
markdown: "# 测试",
language: "zh-CN"
});
expect(getDesktopThemeCss).toHaveBeenCalledWith("test");
});
it("在浏览器环境保持现有 HTTP 协议", async () => {
vi.stubGlobal("window", {});
const fetcher = vi
.fn()
.mockResolvedValueOnce(
Response.json({
rendererVersion: 1,
articleHtml: '<article id="write">测试</article>',
bodyHtml: "测试",
metadata: {
title: "测试",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
features: [],
warnings: []
})
)
.mockResolvedValueOnce(
Response.json({ themes: [] })
)
.mockResolvedValueOnce(
new Response("#write {}", { status: 200 })
);
vi.stubGlobal("fetch", fetcher);
await renderMarkdownDocument({
markdown: "# 测试",
language: "zh-CN"
});
await listThemes();
await getThemeCss("typora-like");
expect(fetcher).toHaveBeenNthCalledWith(
1,
"/api/render",
expect.objectContaining({ method: "POST" })
);
expect(fetcher).toHaveBeenNthCalledWith(
2,
"/api/themes",
{}
);
expect(fetcher).toHaveBeenNthCalledWith(
3,
"/api/themes/typora-like/css",
{}
);
});
});
+62 -2
View File
@@ -1,15 +1,30 @@
import { describe, expect, it, vi } from "vitest";
import { defaultExportConfig } from "@md-to-pdf/core";
import {
afterEach,
describe,
expect,
it,
vi
} from "vitest";
import {
createPagedDocumentPayload,
defaultExportConfig
} from "@md-to-pdf/core";
import {
createPdfExportCacheKey,
downloadPdf,
getCachedPdfExport,
getOrRequestPdfExport,
parseContentDispositionFileName,
requestDesktopPdfExport,
requestPdfExport,
type CachedPdfExport,
type PdfExportRequest
} from "../src/pdf-export";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("PDF 导出客户端", () => {
it("仅复用与当前文档和导出配置完全匹配的 PDF", () => {
const request: PdfExportRequest = {
@@ -178,4 +193,49 @@ describe("PDF 导出客户端", () => {
)
).rejects.toThrow("PDF 生成队列已满");
});
it("桌面端使用共享分页载荷生成并保存 PDF", async () => {
const payload = createPagedDocumentPayload({
document: {
rendererVersion: 1,
articleHtml: '<article id="write">测试</article>',
bodyHtml: "测试",
metadata: {
title: "测试",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
features: [],
warnings: []
},
fileName: "测试.md",
themeCss: "",
exportConfig: defaultExportConfig
});
const generatePdf = vi.fn(async () => ({
pdf: new TextEncoder().encode("%PDF-desktop"),
pageCount: 2,
echartsErrors: [],
mermaidErrors: ["图表 1"]
}));
const savePdf = vi.fn(async () => true);
vi.stubGlobal("window", {
mdToPdfDesktop: { generatePdf, savePdf }
});
const generated = await requestDesktopPdfExport(payload);
const saved = await downloadPdf(generated.blob, generated.fileName);
expect(generatePdf).toHaveBeenCalledWith(payload);
expect(generated.fileName).toBe("测试.pdf");
expect(generated.pageCount).toBe(2);
expect(generated.mermaidErrorCount).toBe(1);
expect(savePdf).toHaveBeenCalledWith(
"测试.pdf",
expect.any(Uint8Array)
);
expect(saved).toBe(true);
});
});
+14
View File
@@ -1,9 +1,23 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const rootPackage = JSON.parse(
readFileSync(
fileURLToPath(new URL("../../package.json", import.meta.url)),
"utf8"
)
) as { version: string };
export default defineConfig({
plugins: [react()],
publicDir: fileURLToPath(
new URL("../../logos/web", import.meta.url)
),
define: {
__APP_VERSION__: JSON.stringify(rootPackage.version)
},
build: {
rollupOptions: {
input: {
+34 -11
View File
@@ -1,5 +1,7 @@
# syntax=docker/dockerfile:1
ARG RUNTIME_BASE_IMAGE=node:22-bookworm-slim
FROM node:22-bookworm-slim AS build
WORKDIR /app
@@ -9,6 +11,7 @@ COPY package.json package-lock.json ./
COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/core/package.json packages/core/package.json
COPY packages/application/package.json packages/application/package.json
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
COPY packages/renderer/package.json packages/renderer/package.json
RUN npm ci
@@ -16,7 +19,13 @@ RUN npm ci
COPY tsconfig.base.json ./
COPY apps ./apps
COPY packages ./packages
RUN npm run build
COPY logos ./logos
RUN npm run build -w @md-to-pdf/markdown-echarts \
&& npm run build -w @md-to-pdf/core \
&& npm run build -w @md-to-pdf/renderer \
&& npm run build -w @md-to-pdf/application \
&& npm run build -w @md-to-pdf/web \
&& npm run build -w @md-to-pdf/server
FROM node:22-bookworm-slim AS production-dependencies
@@ -27,21 +36,28 @@ COPY package.json package-lock.json ./
COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/core/package.json packages/core/package.json
COPY packages/application/package.json packages/application/package.json
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
COPY packages/renderer/package.json packages/renderer/package.json
RUN npm ci --omit=dev --workspace @md-to-pdf/server --include-workspace-root
FROM node:22-bookworm-slim AS runtime
FROM ${RUNTIME_BASE_IMAGE} AS runtime
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
nginx \
fonts-noto-cjk \
fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system pwuser \
&& useradd --system --create-home --gid pwuser --shell /bin/bash pwuser
ARG RUNTIME_BASE_IMAGE
ARG REUSE_PLAYWRIGHT_RUNTIME=0
RUN if [ "${REUSE_PLAYWRIGHT_RUNTIME}" = "1" ]; then \
echo "Reusing the Playwright runtime from ${RUNTIME_BASE_IMAGE}"; \
else \
apt-get update \
&& apt-get install -y --no-install-recommends \
nginx \
fonts-noto-cjk \
fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system pwuser \
&& useradd --system --create-home --gid pwuser --shell /bin/bash pwuser; \
fi
WORKDIR /app
@@ -49,16 +65,23 @@ COPY --chown=pwuser:pwuser package.json package-lock.json ./
COPY --chown=pwuser:pwuser apps/server/package.json apps/server/package.json
COPY --chown=pwuser:pwuser apps/web/package.json apps/web/package.json
COPY --chown=pwuser:pwuser packages/core/package.json packages/core/package.json
COPY --chown=pwuser:pwuser packages/application/package.json packages/application/package.json
COPY --chown=pwuser:pwuser packages/markdown-echarts/package.json packages/markdown-echarts/package.json
COPY --chown=pwuser:pwuser packages/renderer/package.json packages/renderer/package.json
COPY --chown=pwuser:pwuser --from=production-dependencies /app/node_modules ./node_modules
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
RUN npx playwright install --with-deps chromium \
RUN if [ "${REUSE_PLAYWRIGHT_RUNTIME}" = "1" ]; then \
test -x /ms-playwright/chromium-*/chrome-linux/chrome \
|| test -x /ms-playwright/chromium-*/chrome-linux64/chrome; \
else \
npx playwright install --with-deps chromium; \
fi \
&& chown -R pwuser:pwuser /ms-playwright
COPY --chown=pwuser:pwuser --from=build /app/apps/server/dist ./apps/server/dist
COPY --chown=pwuser:pwuser --from=build /app/apps/web/dist ./apps/web/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/core/dist ./packages/core/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/application/dist ./packages/application/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/markdown-echarts/dist ./packages/markdown-echarts/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/renderer/dist ./packages/renderer/dist
COPY --chown=pwuser:pwuser themes ./themes
+2
View File
@@ -2,9 +2,11 @@
.github
.local
.vscode
apps/desktop
coverage
dist
node_modules
out
output
playwright-report
test-results
+23 -5
View File
@@ -34,13 +34,27 @@ docker compose -f deploy\compose.yaml down
## 构建发布标签
本地验证完成后,可以为镜像增加发布标签
可以直接指定版本化镜像名称构建
```powershell
docker tag md-to-pdf:local yixiong/md-to-pdf:v0.1.0
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.0'
docker compose -f deploy\compose.yaml build
```
该命令只创建本地标签,不会自动推送到镜像仓库。
该命令只生成本地镜像,不会自动推送到镜像仓库。
默认构建会安装项目锁定版本的 Playwright Chromium 及系统依赖。如果本机
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
```powershell
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.0'
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.3.1'
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
docker compose -f deploy\compose.yaml build
```
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.4.0 应用代码和生产
依赖。正式跨机器构建仍建议使用默认完整路径。
## 主题挂载
@@ -55,13 +69,17 @@ docker compose -f deploy\compose.yaml up -d
目录下每套主题都应包含 `theme.json`。主题规范详见
[`docs/THEMES.md`](../docs/THEMES.md)。
本机通过导入工具生成的 Typora 主题不会复制进镜像;只有显式挂载后,
容器才会读取这些本机文件。
从 v0.4.0 起,公司内部私有版本已内置 GitHub、Pixyll 和 Whitey 三套
Typora 默认主题;挂载目录仍可用于本机扩展主题。内置主题与挂载主题 ID
重复时以内置版本为准。
## 运行参数
| 环境变量 | 默认值 | 说明 |
| --- | ---: | --- |
| `MD_TO_PDF_IMAGE` | `md-to-pdf:local` | 构建和运行的镜像标签 |
| `MD_TO_PDF_RUNTIME_BASE_IMAGE` | `node:22-bookworm-slim` | 可选运行时基础镜像 |
| `MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME` | `0` | 是否复用基础镜像中的 Chromium |
| `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 |
| `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 |
| `PDF_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 |
+3
View File
@@ -5,6 +5,9 @@ services:
build:
context: ..
dockerfile: deploy/Dockerfile
args:
RUNTIME_BASE_IMAGE: ${MD_TO_PDF_RUNTIME_BASE_IMAGE:-node:22-bookworm-slim}
REUSE_PLAYWRIGHT_RUNTIME: ${MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME:-0}
image: "${MD_TO_PDF_IMAGE:-md-to-pdf:local}"
init: true
ports:
+111 -10
View File
@@ -33,8 +33,9 @@ ec48bce chore: 初始化项目骨架
最新阶段已完成 Markdown ECharts YAML 围栏、统一 SVG 渲染与分页,
并完善默认示例、源文本收起、顶栏文档状态和快速页码跳转。
当前正在推进 `v0.3.1` ECharts 第二阶段,扩展层级图、关系图和更多
业务图表类型。
`v0.4.0` 桌面端阶段已经完成:共享应用服务、无 Server 的 Electron
运行时、三套内置主题、统一品牌资源、Windows 安装包以及 Docker Compose
发布候选均已通过验证。
## 2. 已完成
@@ -80,7 +81,10 @@ ec48bce chore: 初始化项目骨架
- 主题 CSS 相对资源重写与受限资源接口;
- 目录匹配、真实路径包含关系和外部 URL 安全检查。
内置主题项目自有实现,没有复制 Typora 官方默认主题。用户可以通过导入工具将本机已安装的 Typora 基础 CSS以及 GitHub、Pixyll 和 Whitey 三套适合打印的白色默认主题复制到 Git 忽略的 `.local/themes`。这些副本仅供本机使用,不进入仓库、容器或发布包。
内置主题包含项目自有的 Typora 风格主题,以及按公司内部私有项目决策
复制的 GitHub、Pixyll 和 Whitey 三套 Typora 默认主题。三套复制主题的
来源和内部使用边界记录在 `themes/INTERNAL_THEME_NOTICE.md`;如未来公开、
商用或向公司外部分发,必须重新完成许可证审查。
### 2.4 Markdown 渲染核心
@@ -319,6 +323,57 @@ ec48bce chore: 初始化项目骨架
强制关闭 ECharts 动画、动画延迟和持续时间,不允许 YAML 动画参数使
预览或 PDF 停留在动画中间帧。
### 3.12 v0.4.0 Electron 桌面端
- 新增 `apps/desktop` Electron 43.2.0 工作区,使用 Electron Forge
7.11.2 打包。
- 根项目版本升级为 `0.4.0`Vite 从根 `package.json` 注入统一版本,
工具标题右侧显示 `v0.4.0` 徽标。
- 应用窗口和 PDF 窗口均关闭 Node Integration,启用 Context Isolation
和 Chromium Sandbox,并使用不同 preload 暴露窄 IPC 接口。
- 桌面端 PDF 直接接收 Web 端已经构造的共享分页载荷,复用现有
`PagedDocumentRuntime`、主题 CSS、Mermaid 和 ECharts 运行时。
- 隐藏 PDF 窗口使用 Electron 自带 Chromium 的
`webContents.printToPDF()`,桌面发行包不携带 Playwright Chromium。
- Paged.js 的帧队列在纯隐藏窗口中不会推进;桌面 PDF 任务期间使用
计时器调度分页队列,分页完成后恢复原生 `requestAnimationFrame`
- Chromium 打印前将透明、不可聚焦、不进入任务栏的 PDF 窗口短暂放到
屏幕外,使复杂分页页面拥有可打印的窗口表面,用户不会看到该窗口。
- PDF Buffer 通过受控 IPC 返回 Web 界面,桌面端使用系统“另存为”对话框
写入文件;Web 版继续使用浏览器下载行为。
- 生产页面通过 `mdpdf://bundle/` 自定义安全协议加载,不使用权限过宽的
`file://`PDF Session 只允许同源、`data:``blob:``about:`
资源。
- Windows x64 未签名目录包、Squirrel `Setup.exe` 和 ZIP 已由 Forge
成功生成;`app.asar` 只包含
编译后的主进程、preload 和清单,Web 静态资源作为只读资源单独打包。
- 新增 `packages/application`,统一封装 Markdown 渲染、主题清单、主题
CSS 和主题资源读取;Fastify 仅作为 HTTP 适配器。
- Web 端通过应用后端抽象选择 HTTP 或 Electron IPC;桌面端通过受限 IPC
直接调用共享服务,不启动 Fastify,不占用本地 HTTP 端口。
- 主题资源通过 `mdpdf://theme/` 安全协议提供,应用窗口与独立 PDF
Session 均完成协议注册和来源限制。
- 修复 Electron 主进程 ESM Bundle 中 YAML 依赖动态加载 `process`
失败的问题,构建时注入 Node `createRequire`
- `logos/` 作为统一品牌源:Web 使用 favicon、Apple Touch Icon 和
Web ManifestElectron 使用 ICO、ICNS、窗口图标和安装器图标。
- Forge 输出目录包含版本号,正式内部交付使用 Squirrel `Setup.exe`
ZIP 作为免安装辅助包;当前未签名。
### 3.13 v0.4.0 内置主题与发布产物
- `.local/themes` 中 GitHub、Pixyll 和 Whitey 三套主题已复制到
`themes/`,从 v0.4.0 起随 Web 容器和桌面安装包内置。
- 内置主题注册优先于相同 ID 的本地主题,避免 Compose 默认挂载旧副本
覆盖发布版本,并记录可诊断警告。
- Windows 正式安装包为
`Markdown PDF 导出器-0.4.0 Setup.exe`,约 137.48 MiBSHA-256 为
`79CB3ADC0C9DCB1AA2B9EFC792734FF792D410BDFC125B9BA78603EE4D98A894`
- 本地镜像为 `yixiong/md-to-pdf:v0.4.0`Compose 容器保持健康运行并
监听 `http://localhost:8080`
- Dockerfile 默认保留完整 Playwright Chromium 安装路径,同时支持通过
构建参数复用本机已验证的旧版本运行层,适合内网软件源较慢的环境。
## 4. 已执行验证
2026-07-27 在当前完整工作区成功执行:
@@ -335,13 +390,32 @@ git diff --check
- ECharts 围栏测试:36 项通过;
- 共享配置测试:9 项通过;
- 渲染器测试:10 项通过;
- 前端测试:63 项通过;
- 应用服务测试:5 项通过;
- 前端测试:67 项通过;
- 后端测试:29 项通过;
- 全项目共 147 项测试通过;
- 桌面端测试:7 项通过;
- 全项目共 163 项测试通过;
- 全项目类型检查通过;
- 生产构建通过;
- `git diff --check` 通过。
Electron 桌面端第一阶段验证结果:
- Electron 43.2.0 应用窗口成功加载现有 React/Vite 界面,标题旁正确显示
`v0.4.0`
- 最小 Markdown 通过隐藏 Electron Chromium 生成 1 页、52,308 字节
PDF,文字可搜索,并通过桌面原生保存通道落盘。
- 默认示例通过桌面端生成 4 页、439,501 字节 A4 PDFMermaid 和
10 个 ECharts 图表错误数均为 0。
- 将默认示例全部 4 页渲染为 PNG 后逐页检查,Tree、Treemap、Sunburst、
Graph、Sankey、Chord、Funnel、Gauge 和 PictorialBar 均完整显示,
未发现空白页、黑块、跨页切割或明显裁切。
- PDF.js 可以从 4 页中提取 670 个非空白字符,各页均包含可搜索文字。
- Forge 成功生成 Windows x64 未签名目录包,生产窗口通过
`mdpdf://bundle/index.html` 加载,版本徽标和界面静态资源正常。
- 精简后的 `app.asar` 约 554 KiB,Web 静态资源共 134 个文件、约
9.0 MiB;运行目录只包含 Electron 自带的一套 Chromium。
已使用浏览器插件完成视觉验证:
- 默认示例中的 ECharts 柱状图生成 1 个内联 SVG,无错误占位;
@@ -455,6 +529,21 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
独立的 `echarts` Node.js 包;
- 当前容器保留运行,等待 `v0.3.1` 发布前手动验收。
2026-07-27 已完成 `v0.4.0` 最终发布候选验证:
- 生成本地镜像 `yixiong/md-to-pdf:v0.4.0`,镜像内容 ID 为
`sha256:1bf4a136e9b822fa0925070cd522ea2adbad8b388d8e039d6509024d466666b3`
- Compose 强制替换为 v0.4.0 后健康检查通过,首页返回 200,健康接口
返回 `ok`,主题接口返回 4 个 `bundled` 主题;
- 容器 Chromium 生成中文、表格、KaTeX、代码和 Mermaid 验收 PDF
HTTP 200、1 页、147,374 字节,Mermaid/ECharts 错误数均为 0
- PDF 渲染为 PNG 后检查通过,中文、公式、表格、代码、图表、页边距和
页码均正常,无裁切、重叠、黑块或空白;
- Compose 网页真实加载后标题旁显示 `v0.4.0`,主题选择器包含 4 个内置
主题,多页预览和图表正常,浏览器控制台无错误;
- Forge Squirrel 安装包、NUPKG 和 ZIP 均已生成,安装包约 137.48 MiB,
当前 Authenticode 状态为未签名。
## 5. 当前注意事项
- `output/` 可能包含本地 PDF 验证产物,已被 Git 忽略,不得提交。
@@ -481,18 +570,30 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
- `packages/core/src/document.ts` 是渲染文档、分页载荷、运行结果和耗时的
共享模型。
- `apps/server/src/app.ts` 是新增的可测试 Fastify 应用。
- `apps/server/src/theme-registry.ts` 负责内置和本地主题发现、CSS 处理及资源安全。
- `.local/themes/typora-*` 是用户本机副本,已被 Git 忽略,不得提交
- `packages/application` 负责共享 Markdown 渲染和主题应用服务;
`apps/server``apps/desktop` 分别提供 HTTP 和 IPC 适配
- `apps/desktop/src/main.ts` 负责 Electron 生命周期、自定义应用协议、
应用窗口、IPC 校验和原生 PDF 保存。
- `apps/desktop/src/electron-pdf-generator.ts` 负责隐藏 PDF 窗口、分页
调度、网络限制、打印和窗口复用。
- `apps/desktop/src/app-preload.ts``pdf-preload.ts` 分别定义应用
窗口和 PDF 运行窗口的最小 IPC 能力。
- `.local/themes/typora-*` 仍可作为用户本机副本;v0.4.0 的三套发布版本
已复制到 `themes/` 并标记为内置主题。
- `.local/theme-backups` 中保留 GitHub、Pixyll 和 Whitey 的可恢复备份;Newsprint 和 Night 的当前副本及备份已按用户要求删除。
- Typora 官方资源是 All Rights Reserved,仅限当前用户本机使用,不进入 Git、容器或发布包。
- Typora 复制主题按公司内部私有项目决策内置,不面向外部公开或商用;
未来改变分发范围前必须重新完成许可证审查。
- 快速预览会受到客户端 Windows 显示缩放、浏览器缩放和设备像素比影响;
对页数和分页边界有严格要求时,以精确预览和最终 PDF 为准。
- 资源目录上传、本地相对图片、临时目录和路径安全尚未实现。
- AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证。
- 桌面端已通过进程内共享服务实现离线 Markdown 渲染、主题读取和 PDF
导出,不依赖 Fastify 或本地 HTTP Server。
- v0.4.0 AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证,
当前 Compose 容器保持运行。
## 6. 推荐接手顺序
### 阶段一:资源安全
### 阶段一:桌面文档工作区与资源安全
- Markdown 文件夹或 ZIP
- 相对图片解析;
+7 -3
View File
@@ -13,7 +13,7 @@ themes/<theme-id>/ 随项目分发的内置主题
.local/themes/<theme-id>/ 仅供当前设备使用的本地主题
```
- 内置主题的 `bundled` 必须为 `true`,并具有允许项目分发的明确许可证
- 内置主题的 `bundled` 必须为 `true`,并记录来源、许可证或内部使用边界
- 本地主题的 `bundled` 必须为 `false`,整个 `.local/` 目录已被 Git 忽略。
- 主题目录名必须与清单中的 `id` 完全一致。
@@ -262,11 +262,15 @@ npm run theme:import-typora -- --replace
.local/theme-backups/
```
Typora 当前没有为这些文件提供允许项目再分发的明确许可证,因此导入结果只能保存在 `.local`,不得提交到 Git、打包进容器或随项目发布。
Typora 当前没有为这些文件提供允许公开再分发的明确许可证。普通导入结果
仍默认保存在 `.local`。本项目从 v0.4.0 起按公司内部私有项目决策,将
GitHub、Pixyll 和 Whitey 三套主题复制为内置资源,并在
`themes/INTERNAL_THEME_NOTICE.md` 记录来源和内部使用边界;如未来公开、
商用或向公司外部分发,必须重新完成许可证审查并替换或取得授权。
## 10. 提交内置主题前检查
- 许可证明确允许再分发,并在清单和说明中记录。
- 许可证明确允许目标范围内使用,或已在清单和内部说明中记录限制
- 主题目录不包含用户文档、临时文件或无关资源。
- `npm test` 通过。
- `npm run typecheck` 通过。
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

+51
View File
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-labelledby="title description">
<title id="title">Markdown PDF Logo</title>
<desc id="description">青绿色折角文档与 M 形透明负空间组成的标志</desc>
<path
fill="#32B8AE"
fill-rule="evenodd"
d="
M 262 64
H 650
V 198
Q 650 254 706 254
H 840
V 850
Q 840 928 762 928
H 262
Q 184 928 184 850
V 142
Q 184 64 262 64
Z
M 282 792
V 498
Q 282 462 318 462
Q 334 462 350 478
L 512 640
L 674 478
Q 690 462 706 462
Q 742 462 742 498
V 792
H 650
V 626
L 548 728
Q 512 764 476 728
L 374 626
V 792
Z
"
/>
<path
fill="#FFD166"
d="
M 678 76
Q 694 76 710 92
L 812 194
Q 828 210 828 226
H 716
Q 678 226 678 188
Z
"
/>
</svg>

After

Width:  |  Height:  |  Size: 953 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

+20
View File
@@ -0,0 +1,20 @@
{
"name": "Markdown PDF 导出器",
"short_name": "Markdown PDF",
"start_url": "./",
"display": "standalone",
"background_color": "#f4f2ed",
"theme_color": "#1f2937",
"icons": [
{
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+6684 -2
View File
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -1,6 +1,6 @@
{
"name": "md-to-pdf",
"version": "0.1.0",
"version": "0.4.0",
"private": true,
"description": "可配置、可主题化的 Markdown PDF 导出工具",
"workspaces": [
@@ -8,16 +8,20 @@
"packages/*"
],
"scripts": {
"build": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/web && npm run build -w @md-to-pdf/server",
"dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && concurrently -k -n markdown-echarts,core,renderer,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:renderer\" \"npm:dev:server\" \"npm:dev:web\"",
"build": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/web && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop",
"dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && concurrently -k -n markdown-echarts,core,renderer,application,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:server\" \"npm:dev:web\"",
"dev:markdown-echarts": "npm run dev -w @md-to-pdf/markdown-echarts",
"dev:core": "npm run dev -w @md-to-pdf/core",
"dev:renderer": "npm run dev -w @md-to-pdf/renderer",
"dev:application": "npm run dev -w @md-to-pdf/application",
"dev:server": "npm run dev -w @md-to-pdf/server",
"dev:web": "npm run dev -w @md-to-pdf/web",
"dev:desktop": "npm run dev -w @md-to-pdf/desktop",
"desktop:dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"",
"desktop:package": "npm run build -w @md-to-pdf/web && npm run package -w @md-to-pdf/desktop",
"theme:import-typora": "node scripts/import-typora-theme.mjs",
"test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server",
"typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server"
"test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop",
"typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop"
},
"engines": {
"node": ">=22"
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@md-to-pdf/application",
"version": "0.4.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
},
"dependencies": {
"@md-to-pdf/core": "0.1.0",
"@md-to-pdf/renderer": "0.1.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
"vitest": "^4.1.10"
}
}
@@ -0,0 +1,99 @@
import {
MarkdownDocumentParseError,
renderMarkdown
} from "@md-to-pdf/renderer";
import {
createThemeRegistry,
type ThemeRegistryOptions
} from "./theme-registry.js";
export const MAXIMUM_MARKDOWN_LENGTH = 1_500_000;
export interface MarkdownRenderRequest {
markdown?: unknown;
language?: unknown;
}
export class ApplicationRequestError extends Error {
constructor(
readonly statusCode: 400 | 404 | 413,
readonly code: string,
message: string
) {
super(message);
this.name = "ApplicationRequestError";
}
}
export interface ApplicationServiceOptions extends ThemeRegistryOptions {}
export function createApplicationService(
options: ApplicationServiceOptions
) {
const themes = createThemeRegistry(options);
function render(request: MarkdownRenderRequest) {
const { markdown, language } = request;
if (typeof markdown !== "string") {
throw new ApplicationRequestError(
400,
"INVALID_MARKDOWN",
"markdown 必须是字符串"
);
}
if (markdown.length > MAXIMUM_MARKDOWN_LENGTH) {
throw new ApplicationRequestError(
413,
"MARKDOWN_TOO_LARGE",
"Markdown 内容不能超过 1.5 MB"
);
}
if (language !== undefined && typeof language !== "string") {
throw new ApplicationRequestError(
400,
"INVALID_LANGUAGE",
"language 必须是字符串"
);
}
try {
return renderMarkdown(markdown, {
...(language ? { language } : {})
});
} catch (error) {
if (error instanceof MarkdownDocumentParseError) {
throw new ApplicationRequestError(
400,
error.code,
error.message
);
}
throw error;
}
}
async function listThemes() {
return {
themes: (await themes.list()).map(({ manifest, source }) => ({
id: manifest.id,
name: manifest.name,
version: manifest.version,
description: manifest.description,
bundled: manifest.bundled,
source
}))
};
}
return {
getThemeAsset: themes.getAsset,
getThemeCss: themes.getCss,
invalidateThemes: themes.invalidate,
listThemes,
render
};
}
export type ApplicationService = ReturnType<
typeof createApplicationService
>;
+13
View File
@@ -0,0 +1,13 @@
export {
ApplicationRequestError,
MAXIMUM_MARKDOWN_LENGTH,
createApplicationService,
type ApplicationService,
type ApplicationServiceOptions,
type MarkdownRenderRequest
} from "./application-service.js";
export {
createThemeRegistry,
type ThemeRecord,
type ThemeRegistryOptions
} from "./theme-registry.js";
@@ -28,6 +28,7 @@ export interface ThemeRegistryOptions {
localRoot: string;
onWarning?: (message: string) => void;
cacheTtlMs?: number;
createAssetUrl?: (themeId: string, assetPath: string) => string;
}
const assetContentTypes: Record<string, string> = {
@@ -163,7 +164,8 @@ function encodeAssetPath(path: string) {
function prepareCssSegment(
css: string,
themeId: string,
cssDirectory: string
cssDirectory: string,
createAssetUrl: (themeId: string, assetPath: string) => string
) {
return css.replace(
/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi,
@@ -194,7 +196,7 @@ function prepareCssSegment(
throw new Error(`主题资源路径越界:${value}`);
}
return `url("/api/themes/${encodeURIComponent(themeId)}/assets/${encodeAssetPath(assetPath)}")`;
return `url("${createAssetUrl(themeId, assetPath)}")`;
}
);
}
@@ -219,6 +221,7 @@ function normalizeCssPath(relativePath: string) {
async function loadThemeCssFile(
theme: ThemeRecord,
relativePath: string,
createAssetUrl: (themeId: string, assetPath: string) => string,
importStack: string[] = []
): Promise<string> {
const normalizedPath = normalizeCssPath(relativePath);
@@ -246,7 +249,8 @@ async function loadThemeCssFile(
result += prepareCssSegment(
css.slice(previousEnd, matchStart),
theme.manifest.id,
cssDirectory
cssDirectory,
createAssetUrl
);
const importedPath = readImportedPath(match);
@@ -255,10 +259,12 @@ async function loadThemeCssFile(
}
const normalizedImport = normalizeCssPath(importedPath);
const importPath = posix.normalize(posix.join(cssDirectory, normalizedImport));
result += await loadThemeCssFile(theme, importPath, [
...importStack,
normalizedPath
]);
result += await loadThemeCssFile(
theme,
importPath,
createAssetUrl,
[...importStack, normalizedPath]
);
previousEnd = matchStart + match[0].length;
}
@@ -266,7 +272,8 @@ async function loadThemeCssFile(
result += prepareCssSegment(
remainder,
theme.manifest.id,
cssDirectory
cssDirectory,
createAssetUrl
);
if (/@import\b/i.test(result)) {
throw new Error("主题包含不支持的 CSS @import 语法");
@@ -276,6 +283,10 @@ async function loadThemeCssFile(
}
export function createThemeRegistry(options: ThemeRegistryOptions) {
const createAssetUrl =
options.createAssetUrl ??
((themeId: string, assetPath: string) =>
`/api/themes/${encodeURIComponent(themeId)}/assets/${encodeAssetPath(assetPath)}`);
const cacheTtlMs = options.cacheTtlMs ?? 1_000;
if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0) {
throw new Error("主题缓存有效期必须是非负有限数值");
@@ -293,7 +304,19 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
readThemeRoot(options.localRoot, "local", options.onWarning)
]);
const themes = [...bundledThemes, ...localThemes];
const bundledThemeIds = new Set(
bundledThemes.map((theme) => theme.manifest.id)
);
const uniqueLocalThemes = localThemes.filter((theme) => {
if (!bundledThemeIds.has(theme.manifest.id)) {
return true;
}
options.onWarning?.(
`已忽略与内置主题同 ID 的本地主题 ${theme.manifest.id}`
);
return false;
});
const themes = [...bundledThemes, ...uniqueLocalThemes];
const themeIds = new Set<string>();
for (const theme of themes) {
if (themeIds.has(theme.manifest.id)) {
@@ -347,7 +370,9 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
];
return (
await Promise.all(
cssFiles.map((path) => loadThemeCssFile(theme, path))
cssFiles.map((path) =>
loadThemeCssFile(theme, path, createAssetUrl)
)
)
).join("\n");
}
@@ -0,0 +1,190 @@
import {
afterEach,
describe,
expect,
it,
vi
} from "vitest";
import {
mkdtemp,
mkdir,
rm,
writeFile
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
ApplicationRequestError,
createApplicationService
} from "../src/index.js";
let temporaryDirectory: string | undefined;
afterEach(async () => {
if (temporaryDirectory) {
await rm(temporaryDirectory, {
recursive: true,
force: true
});
}
temporaryDirectory = undefined;
});
async function createThemeFixture() {
temporaryDirectory = await mkdtemp(
join(tmpdir(), "md-to-pdf-application-")
);
const bundledRoot = join(temporaryDirectory, "bundled");
const localRoot = join(temporaryDirectory, "local");
const themeRoot = join(bundledRoot, "test-theme");
await mkdir(join(themeRoot, "fonts"), { recursive: true });
await mkdir(localRoot, { recursive: true });
await writeFile(
join(themeRoot, "theme.json"),
JSON.stringify({
manifestVersion: 1,
id: "test-theme",
name: "测试主题",
version: "1.0.0",
description: "共享应用服务测试主题",
author: "test",
license: "MIT",
entry: "theme.css",
domPreset: "typora",
defaultFontSize: "16px",
supportedFeatures: ["code", "table"],
bundled: true
}),
"utf8"
);
await writeFile(
join(themeRoot, "theme.css"),
[
"@font-face {",
" font-family: Test;",
" src: url('./fonts/test.woff2') format('woff2');",
"}",
"#write { font-family: Test; }"
].join("\n"),
"utf8"
);
await writeFile(
join(themeRoot, "fonts", "test.woff2"),
Buffer.from([0, 1, 2, 3])
);
return { bundledRoot, localRoot };
}
describe("共享应用服务", () => {
it("渲染安全 Markdown 文档", async () => {
const roots = await createThemeFixture();
const service = createApplicationService(roots);
const document = service.render({
markdown: "# 文档\n\n<script>alert('xss')</script>",
language: "zh-CN"
});
expect(document.articleHtml).toContain('id="write"');
expect(document.articleHtml).not.toContain("<script");
expect(document.metadata.title).toBe("文档");
});
it("以稳定错误协议拒绝非法请求", async () => {
const roots = await createThemeFixture();
const service = createApplicationService(roots);
expect(() => service.render({ markdown: 42 })).toThrow(
expect.objectContaining<ApplicationRequestError>({
statusCode: 400,
code: "INVALID_MARKDOWN"
})
);
});
it("列出主题并使用调用方提供的资源 URL", async () => {
const roots = await createThemeFixture();
const service = createApplicationService({
...roots,
createAssetUrl: (themeId, assetPath) =>
`mdpdf://theme/${themeId}/${assetPath}`
});
await expect(service.listThemes()).resolves.toEqual({
themes: [
expect.objectContaining({
id: "test-theme",
bundled: true,
source: "bundled"
})
]
});
await expect(
service.getThemeCss("test-theme")
).resolves.toContain(
"mdpdf://theme/test-theme/fonts/test.woff2"
);
});
it("安全读取主题二进制资源", async () => {
const roots = await createThemeFixture();
const service = createApplicationService(roots);
await expect(
service.getThemeAsset("test-theme", "fonts/test.woff2")
).resolves.toEqual({
contentType: "font/woff2",
content: Buffer.from([0, 1, 2, 3])
});
await expect(
service.getThemeAsset("test-theme", "../test.woff2")
).rejects.toThrow("主题资源路径不安全");
});
it("内置主题优先于同 ID 的旧本地副本", async () => {
const roots = await createThemeFixture();
const localThemeRoot = join(roots.localRoot, "test-theme");
await mkdir(localThemeRoot, { recursive: true });
await writeFile(
join(localThemeRoot, "theme.json"),
JSON.stringify({
manifestVersion: 1,
id: "test-theme",
name: "旧本地主题",
version: "local",
description: "迁移前的本地副本",
author: "test",
license: "local-only",
entry: "theme.css",
domPreset: "typora",
defaultFontSize: "16px",
supportedFeatures: ["code", "table"],
bundled: false
}),
"utf8"
);
await writeFile(
join(localThemeRoot, "theme.css"),
"#write { color: red; }",
"utf8"
);
const onWarning = vi.fn();
const service = createApplicationService({
...roots,
onWarning
});
await expect(service.listThemes()).resolves.toEqual({
themes: [
expect.objectContaining({
id: "test-theme",
name: "测试主题",
bundled: true
})
]
});
expect(onWarning).toHaveBeenCalledWith(
"已忽略与内置主题同 ID 的本地主题 test-theme"
);
});
});
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": [
"src"
]
}
+9
View File
@@ -0,0 +1,9 @@
# 内部主题资源说明
`typora-github``typora-pixyll``typora-whitey` 来自公司内部已安装的
Typora 1.14.7,仅用于本项目的公司内部私有部署。
- 不对外发布、销售或宣称为项目原创;
- 保留主题清单中的原作者和来源信息;
- Pixyll 的上游项目采用 MIT License
- 若项目未来转为公开发布,必须先移除这些目录或重新完成分发授权审查。
+416
View File
@@ -0,0 +1,416 @@
:root {
--side-bar-bg-color: #fafafa;
--control-text-color: #777;
}
@include-when-export url(https://fonts.googleapis.com/css?family=Open+Sans:400italic,700italic,700,400&subset=latin,latin-ext);
/* open-sans-regular - latin-ext_latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: normal;
src: local('Open Sans Regular'), local('OpenSans-Regular'), url('./github/open-sans-v17-latin-ext_latin-regular.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD, U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* open-sans-italic - latin-ext_latin */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: normal;
src: local('Open Sans Italic'), local('OpenSans-Italic'), url('./github/open-sans-v17-latin-ext_latin-italic.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD, U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* open-sans-700 - latin-ext_latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: bold;
src: local('Open Sans Bold'), local('OpenSans-Bold'), url('./github/open-sans-v17-latin-ext_latin-700.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD, U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* open-sans-700italic - latin-ext_latin */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: bold;
src: local('Open Sans Bold Italic'), local('OpenSans-BoldItalic'), url('./github/open-sans-v17-latin-ext_latin-700italic.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD, U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
body {
font-family: "Open Sans","Clear Sans", "Helvetica Neue", Helvetica, Arial, 'Segoe UI Emoji', 'SF Pro', sans-serif;
color: rgb(51, 51, 51);
line-height: 1.6;
}
#write {
max-width: 860px;
margin: 0 auto;
padding: 30px;
padding-bottom: 100px;
}
@media only screen and (min-width: 1400px) {
#write {
max-width: 1024px;
}
}
@media only screen and (min-width: 1800px) {
#write {
max-width: 1200px;
}
}
#write > ul:first-child,
#write > ol:first-child{
margin-top: 30px;
}
a {
color: #4183C4;
}
h1,
h2,
h3,
h4,
h5,
h6 {
position: relative;
margin-top: 1rem;
margin-bottom: 1rem;
font-weight: bold;
line-height: 1.4;
cursor: text;
}
h1:hover a.anchor,
h2:hover a.anchor,
h3:hover a.anchor,
h4:hover a.anchor,
h5:hover a.anchor,
h6:hover a.anchor {
text-decoration: none;
}
h1 tt,
h1 code {
font-size: inherit;
}
h2 tt,
h2 code {
font-size: inherit;
}
h3 tt,
h3 code {
font-size: inherit;
}
h4 tt,
h4 code {
font-size: inherit;
}
h5 tt,
h5 code {
font-size: inherit;
}
h6 tt,
h6 code {
font-size: inherit;
}
h1 {
font-size: 2.25em;
line-height: 1.2;
border-bottom: 1px solid #eee;
}
h2 {
font-size: 1.75em;
line-height: 1.225;
border-bottom: 1px solid #eee;
}
/*@media print {
.typora-export h1,
.typora-export h2 {
border-bottom: none;
padding-bottom: initial;
}
.typora-export h1::after,
.typora-export h2::after {
content: "";
display: block;
height: 100px;
margin-top: -96px;
border-top: 1px solid #eee;
}
}*/
h3 {
font-size: 1.5em;
line-height: 1.43;
}
h4 {
font-size: 1.25em;
}
h5 {
font-size: 1em;
}
h6 {
font-size: 1em;
color: #777;
}
p,
blockquote,
ul,
ol,
dl,
table{
margin: 0.8em 0;
}
li>ol,
li>ul {
margin: 0 0;
}
hr {
height: 2px;
padding: 0;
margin: 16px 0;
background-color: #e7e7e7;
border: 0 none;
overflow: hidden;
box-sizing: content-box;
}
li p.first {
display: inline-block;
}
ul,
ol {
padding-left: 30px;
}
ul:first-child,
ol:first-child {
margin-top: 0;
}
ul:last-child,
ol:last-child {
margin-bottom: 0;
}
blockquote {
border-left: 4px solid #dfe2e5;
padding: 0 15px;
color: #777777;
}
blockquote blockquote {
padding-right: 0;
}
table {
padding: 0;
word-break: initial;
}
table tr {
border: 1px solid #dfe2e5;
margin: 0;
padding: 0;
}
table tr:nth-child(2n),
thead {
background-color: #f8f8f8;
}
table th {
font-weight: bold;
border: 1px solid #dfe2e5;
border-bottom: 0;
margin: 0;
padding: 6px 13px;
}
table td {
border: 1px solid #dfe2e5;
margin: 0;
padding: 6px 13px;
}
table th:first-child,
table td:first-child {
margin-top: 0;
}
table th:last-child,
table td:last-child {
margin-bottom: 0;
}
.CodeMirror-lines {
padding-left: 4px;
}
.code-tooltip {
box-shadow: 0 1px 1px 0 rgba(0,28,36,.3);
border-top: 1px solid #eef2f2;
}
.md-fences,
code,
tt {
border: 1px solid #e7eaed;
background-color: #f8f8f8;
border-radius: 3px;
padding: 0;
padding: 2px 4px 0px 4px;
font-size: 0.9em;
}
code {
background-color: #f3f4f4;
padding: 0 2px 0 2px;
}
.md-fences {
margin-bottom: 15px;
margin-top: 15px;
padding-top: 8px;
padding-bottom: 6px;
}
.md-task-list-item > input {
margin-left: -1.3em;
}
@media print {
html {
font-size: 13px;
}
pre {
page-break-inside: avoid;
word-wrap: break-word;
}
}
.md-fences {
background-color: #f8f8f8;
}
#write pre.md-meta-block {
padding: 1rem;
font-size: 85%;
line-height: 1.45;
background-color: #f7f7f7;
border: 0;
border-radius: 3px;
color: #777777;
margin-top: 0 !important;
}
.mathjax-block>.code-tooltip {
bottom: .375rem;
}
.md-mathjax-midline {
background: #fafafa;
}
#write>h3.md-focus:before{
left: -1.5625rem;
top: .375rem;
}
#write>h4.md-focus:before{
left: -1.5625rem;
top: .285714286rem;
}
#write>h5.md-focus:before{
left: -1.5625rem;
top: .285714286rem;
}
#write>h6.md-focus:before{
left: -1.5625rem;
top: .285714286rem;
}
.md-image>.md-meta {
/*border: 1px solid #ddd;*/
border-radius: 3px;
padding: 2px 0px 0px 4px;
font-size: 0.9em;
color: inherit;
}
.md-tag {
color: #a7a7a7;
opacity: 1;
}
.md-toc {
margin-top:20px;
padding-bottom:20px;
}
.sidebar-tabs {
border-bottom: none;
}
#typora-quick-open {
border: 1px solid #ddd;
background-color: #f8f8f8;
}
#typora-quick-open-item {
background-color: #FAFAFA;
border-color: #FEFEFE #e5e5e5 #e5e5e5 #eee;
border-style: solid;
border-width: 1px;
}
/** focus mode */
.on-focus-mode blockquote {
border-left-color: rgba(85, 85, 85, 0.12);
}
header, .context-menu, .megamenu-content, footer{
font-family: "Segoe UI", "Arial", sans-serif;
}
.file-node-content:hover .file-node-icon,
.file-node-content:hover .file-node-open-state{
visibility: visible;
}
.mac-seamless-mode #typora-sidebar {
background-color: #fafafa;
background-color: var(--side-bar-bg-color);
}
.mac-os #write{
caret-color: AccentColor;
}
.md-lang {
color: #b4654d;
}
/*.html-for-mac {
--item-hover-bg-color: #E6F0FE;
}*/
#md-notification .btn {
border: 0;
}
.dropdown-menu .divider {
border-color: #e5e5e5;
opacity: 0.4;
}
.ty-preferences .window-content {
background-color: #fafafa;
}
.ty-preferences .nav-group-item.active {
color: white;
background: #999;
}
.menu-item-container a.menu-style-btn {
background-color: #f5f8fa;
background-image: linear-gradient( 180deg , hsla(0, 0%, 100%, 0.8), hsla(0, 0%, 100%, 0));
}
+22
View File
@@ -0,0 +1,22 @@
{
"manifestVersion": 1,
"id": "typora-github",
"name": "Typora 默认(GitHub",
"version": "1.14.7",
"description": "从 Typora 1.14.7 导入,供公司内部私有部署使用。",
"author": "Typora",
"license": "公司内部使用,不对外分发",
"base": "typora-base.css",
"entry": "github.css",
"domPreset": "typora",
"defaultFontSize": "16px",
"supportedFeatures": [
"code",
"table",
"task-list",
"footnote",
"katex",
"mermaid"
],
"bundled": true
}
File diff suppressed because one or more lines are too long
+528
View File
@@ -0,0 +1,528 @@
@include-when-export url(https://fonts.googleapis.com/css?family=Merriweather:900,900italic,300,300italic&subset=latin-ext);
@include-when-export url(https://fonts.googleapis.com/css?family=Lato:900,300&subset=latin-ext);
:root {
--control-text-color: #777;
}
/**
* forked from pixyll.com
* MIT license
*/
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-weight: normal;
src: local('Merriweather Light'), local('Merriweather-Light'), url('./pixyll/merriweather-v19-latin-300.woff') format('woff');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-weight: bold;
src: local('Merriweather Heavy'), local('Merriweather-Heavy'), url('./pixyll/merriweather-v19-latin-700.woff') format('woff');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Merriweather';
font-style: italic;
font-weight: normal;
src: local('Merriweather Light Italic'), local('Merriweather-LightItalic'), url('./pixyll/merriweather-v19-latin-300italic.woff') format('woff');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Merriweather';
font-style: italic;
font-weight: bold;
src: local('Merriweather Heavy Italic'), local('Merriweather-HeavyItalic'), url('./pixyll/merriweather-v19-latin-700italic.woff') format('woff');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: normal;
src: local('Lato Light'), local('Lato-Light'), url('./pixyll/lato-v14-latin-300.woff') format('woff');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: bold;
src: local('Lato Black'), local('Lato-Blcak'), url('./pixyll/lato-v14-latin-900.woff') format('woff');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Lato';
font-style: italic;
font-weight: normal;
src: local('Lato LightItalic'), local('Lato-LightItalic'), url('./pixyll/lato-v14-latin-300italic.woff') format('woff');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Lato';
font-style: italic;
font-weight: bold;
src: local('Lato BlackItalic'), local('Lato-BlackItalic'), url('./pixyll/lato-v14-latin-900italic.woff') format('woff');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
h1,
.h1,
.f1 {
font-size: 2rem;
line-height: 2.5rem;
}
h2,
.h2,
.f2 {
font-size: 1.5rem;
line-height: 2rem;
}
h3,
.h3,
.f3 {
font-size: 1.25rem;
line-height: 1.5rem;
}
p,
.p,
.f4,
h4,
h5,
h6,
dl,
ol,
ul,
pre[cid],
div[cid],
#typora-source {
font-size: 1.125rem;
line-height: 1.5rem;
}
h4 {
font-size: 1.13rem;
}
/*
Pixyll
A simple, beautiful theme for Jekyll that emphasizes content rather than aesthetic fluff.
Best served with BASSCSS (http://jxnblk.github.io/basscss)
Crafted with <3 by John Otander (@4lpine) - ©2015 John Otander MIT License http://opensource.org/licenses/MIT
*/
body {
font-family: "Merriweather", "PT Serif", Georgia, "Times New Roman", "STSong", 'Segoe UI Emoji','SF Pro', Serif;
line-height: 1.5rem;
font-weight: 400;
}
#write {
max-width: 914px;
color: #333;
}
@media only screen and (min-width: 1400px) {
#write {
max-width: 1100px;
}
}
@media only screen and (min-width: 1700px) {
#write {
max-width: 1200px;
}
}
img {
width: auto;
max-width: 100%;
}
body {
font-size: 1.5rem;
box-sizing: border-box;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
}
.ty-table-edit {
background: #ededed;
}
table {
width: 100%;
font-size: 1.125rem;
}
table > thead > tr > th,
table > thead > tr > td,
table > tbody > tr > th,
table > tbody > tr > td,
table > tfoot > tr > th,
table > tfoot > tr > td {
padding: 12px;
line-height: 1.2;
vertical-align: top;
border-top: 1px solid #333;
}
table > thead > tr > th {
vertical-align: bottom;
border-bottom: 2px solid #333;
}
table > caption + thead > tr:first-child > th,
table > caption + thead > tr:first-child > td,
table > colgroup + thead > tr:first-child > th,
table > colgroup + thead > tr:first-child > td,
table > thead:first-child > tr:first-child > th,
table > thead:first-child > tr:first-child > td {
border-top: 0;
}
table > tbody + tbody {
border-top: 2px solid #333;
}
p {
font-weight: 300;
line-height: 1.5;
}
abbr {
border-bottom: 1px black dotted;
cursor: help;
}
pre,
code {
font-family: Menlo, Monaco, "Courier New", monospace;
}
code,
.md-fences {
color: #7a7a7a;
}
.md-fences {
padding: 1.125em;
margin-bottom: 0.88em;
font-size: 1rem;
border: 1px solid #7a7a7a;
padding-bottom: 0.5rem;
padding-top: 0.5rem;
}
blockquote {
padding: 1.33em;
font-style: italic;
border-left: 5px solid #7a7a7a;
color: #555;
}
blockquote em {
color: #000;
}
blockquote footer {
font-size: .85rem;
font-style: normal;
background-color: #fff;
color: #7a7a7a;
border-color: transparent;
}
h1,
.h1,
h2,
.h2,
h3,
.h3,
h4,
.h4,
h5,
.h5,
h6,
.h6 {
font-family: "Lato", 'Helvetica Neue', Helvetica, sans-serif;
font-weight: bold;
line-height: 1.2;
margin: 1em 0 0.5em;
}
@media screen and (min-width: 48em) {
.h1,
h1 {
font-size: 3.250rem;
}
.h2,
h2 {
font-size: 2.298rem;
}
.h3,
h3 {
font-size: 1.625rem;
}
.h4,
h4 {
font-size: 1.3rem;
}
#write>h4.md-focus:before,
#write>h5.md-focus:before,
#write>h6.md-focus:before{
top: 1px;
}
.p,
p,
li {
font-size: 1.25rem;
line-height: 1.8;
}
table {
font-size: 1.25rem;
}
}
@media (max-width: 48em) {
blockquote {
margin-left: 1rem;
margin-right: 0;
padding: 0.5em;
}
.h1,
h1 {
font-size: 2.827rem;
}
.h2,
h2 {
font-size: 1.999rem;
}
.h3,
h3 {
font-size: 1.413rem;
}
.h4,
h4 {
font-size: 1.3rem;
}
}
@media screen and (min-width: 64em) {
.h1,
h1 {
font-size: 4.498rem;
}
.h2,
h2 {
font-size: 2.29rem;
}
.h3,
h3 {
font-size: 1.9rem;
}
.h4,
h4 {
font-size: 1.591rem;
}
#write>h4.md-focus:before{
top:4px;
}
}
a {
color: #463F5C;
text-decoration: underline;
}
#write {
padding-top: 2rem;
}
#write pre.md-meta-block {
min-height: 35px;
padding: 0.5em 1em;
white-space: pre;
border: 0px;
border-left: 30px #f8f8f8 solid;
border-right: 30px #f8f8f8 solid;
width: 100vw;
max-width: calc(100% + 60px);
margin-left: -30px;
margin-bottom: 2em;
margin-top: -2010px;
padding-top: 2000px;
padding-bottom: 10px;
line-height: 1.5em;
color: #7a7a7a;
background-color: #fafafa;
font-family: 'Lato', 'Helvetica Neue', Helvetica, sans-serif;
font-weight: 300;
clear: both;
padding-left: 0;
font-size:1.125rem;
}
.md-image>.md-meta {
color: #463F5C
}
.footnotes {
font-size:1.1rem;
}
.md-tag {
font-family: 'Lato', 'Helvetica Neue', Helvetica, sans-serif;
}
.code-tooltip {
background: white;
}
.code-tooltip-content {
font-size: 1.1rem;
}
.task-list{
padding-left: 0;
}
.md-task-list-item {
padding-left:34px;
}
.md-task-list-item > input{
width: 1.25rem;
height: 1.25rem;
display: block;
-webkit-appearance: initial;
top: -0.2rem;
margin-left: -1.6em;
margin-top: calc(1rem - 7px);
border: none;
}
.md-task-list-item > input:focus{
outline: none;
box-shadow: none;
}
.md-task-list-item > input:before{
border: 1px solid #555;
border-radius: 1.5rem;
width: 1.5rem;
height: 1.5rem;
background: #fff;
content: ' ';
transition: background-color 200ms ease-in-out;
display: block;
}
.md-task-list-item > input:checked:before,
.md-task-list-item > input[checked]:before{
background: #333;
border-width: 2px;
display:inline-block;
transition: background-color 200ms ease-in-out;
}
.md-task-list-item > input:checked:after,
.md-task-list-item > input[checked]:after {
opacity: 1;
}
.md-task-list-item > input:after {
opacity: 1;
-webkit-transition: opacity 0.05s ease-in-out;
-moz-transition: opacity 0.05s ease-in-out;
transition: opacity 0.05s ease-in-out;
-webkit-transform: rotate(-45deg);
-moz-transform: rotate(-45deg);
transform: rotate(-45deg);
position: absolute;
top: 0.4375rem;
left: 0.28125rem;
width: 0.9375rem;
height: 0.5rem;
border: 3px solid #fff;
border-top: 0;
border-right: 0;
content: ' ';
opacity: 0;
}
.md-tag {
color:inherit;
}
.md-toc:focus .md-toc-content{
margin-top: 19px;
}
#typora-sidebar {
font-size:1rem !important;
}
.html-for-mac #typora-sidebar {
background-color:white;
}
.outline-content li, .outline-content ul {
font-size:1rem !important;
}
.outline-title {
line-height: inherit;
margin-top: 10px;
}
.outline-expander {
width: 18px;
}
.outline-expander:before {
content: "+";
font-family: inherit;
color: rgb(108, 108, 108);
font-size: 1.5rem;
top: -0.1rem;
}
.outline-expander:hover:before {
content: "+";
}
.outline-item-open>.outline-item>.outline-expander:before{
content: "-";
}
/** source code mode */
#typora-source {
font-family: Courier, monospace;
color: #6A6A6A;
}
.os-windows #typora-source {
font-family: inherit;
}
.cm-s-typora-default .cm-header,
.cm-s-typora-default .cm-property,
.CodeMirror.cm-s-typora-default div.CodeMirror-cursor {
color: #428bca;
}
.cm-s-typora-default .cm-atom, .cm-s-typora-default .cm-number {
color: #777777;
}
.md-diagram-panel {
margin-top: 24px;
margin-left: -1.2em;
}
.md-mathjax-midline {
background: #fafafa;
}
.enable-diagrams pre.md-fences[lang="sequence"] .code-tooltip,
.enable-diagrams pre.md-fences[lang="flow"] .code-tooltip,
.enable-diagrams pre.md-fences[lang="mermaid"] .code-tooltip {
bottom: -3.4em;
}
.dropdown-menu .divider {
border-color: #e5e5e5;
}
li {
margin: 0.5rem 0;
}
li > *:last-child {
margin-bottom: 0;
}
Binary file not shown.
Binary file not shown.
+22
View File
@@ -0,0 +1,22 @@
{
"manifestVersion": 1,
"id": "typora-pixyll",
"name": "Typora 默认(Pixyll",
"version": "1.14.7",
"description": "从 Typora 1.14.7 导入,供公司内部私有部署使用。",
"author": "Typora",
"license": "公司内部使用,不对外分发;Pixyll 上游为 MIT",
"base": "typora-base.css",
"entry": "pixyll.css",
"domPreset": "typora",
"defaultFontSize": "16px",
"supportedFeatures": [
"code",
"table",
"task-list",
"footnote",
"katex",
"mermaid"
],
"bundled": true
}
File diff suppressed because one or more lines are too long
+22
View File
@@ -0,0 +1,22 @@
{
"manifestVersion": 1,
"id": "typora-whitey",
"name": "Typora 默认(Whitey",
"version": "1.14.7",
"description": "从 Typora 1.14.7 导入,供公司内部私有部署使用。",
"author": "Typora",
"license": "公司内部使用,不对外分发",
"base": "typora-base.css",
"entry": "whitey.css",
"domPreset": "typora",
"defaultFontSize": "16px",
"supportedFeatures": [
"code",
"table",
"task-list",
"footnote",
"katex",
"mermaid"
],
"bundled": true
}
File diff suppressed because one or more lines are too long
+299
View File
@@ -0,0 +1,299 @@
html {
font-size: 19px;
}
html, body {
margin: auto;
background: #fefefe;
-webkit-font-smoothing: antialiased;
}
body {
font-family: "Vollkorn", Palatino, Times;
color: #333;
line-height: 1.4;
text-align: justify;
}
#write {
max-width: 960px;
margin: 0 auto;
margin-bottom: 2em;
line-height: 1.53;
padding-top: 40px;
}
@media only screen and (min-width: 1400px) {
#write {
max-width: 1100px;
}
}
@media print {
html {
font-size: 13px;
}
}
/* Typography
-------------------------------------------------------- */
#write>h1:first-child,
h1 {
margin-top: 1.6em;
font-weight: normal;
}
h1 {
font-size:3em;
}
h2 {
margin-top:2em;
font-weight: normal;
}
h3 {
font-weight: normal;
font-style: italic;
margin-top: 3em;
}
h1,
h2,
h3{
text-align: center;
}
h2:after{
border-bottom: 1px solid #2f2f2f;
content: '';
width: 100px;
display: block;
margin: 0 auto;
height: 1px;
}
h1+h2, h2+h3 {
margin-top: 0.83em;
}
p,
.mathjax-block {
margin-top: 0;
-webkit-hypens: auto;
-moz-hypens: auto;
hyphens: auto;
}
ul {
list-style: square;
padding-left: 1.2em;
}
ol {
padding-left: 1.2em;
}
@media print {
ol {
padding-left: 40px;
}
}
blockquote {
margin-left: 1em;
padding-left: 1em;
border-left: 1px solid #ddd;
}
code,
pre {
font-family: "Consolas", "Menlo", "Monaco", monospace, serif;
font-size: .9em;
background: white;
}
.md-fences{
margin-left: 1em;
padding-left: 1em;
border: 1px solid #ddd;
padding-bottom: 8px;
padding-top: 6px;
margin-bottom: 1.5em;
}
a {
color: #2484c1;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a img {
border: none;
}
h1 a,
h1 a:hover {
color: #333;
text-decoration: none;
}
hr {
color: #ddd;
height: 1px;
margin: 2em 0;
border-top: solid 1px #ddd;
border-bottom: none;
border-left: 0;
border-right: 0;
}
.ty-table-edit {
background: #ededed;
padding-top: 4px;
}
table {
margin-bottom: 1.333333rem
}
table th,
table td {
padding: 8px;
line-height: 1.333333rem;
vertical-align: top;
border-top: 1px solid #ddd
}
table th {
font-weight: bold
}
table thead th {
vertical-align: bottom
}
table caption+thead tr:first-child th,
table caption+thead tr:first-child td,
table colgroup+thead tr:first-child th,
table colgroup+thead tr:first-child td,
table thead:first-child tr:first-child th,
table thead:first-child tr:first-child td {
border-top: 0
}
table tbody+tbody {
border-top: 2px solid #ddd
}
.task-list{
padding:0;
}
.md-task-list-item {
padding-left: 1.6rem;
}
.md-task-list-item > input:before {
content: '\221A';
display: inline-block;
width: 1.33333333rem;
height: 1.6rem;
vertical-align: middle;
text-align: center;
color: #ddd;
background-color: #fefefe;
}
.md-task-list-item > input:checked:before,
.md-task-list-item > input[checked]:before{
color: inherit;
}
.md-tag {
color: inherit;
font: inherit;
}
#write pre.md-meta-block {
min-height: 35px;
padding: 0.5em 1em;
}
#write pre.md-meta-block {
white-space: pre;
background: #f8f8f8;
border: 0px;
color: #999;
width: 100vw;
max-width: calc(100% + 60px);
margin-left: -30px;
border-left: 30px #f8f8f8 solid;
border-right: 30px #f8f8f8 solid;
margin-bottom: 2em;
margin-top: -1.3333333333333rem;
padding-top: 26px;
padding-bottom: 10px;
line-height: 1.8em;
font-size: 0.9em;
font-size: 0.76em;
padding-left: 0;
}
.md-img-error.md-image>.md-meta{
vertical-align: bottom;
}
#write>h5.md-focus:before {
top: 2px;
}
.md-toc {
margin-top: 40px;
}
.md-toc-content {
padding-bottom: 20px;
}
.outline-expander:before {
color: inherit;
font-size: 14px;
top: auto;
content: "\f0da";
font-family: FontAwesome;
}
.outline-expander:hover:before,
.outline-item-open>.outline-item>.outline-expander:before {
content: "\f0d7";
}
/** source code mode */
#typora-source {
font-family: Courier, monospace;
color: #6A6A6A;
}
.html-for-mac #typora-sidebar {
-webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175);
box-shadow: 0 6px 12px rgba(0, 0, 0, .175);
}
.cm-s-typora-default .cm-header,
.cm-s-typora-default .cm-property,
.CodeMirror.cm-s-typora-default div.CodeMirror-cursor {
color: #428bca;
}
.cm-s-typora-default .cm-atom, .cm-s-typora-default .cm-number {
color: #777777;
}
.typora-node .file-list-item-parent-loc,
.typora-node .file-list-item-time,
.typora-node .file-list-item-summary {
font-family: arial, sans-serif;
}
.md-task-list-item>input {
margin-left: -1.3em;
margin-top: calc(1rem - 12px);
}
.md-mathjax-midline {
background: #fafafa;
}
.md-fences .code-tooltip {
bottom: -2em !important;
}
.dropdown-menu .divider {
border-color: #e5e5e5;
}