1 Commits
Author SHA1 Message Date
SkyJourney 83e6559c2c release: 发布 v0.5.1
新增能力:内置 4 套红头、3 套正式文档和 3 套标书主题,支持主题推荐页边距、页面装饰、页眉页脚和页码;增加结构化公文、项目报告及标书 Front Matter,内置 Fandol 中文字体、两份教程和 14 份主题示例。

桌面工作流:重组更多菜单,增加新建、保存、另存为快捷键和未保存确认;另存为后跟随新路径,主题示例自动切换主题,Desktop 发行包完整携带教程、示例与字体。

问题修复:修正红头标题居中与正式文档字体;围栏代码按正文宽度自动换行,长内容安全断行,至少两行即可在当前页分页,并统一保留 8px 左侧内容留白;Docker Web 镜像正确打包 samples。

兼容与部署:未声明主题推荐设置时继续使用 16mm 默认页边距;Web 隐藏不适用的另存为。正式镜像 yixiong/md-to-pdf:v0.5.1 内容 ID 为 sha256:72f519a69bfd6cb2f6df30c2be938e58ceb6f20e4748a32551874de3d436b276,Compose 健康运行。

验证结果:最终修复前 65 个测试文件、286 项测试通过,最终代码块与主题定向测试 27 项通过;全项目类型检查、生产构建和 git diff --check 通过。容器检出 14 套主题、17 份 Markdown,代码块回归 PDF 为 2 页且无越界。NSIS SHA-256 为 676CCE73D782B0B15AC6BC68F253CFBC4740383583E4DAA98F746EDF9999C5CC,ZIP SHA-256 为 82BF6ADF1200604F145CC86FA3ED193955CF6741EBEE3DF8953483515CFF621F。
2026-07-29 16:58:17 +08:00
111 changed files with 8274 additions and 173 deletions
+1
View File
@@ -3,6 +3,7 @@ dist/
coverage/ coverage/
.env .env
.env.local .env.local
.conda_env
.local/ .local/
*.log *.log
.DS_Store .DS_Store
+21 -24
View File
@@ -4,27 +4,24 @@
导出工具。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题 CSS 和 导出工具。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题 CSS 和
分页运行时,适合内网部署,也可以作为不依赖本地服务的桌面应用使用。 分页运行时,适合内网部署,也可以作为不依赖本地服务的桌面应用使用。
当前版本:`v0.5.0` 当前版本:`v0.5.1`
## v0.5.0 主要能力 ## v0.5.1 主要能力
- 新增“连续”预览模式:不分页、低测量开销,适合编辑时快速刷新;快速 - 新增政企标准红头、函件红头、企业简版红头、简报红头等 4 套红头主题,
预览继续提供分页近似,精确预览和导出 PDF 保持权威分页 以及 3 套正式文档主题和 3 套标书主题
- 分页引擎从 Web 应用抽取为 `@md-to-pdf/preview-engine`Web、 - 正式主题可声明推荐页边距、页眉页脚、页码和页面装饰;未声明时继续使用
Playwright PDF 与 Electron PDF 直接复用同一运行时 16mm 默认页边距,选择正式主题时自动采用其规范参数
- 快速预览支持按修改位置复用稳定前缀,只重新分页受影响的后缀; - 新增公文、项目报告和标书结构化 Front Matter,支持发文机关、文号、
- Web 链接只处理当前文档锚点和 HTTP/HTTPS 外链;外链在新浏览器窗口 签发人、密级、主送、抄送、印发信息、封面及落款等文档结构;
打开,本地路径及不支持协议不执行导航; - 内置方正书版兼容的 Fandol 中文字体资源,统一正文、红头标题与页眉页脚
- Desktop 支持 HTTP/HTTPS、系统协议、本地相对或绝对路径和 `file:` 字体,并修复红头标题居中;代码块支持按正文宽度自动换行、跨页分片,
URI;Markdown 链接可选择在当前窗口或新窗口打开 同时保留 `8px` 左侧内容留白
- Desktop 支持多窗口和同一文件单例。窗口聚焦时检查磁盘文件变化,并 - 内置 ECharts 与公文主题教程,并为 14 套主题提供示例 Markdown
提示是否重新加载 Desktop 发行包包含全部教程和示例,打开示例时自动切换相应主题
- 四个内置主题统一命名为 Typora Github、Typora Pixyll、 - “更多”菜单按文件、教程与示例、主题管理分区;Desktop 新增
Typora whitey 和 Typora Clean,默认使用 Typora Github `Ctrl+N/W/S/Shift+S` 工作流、未保存确认及跟随新路径的“另存为”,
- 围栏代码块输出 Typora 兼容的 `.md-fences` DOM,并避免内部 Web 端隐藏不适用的“另存为”。
`<code>` 重复套用行内代码样式;
- 桌面发行链会在每次打包前完整重建内嵌 Web 依赖,避免安装包携带陈旧
前端资源。
## Web 与桌面端 ## Web 与桌面端
@@ -32,7 +29,7 @@
| --- | --- | --- | | --- | --- | --- |
| 新建 Markdown | 支持 | 支持 | | 新建 Markdown | 支持 | 支持 |
| 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 | | 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 |
| 保存 Markdown | 浏览器下载 | 原生另存为 | | 保存文件 | 浏览器下载 | 原生保存与另存为 |
| 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 | | 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 |
| 文档内锚点 | 当前预览定位 | 当前预览定位 | | 文档内锚点 | 当前预览定位 | 当前预览定位 |
| HTTP/HTTPS 链接 | 浏览器新窗口 | 系统默认浏览器 | | HTTP/HTTPS 链接 | 浏览器新窗口 | 系统默认浏览器 |
@@ -129,8 +126,8 @@ npm run make -w @md-to-pdf/desktop
``` ```
桌面 `package``make` 会先完整重建内嵌 Web 及全部共享依赖,不能 桌面 `package``make` 会先完整重建内嵌 Web 及全部共享依赖,不能
复用陈旧的 `apps/web/dist`。产物位于 `apps/desktop/out/v0.5.0/` 复用陈旧的 `apps/web/dist`。产物位于 `apps/desktop/out/v0.5.1/`
正式安装包命名为 `md-to-pdf-0.5.0-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包 正式安装包命名为 `md-to-pdf-0.5.1-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
未配置代码签名。 未配置代码签名。
## 文档与媒体 ## 文档与媒体
@@ -149,8 +146,8 @@ ECharts 围栏语法、支持系列和安全边界详见
## 主题 ## 主题
仓库内置 Typora Github、Typora Pixyll、Typora whitey 仓库内置 4 套 Typora 风格主题、4 套红头主题、3 套正式文档主题
Typora Clean 四套主题,默认使用 Typora Github。主题由版本化 3 套标书主题,默认使用 Typora Github。主题由版本化
`theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。 `theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。
- 本地开发与 Compose:将主题目录放入 `.local/themes/` - 本地开发与 Compose:将主题目录放入 `.local/themes/`
+6 -5
View File
@@ -14,10 +14,11 @@ preload、进程内应用服务、原生 Markdown 打开与保存、同目录图
窗口每次获得焦点时会检查源文件是否被其他程序修改。检测到变化后提示 窗口每次获得焦点时会检查源文件是否被其他程序修改。检测到变化后提示
重新加载,避免阅读或导出磁盘旧版本。 重新加载,避免阅读或导出磁盘旧版本。
“打开 Markdown”是顶部独立主操作;新建文档、保存 Markdown 和打开 “打开 Markdown”是顶部独立主操作;新建、保存、另存为、教程、主题示例
自定义主题目录收纳在“更多”菜单中。新建文档未保存时显示“未命名文档”, 自定义主题目录统一收纳在“更多”分区菜单中。新建文档未保存时显示“未命名文档”,
首次保存默认使用一级标题作为文件名;没有一级标题时使用首行文本,并会 首次保存默认使用一级标题作为文件名;没有一级标题时使用首行文本,并会
自动清理 Windows 非法文件名字符。新文档无需先保存即可直接导出 PDF。 自动清理 Windows 非法文件名字符。“另存为”完成后当前窗口会追踪新文件,
行为与常见办公软件一致。新文档无需先保存即可直接导出 PDF。
## 开发 ## 开发
@@ -45,8 +46,8 @@ Markdown ECharts、Core、Renderer、Application、Preview Engine 和 Web
然后才复制 `apps/web/dist`。不得绕过该链路直接调用 electron-builder。 然后才复制 `apps/web/dist`。不得绕过该链路直接调用 electron-builder。
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到 版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
`apps/desktop/out/v0.5.0/`。该目录被 Git 忽略。公司内部分发以 `apps/desktop/out/v0.5.1/`。该目录被 Git 忽略。公司内部分发以
`md-to-pdf-0.5.0-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装 `md-to-pdf-0.5.1-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者” 辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
提示。 提示。
+5
View File
@@ -30,6 +30,11 @@ module.exports = {
to: "themes", to: "themes",
filter: ["**/*"] filter: ["**/*"]
}, },
{
from: "../../samples",
to: "samples",
filter: ["**/*"]
},
{ {
from: windowsIcon, from: windowsIcon,
to: "app.ico" to: "app.ico"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@md-to-pdf/desktop", "name": "@md-to-pdf/desktop",
"version": "0.5.0", "version": "0.5.1",
"private": true, "private": true,
"productName": "Markdown PDF 导出器", "productName": "Markdown PDF 导出器",
"description": "Markdown PDF 导出器桌面端", "description": "Markdown PDF 导出器桌面端",
+22
View File
@@ -3,6 +3,7 @@ import type { PagedDocumentPayload } from "@md-to-pdf/core";
import type { MarkdownRenderRequest } from "@md-to-pdf/application"; import type { MarkdownRenderRequest } from "@md-to-pdf/application";
import { import {
DESKTOP_CONSUME_PENDING_MARKDOWN, DESKTOP_CONSUME_PENDING_MARKDOWN,
DESKTOP_CREATE_NEW_WINDOW,
DESKTOP_DISCARD_PENDING_MARKDOWN, DESKTOP_DISCARD_PENDING_MARKDOWN,
DESKTOP_GET_THEME_CSS, DESKTOP_GET_THEME_CSS,
DESKTOP_GENERATE_PDF, DESKTOP_GENERATE_PDF,
@@ -13,9 +14,12 @@ import {
DESKTOP_OPEN_THEME_DIRECTORY, DESKTOP_OPEN_THEME_DIRECTORY,
DESKTOP_REFRESH_THEMES, DESKTOP_REFRESH_THEMES,
DESKTOP_RENDER_MARKDOWN, DESKTOP_RENDER_MARKDOWN,
DESKTOP_RESOLVE_WINDOW_CLOSE,
DESKTOP_SAVE_MARKDOWN, DESKTOP_SAVE_MARKDOWN,
DESKTOP_SAVE_MARKDOWN_AS,
DESKTOP_SET_DOCUMENT_DIRTY, DESKTOP_SET_DOCUMENT_DIRTY,
DESKTOP_START_NEW_MARKDOWN, DESKTOP_START_NEW_MARKDOWN,
DESKTOP_WINDOW_CLOSE_REQUESTED,
DESKTOP_SAVE_PDF DESKTOP_SAVE_PDF
} from "./channels.js"; } from "./channels.js";
@@ -38,12 +42,30 @@ contextBridge.exposeInMainWorld("mdToPdfDesktop", {
}, },
startNewMarkdown: () => startNewMarkdown: () =>
ipcRenderer.invoke(DESKTOP_START_NEW_MARKDOWN), ipcRenderer.invoke(DESKTOP_START_NEW_MARKDOWN),
createNewWindow: () =>
ipcRenderer.invoke(DESKTOP_CREATE_NEW_WINDOW),
resolveWindowClose: (close: boolean) =>
ipcRenderer.invoke(DESKTOP_RESOLVE_WINDOW_CLOSE, close),
onWindowCloseRequested: (listener: () => void) => {
const handleCloseRequested = () => listener();
ipcRenderer.on(
DESKTOP_WINDOW_CLOSE_REQUESTED,
handleCloseRequested
);
return () =>
ipcRenderer.removeListener(
DESKTOP_WINDOW_CLOSE_REQUESTED,
handleCloseRequested
);
},
setDocumentDirty: (dirty: boolean) => setDocumentDirty: (dirty: boolean) =>
ipcRenderer.invoke(DESKTOP_SET_DOCUMENT_DIRTY, dirty), ipcRenderer.invoke(DESKTOP_SET_DOCUMENT_DIRTY, dirty),
openDocumentLink: (href: string) => openDocumentLink: (href: string) =>
ipcRenderer.invoke(DESKTOP_OPEN_DOCUMENT_LINK, href), ipcRenderer.invoke(DESKTOP_OPEN_DOCUMENT_LINK, href),
saveMarkdown: (fileName: string, markdown: string) => saveMarkdown: (fileName: string, markdown: string) =>
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN, fileName, markdown), ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN, fileName, markdown),
saveMarkdownAs: (fileName: string, markdown: string) =>
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN_AS, fileName, markdown),
openThemeDirectory: () => openThemeDirectory: () =>
ipcRenderer.invoke(DESKTOP_OPEN_THEME_DIRECTORY), ipcRenderer.invoke(DESKTOP_OPEN_THEME_DIRECTORY),
refreshThemes: () => ipcRenderer.invoke(DESKTOP_REFRESH_THEMES), refreshThemes: () => ipcRenderer.invoke(DESKTOP_REFRESH_THEMES),
+8
View File
@@ -14,8 +14,16 @@ export const DESKTOP_OPEN_DOCUMENT_LINK =
"md-to-pdf:desktop:open-document-link"; "md-to-pdf:desktop:open-document-link";
export const DESKTOP_START_NEW_MARKDOWN = export const DESKTOP_START_NEW_MARKDOWN =
"md-to-pdf:desktop:start-new-markdown"; "md-to-pdf:desktop:start-new-markdown";
export const DESKTOP_CREATE_NEW_WINDOW =
"md-to-pdf:desktop:create-new-window";
export const DESKTOP_WINDOW_CLOSE_REQUESTED =
"md-to-pdf:desktop:window-close-requested";
export const DESKTOP_RESOLVE_WINDOW_CLOSE =
"md-to-pdf:desktop:resolve-window-close";
export const DESKTOP_SAVE_MARKDOWN = export const DESKTOP_SAVE_MARKDOWN =
"md-to-pdf:desktop:save-markdown"; "md-to-pdf:desktop:save-markdown";
export const DESKTOP_SAVE_MARKDOWN_AS =
"md-to-pdf:desktop:save-markdown-as";
export const DESKTOP_OPEN_THEME_DIRECTORY = export const DESKTOP_OPEN_THEME_DIRECTORY =
"md-to-pdf:desktop:open-theme-directory"; "md-to-pdf:desktop:open-theme-directory";
export const DESKTOP_REFRESH_THEMES = export const DESKTOP_REFRESH_THEMES =
@@ -21,6 +21,7 @@ import {
} from "@md-to-pdf/application"; } from "@md-to-pdf/application";
import { import {
DESKTOP_CONSUME_PENDING_MARKDOWN, DESKTOP_CONSUME_PENDING_MARKDOWN,
DESKTOP_CREATE_NEW_WINDOW,
DESKTOP_DISCARD_PENDING_MARKDOWN, DESKTOP_DISCARD_PENDING_MARKDOWN,
DESKTOP_GENERATE_PDF, DESKTOP_GENERATE_PDF,
DESKTOP_GET_THEME_CSS, DESKTOP_GET_THEME_CSS,
@@ -31,10 +32,13 @@ import {
DESKTOP_OPEN_THEME_DIRECTORY, DESKTOP_OPEN_THEME_DIRECTORY,
DESKTOP_REFRESH_THEMES, DESKTOP_REFRESH_THEMES,
DESKTOP_RENDER_MARKDOWN, DESKTOP_RENDER_MARKDOWN,
DESKTOP_RESOLVE_WINDOW_CLOSE,
DESKTOP_SAVE_MARKDOWN, DESKTOP_SAVE_MARKDOWN,
DESKTOP_SAVE_MARKDOWN_AS,
DESKTOP_SAVE_PDF, DESKTOP_SAVE_PDF,
DESKTOP_SET_DOCUMENT_DIRTY, DESKTOP_SET_DOCUMENT_DIRTY,
DESKTOP_START_NEW_MARKDOWN DESKTOP_START_NEW_MARKDOWN,
DESKTOP_WINDOW_CLOSE_REQUESTED
} from "./channels.js"; } from "./channels.js";
import { import {
parseMarkdownRenderRequest, parseMarkdownRenderRequest,
@@ -55,6 +59,7 @@ import {
saveWindowState, saveWindowState,
type WindowState type WindowState
} from "./window-state.js"; } from "./window-state.js";
import { getWindowCloseDecision } from "./window-close-policy.js";
const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 }; const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 };
const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 }; const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 };
@@ -72,6 +77,7 @@ interface WindowSession {
window: BrowserWindow; window: BrowserWindow;
dirty: boolean; dirty: boolean;
allowClose: boolean; allowClose: boolean;
closeRequestPending: boolean;
currentFilePath: string | undefined; currentFilePath: string | undefined;
documentKey: string | undefined; documentKey: string | undefined;
documentRoot: string | undefined; documentRoot: string | undefined;
@@ -159,7 +165,10 @@ export class DesktopApplicationController {
return this.#createWindow(pendingSnapshot); return this.#createWindow(pendingSnapshot);
} }
async #createWindow(pendingSnapshot?: MarkdownFileSnapshot) { async #createWindow(
pendingSnapshot?: MarkdownFileSnapshot,
startBlank = false
) {
const isPrimary = !this.#primarySession; const isPrimary = !this.#primarySession;
const restoredWindowState = isPrimary const restoredWindowState = isPrimary
? this.#latestWindowState ?? this.#savedWindowState ? this.#latestWindowState ?? this.#savedWindowState
@@ -190,6 +199,7 @@ export class DesktopApplicationController {
window, window,
dirty: false, dirty: false,
allowClose: false, allowClose: false,
closeRequestPending: false,
currentFilePath: undefined, currentFilePath: undefined,
documentKey: undefined, documentKey: undefined,
documentRoot: undefined, documentRoot: undefined,
@@ -219,7 +229,11 @@ export class DesktopApplicationController {
} }
window.show(); window.show();
}); });
await window.loadURL(this.#applicationUrl); const targetUrl = new URL(this.#applicationUrl);
if (startBlank) {
targetUrl.searchParams.set("new", "1");
}
await window.loadURL(targetUrl.href);
return window; return window;
} }
@@ -325,11 +339,17 @@ export class DesktopApplicationController {
void this.#checkForExternalChange(session); void this.#checkForExternalChange(session);
}); });
window.on("close", (event) => { window.on("close", (event) => {
if (!session.dirty || session.allowClose) { const decision = getWindowCloseDecision(session);
if (decision === "allow") {
return; return;
} }
event.preventDefault(); event.preventDefault();
void this.#confirmDirtyWindowClose(session); if (decision === "request") {
session.closeRequestPending = true;
session.window.webContents.send(
DESKTOP_WINDOW_CLOSE_REQUESTED
);
}
}); });
window.on("closed", () => { window.on("closed", () => {
const wasPrimary = this.#primarySession === session; const wasPrimary = this.#primarySession === session;
@@ -358,28 +378,6 @@ export class DesktopApplicationController {
this.#captureWindowState(); this.#captureWindowState();
} }
async #confirmDirtyWindowClose(session: WindowSession) {
if (session.window.isDestroyed()) {
return;
}
const result = await dialog.showMessageBox(session.window, {
type: "warning",
title: "关闭未保存文档",
message: "当前文档有未保存修改,确定要关闭窗口吗?",
detail: "关闭后,尚未保存的修改将会丢失。",
buttons: ["取消", "仍然关闭"],
defaultId: 0,
cancelId: 0,
noLink: true
});
session.suppressFocusCheckUntil =
Date.now() + FOCUS_CHECK_SUPPRESSION_MS;
if (result.response === 1 && !session.window.isDestroyed()) {
session.allowClose = true;
session.window.close();
}
}
async #checkForExternalChange(session: WindowSession) { async #checkForExternalChange(session: WindowSession) {
if ( if (
session.focusCheckRunning || session.focusCheckRunning ||
@@ -630,6 +628,28 @@ export class DesktopApplicationController {
this.#clearDocument(this.#getSession(event)); this.#clearDocument(this.#getSession(event));
}); });
ipcMain.handle(DESKTOP_CREATE_NEW_WINDOW, async (event) => {
this.#getSession(event);
await this.#createWindow(undefined, true);
});
ipcMain.handle(
DESKTOP_RESOLVE_WINDOW_CLOSE,
async (event, unsafeClose: unknown) => {
if (typeof unsafeClose !== "boolean") {
throw new Error("窗口关闭决策无效");
}
const session = this.#getSession(event);
session.closeRequestPending = false;
session.suppressFocusCheckUntil =
Date.now() + FOCUS_CHECK_SUPPRESSION_MS;
if (unsafeClose && !session.window.isDestroyed()) {
session.allowClose = true;
session.window.close();
}
}
);
ipcMain.handle( ipcMain.handle(
DESKTOP_SET_DOCUMENT_DIRTY, DESKTOP_SET_DOCUMENT_DIRTY,
async (event, unsafeDirty: unknown) => { async (event, unsafeDirty: unknown) => {
@@ -640,13 +660,12 @@ export class DesktopApplicationController {
} }
); );
ipcMain.handle( const saveMarkdown = async (
DESKTOP_SAVE_MARKDOWN, event: IpcMainInvokeEvent,
async ( unsafeFileName: unknown,
event, unsafeMarkdown: unknown,
unsafeFileName: unknown, forceSaveAs: boolean
unsafeMarkdown: unknown ) => {
) => {
const session = this.#getSession(event); const session = this.#getSession(event);
if ( if (
typeof unsafeFileName !== "string" || typeof unsafeFileName !== "string" ||
@@ -659,18 +678,20 @@ export class DesktopApplicationController {
} }
let targetPath = session.currentFilePath; let targetPath = session.currentFilePath;
if (!targetPath) { if (forceSaveAs || !targetPath) {
const suggestedName = /\.(?:md|markdown)$/iu.test( const suggestedName = /\.(?:md|markdown)$/iu.test(
path.basename(unsafeFileName) path.basename(unsafeFileName)
) )
? path.basename(unsafeFileName) ? path.basename(unsafeFileName)
: `${path.basename(unsafeFileName)}.md`; : `${path.basename(unsafeFileName)}.md`;
const selection = await dialog.showSaveDialog(session.window, { const selection = await dialog.showSaveDialog(session.window, {
title: "保存 Markdown", title: forceSaveAs
defaultPath: path.join( ? "另存为 Markdown"
app.getPath("documents"), : "保存 Markdown",
suggestedName defaultPath:
), forceSaveAs && session.currentFilePath
? session.currentFilePath
: path.join(app.getPath("documents"), suggestedName),
filters: [ filters: [
{ {
name: "Markdown 文件", name: "Markdown 文件",
@@ -724,7 +745,27 @@ export class DesktopApplicationController {
} }
this.#commitSnapshot(session, snapshot); this.#commitSnapshot(session, snapshot);
return { fileName: snapshot.document.fileName }; return { fileName: snapshot.document.fileName };
} };
ipcMain.handle(
DESKTOP_SAVE_MARKDOWN,
(event, unsafeFileName: unknown, unsafeMarkdown: unknown) =>
saveMarkdown(
event,
unsafeFileName,
unsafeMarkdown,
false
)
);
ipcMain.handle(
DESKTOP_SAVE_MARKDOWN_AS,
(event, unsafeFileName: unknown, unsafeMarkdown: unknown) =>
saveMarkdown(
event,
unsafeFileName,
unsafeMarkdown,
true
)
); );
ipcMain.handle(DESKTOP_OPEN_THEME_DIRECTORY, async (event) => { ipcMain.handle(DESKTOP_OPEN_THEME_DIRECTORY, async (event) => {
+16
View File
@@ -0,0 +1,16 @@
export interface WindowCloseState {
dirty: boolean;
allowClose: boolean;
closeRequestPending: boolean;
}
export type WindowCloseDecision = "allow" | "request" | "wait";
export function getWindowCloseDecision(
state: WindowCloseState
): WindowCloseDecision {
if (!state.dirty || state.allowClose) {
return "allow";
}
return state.closeRequestPending ? "wait" : "request";
}
+29
View File
@@ -47,4 +47,33 @@ describe("桌面发行构建链", () => {
"npm run package -w @md-to-pdf/desktop" "npm run package -w @md-to-pdf/desktop"
); );
}); });
it("将内置主题示例作为独立资源复制到发行目录", () => {
const builderConfig = readFileSync(
`${desktopRoot}/electron-builder.config.cjs`,
"utf8"
);
expect(builderConfig).toContain('from: "../../samples"');
expect(builderConfig).toContain('to: "samples"');
});
it("在 Docker Web 构建前复制样例并保留到运行镜像", () => {
const dockerfile = readFileSync(
`${projectRoot}/deploy/Dockerfile`,
"utf8"
);
const samplesBuildCopyIndex = dockerfile.indexOf(
"COPY samples ./samples"
);
const webBuildIndex = dockerfile.indexOf(
"npm run build -w @md-to-pdf/web"
);
expect(samplesBuildCopyIndex).toBeGreaterThan(-1);
expect(webBuildIndex).toBeGreaterThan(samplesBuildCopyIndex);
expect(dockerfile).toContain(
"COPY --chown=pwuser:pwuser --from=build /app/samples ./samples"
);
});
}); });
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { getWindowCloseDecision } from "../src/window-close-policy.js";
describe("桌面窗口关闭策略", () => {
it("干净文档或已授权窗口可以直接关闭", () => {
expect(
getWindowCloseDecision({
dirty: false,
allowClose: false,
closeRequestPending: false
})
).toBe("allow");
expect(
getWindowCloseDecision({
dirty: true,
allowClose: true,
closeRequestPending: false
})
).toBe("allow");
});
it("未保存文档只发出一次关闭确认请求", () => {
expect(
getWindowCloseDecision({
dirty: true,
allowClose: false,
closeRequestPending: false
})
).toBe("request");
expect(
getWindowCloseDecision({
dirty: true,
allowClose: false,
closeRequestPending: true
})
).toBe("wait");
});
});
+1 -1
View File
@@ -12,7 +12,7 @@
<meta name="theme-color" content="#1f2937" /> <meta name="theme-color" content="#1f2937" />
<meta <meta
http-equiv="Content-Security-Policy" 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:;" 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:; manifest-src 'self';"
/> />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
+485 -37
View File
@@ -59,6 +59,18 @@ import {
useThemeResources useThemeResources
} from "./use-theme-resources"; } from "./use-theme-resources";
import { sampleMarkdown } from "./sample-markdown"; import { sampleMarkdown } from "./sample-markdown";
import { groupThemesByCategory } from "./theme-groups";
import { getBuiltinThemeSample } from "./builtin-theme-samples";
import {
builtinTutorials,
type BuiltinTutorial
} from "./builtin-tutorials";
import { DocumentActionDialog } from "./DocumentActionDialog";
import { getDocumentShortcut } from "./document-shortcuts";
import {
applyThemeDefaults,
selectTheme
} from "./theme-margins";
const PrecisePdfPreview = lazy(async () => { const PrecisePdfPreview = lazy(async () => {
const module = await import("./PrecisePdfPreview"); const module = await import("./PrecisePdfPreview");
@@ -67,18 +79,48 @@ const PrecisePdfPreview = lazy(async () => {
type PreviewMode = "quick" | "precise" | "continuous"; type PreviewMode = "quick" | "precise" | "continuous";
type DocumentKind = "sample" | "new" | "file"; type DocumentKind = "sample" | "new" | "file";
type PendingDocumentAction =
| {
type: "new-current";
}
| {
type: "close";
systemRequest: boolean;
};
type DocumentDialogState =
| {
type: "new-location";
}
| {
type: "unsaved";
action: PendingDocumentAction;
};
const noImageResources: never[] = []; const noImageResources: never[] = [];
const startsWithBlankMarkdown =
Boolean(window.mdToPdfDesktop) &&
new URLSearchParams(window.location.search).get("new") === "1";
const initialMarkdown = startsWithBlankMarkdown ? "" : sampleMarkdown;
const initialFileName = startsWithBlankMarkdown ? "" : "示例文档.md";
const initialDocumentKind: DocumentKind = startsWithBlankMarkdown
? "new"
: "sample";
export function App() { export function App() {
const [markdown, setMarkdown] = useState(sampleMarkdown); const [markdown, setMarkdown] = useState(initialMarkdown);
const [fileName, setFileName] = useState("示例文档.md"); const [fileName, setFileName] = useState(initialFileName);
const [documentKind, setDocumentKind] = const [documentKind, setDocumentKind] =
useState<DocumentKind>("sample"); useState<DocumentKind>(initialDocumentKind);
const [savedMarkdown, setSavedMarkdown] = useState(sampleMarkdown); const [savedMarkdown, setSavedMarkdown] = useState(initialMarkdown);
const [exportConfig, setExportConfig] = const [exportConfig, setExportConfig] =
useState<ExportConfig>(loadExportConfig); useState<ExportConfig>(loadExportConfig);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [documentDialog, setDocumentDialog] =
useState<DocumentDialogState>();
const [documentActionBusy, setDocumentActionBusy] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [moreSubmenuOpen, setMoreSubmenuOpen] = useState<
"tutorials" | "samples" | undefined
>();
const [themeRefreshKey, setThemeRefreshKey] = useState(0); const [themeRefreshKey, setThemeRefreshKey] = useState(0);
const [sourcePanelCollapsed, setSourcePanelCollapsed] = useState(false); const [sourcePanelCollapsed, setSourcePanelCollapsed] = useState(false);
const [status, setStatus] = useState("正在准备预览…"); const [status, setStatus] = useState("正在准备预览…");
@@ -186,21 +228,60 @@ export function App() {
); );
}, [documentDirty]); }, [documentDirty]);
useEffect(() => {
const bridge = window.mdToPdfDesktop;
if (!bridge) {
return;
}
return bridge.onWindowCloseRequested(() => {
requestDocumentClose(true);
});
}, [documentDialog, documentDirty]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const shortcut = getDocumentShortcut(
event,
Boolean(window.mdToPdfDesktop)
);
if (!shortcut) {
return;
}
event.preventDefault();
if (documentDialog) {
return;
}
if (shortcut === "save") {
void handleSaveMarkdown();
} else if (shortcut === "save-as") {
void handleSaveMarkdownAs();
} else if (shortcut === "new") {
requestNewMarkdown();
} else {
requestDocumentClose(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [documentDialog, documentDirty, fileName, markdown]);
useEffect(() => { useEffect(() => {
if (!moreMenuOpen) { if (!moreMenuOpen) {
return; return;
} }
const handlePointerDown = (event: PointerEvent) => { const handlePointerDown = (event: PointerEvent) => {
if ( if (!(event.target instanceof Node)) {
event.target instanceof Node && return;
!moreMenuRef.current?.contains(event.target) }
) { if (!moreMenuRef.current?.contains(event.target)) {
setMoreMenuOpen(false); setMoreMenuOpen(false);
setMoreSubmenuOpen(undefined);
} }
}; };
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") { if (event.key === "Escape") {
setMoreMenuOpen(false); setMoreMenuOpen(false);
setMoreSubmenuOpen(undefined);
} }
}; };
window.addEventListener("pointerdown", handlePointerDown); window.addEventListener("pointerdown", handlePointerDown);
@@ -214,19 +295,18 @@ export function App() {
const handleThemesLoaded = useCallback( const handleThemesLoaded = useCallback(
(availableThemes: ThemeSummary[], loadedRefreshKey: number) => { (availableThemes: ThemeSummary[], loadedRefreshKey: number) => {
setExportConfig((currentConfig) => { setExportConfig((currentConfig) => {
if ( const currentTheme = availableThemes.find(
availableThemes.some( (theme) => theme.id === currentConfig.themeId
(theme) => theme.id === currentConfig.themeId );
) if (currentTheme) {
) { return applyThemeDefaults(currentConfig, currentTheme);
return currentConfig;
} }
const preferredTheme = const preferredTheme =
availableThemes.find((theme) => theme.id === "typora-github") ?? availableThemes.find((theme) => theme.id === "typora-github") ??
availableThemes.find((theme) => theme.id === "typora-like") ?? availableThemes.find((theme) => theme.id === "typora-like") ??
availableThemes[0]; availableThemes[0];
return preferredTheme return preferredTheme
? { ...currentConfig, themeId: preferredTheme.id } ? selectTheme(currentConfig, preferredTheme)
: currentConfig; : currentConfig;
}); });
if (themeRefreshTargetRef.current === loadedRefreshKey) { if (themeRefreshTargetRef.current === loadedRefreshKey) {
@@ -268,6 +348,17 @@ export function App() {
pdfError; pdfError;
const themeId = exportConfig.themeId; const themeId = exportConfig.themeId;
const selectedTheme = themes.find((theme) => theme.id === themeId); const selectedTheme = themes.find((theme) => theme.id === themeId);
const themeGroups = useMemo(
() => groupThemesByCategory(themes),
[themes]
);
const sampleThemeGroups = useMemo(
() =>
groupThemesByCategory(
themes.filter((theme) => getBuiltinThemeSample(theme.id))
),
[themes]
);
const effectiveFileName = const effectiveFileName =
documentKind === "new" || !fileName documentKind === "new" || !fileName
? createMarkdownFileName(markdown) ? createMarkdownFileName(markdown)
@@ -806,11 +897,29 @@ export function App() {
} }
} }
async function handleNewMarkdown() { function requestNewMarkdown() {
setMoreMenuOpen(false); setMoreMenuOpen(false);
if (!confirmDiscardChanges()) { setMoreSubmenuOpen(undefined);
if (window.mdToPdfDesktop) {
setDocumentDialog({ type: "new-location" });
return; return;
} }
requestCurrentWindowNew();
}
function requestCurrentWindowNew() {
if (documentDirty) {
setDocumentDialog({
type: "unsaved",
action: { type: "new-current" }
});
return;
}
setDocumentDialog(undefined);
void createMarkdownInCurrentWindow();
}
async function createMarkdownInCurrentWindow() {
try { try {
setAppError(""); setAppError("");
await window.mdToPdfDesktop?.startNewMarkdown(); await window.mdToPdfDesktop?.startNewMarkdown();
@@ -827,18 +936,193 @@ export function App() {
} }
} }
async function handleSaveMarkdown() { async function createMarkdownInNewWindow() {
setDocumentDialog(undefined);
try {
setAppError("");
await window.mdToPdfDesktop?.createNewWindow();
setStatus("已在新窗口创建空白 Markdown");
} catch (reason) {
setAppError(
reason instanceof Error
? reason.message
: "无法创建新的 Markdown 窗口"
);
}
}
function requestDocumentClose(systemRequest: boolean) {
const bridge = window.mdToPdfDesktop;
if (!bridge) {
return;
}
if (documentDialog) {
if (systemRequest) {
void bridge.resolveWindowClose(false);
}
return;
}
if (documentDirty) {
setDocumentDialog({
type: "unsaved",
action: {
type: "close",
systemRequest
}
});
return;
}
void bridge.resolveWindowClose(true);
}
async function completePendingDocumentAction(
action: PendingDocumentAction
) {
setDocumentDialog(undefined);
if (action.type === "new-current") {
await createMarkdownInCurrentWindow();
return;
}
await window.mdToPdfDesktop?.resolveWindowClose(true);
}
function cancelDocumentDialog() {
const currentDialog = documentDialog;
setDocumentDialog(undefined);
if (
currentDialog?.type === "unsaved" &&
currentDialog.action.type === "close" &&
currentDialog.action.systemRequest
) {
void window.mdToPdfDesktop?.resolveWindowClose(false);
}
}
function discardPendingDocumentChanges() {
if (documentDialog?.type !== "unsaved") {
return;
}
void completePendingDocumentAction(documentDialog.action);
}
async function saveBeforePendingDocumentAction() {
if (documentDialog?.type !== "unsaved" || documentActionBusy) {
return;
}
const action = documentDialog.action;
setDocumentActionBusy(true);
try {
if (await saveMarkdown()) {
await completePendingDocumentAction(action);
}
} finally {
setDocumentActionBusy(false);
}
}
async function handleOpenThemeSample(theme: ThemeSummary) {
const sample = getBuiltinThemeSample(theme.id);
if (!sample) {
setAppError(`找不到“${theme.name}”的内置示例`);
return;
}
setMoreMenuOpen(false); setMoreMenuOpen(false);
setMoreSubmenuOpen(undefined);
if (!confirmDiscardChanges()) {
return;
}
try {
setAppError("");
await window.mdToPdfDesktop?.startNewMarkdown();
setMarkdown(sample.markdown);
setFileName(sample.fileName);
setDocumentKind("sample");
setSavedMarkdown(sample.markdown);
setExportConfig((currentConfig) =>
selectTheme(
{
...currentConfig,
paper: {
...currentConfig.paper,
marginMode: "theme"
},
pageDecorationsMode: "theme"
},
theme
)
);
setStatus(
`已打开“${theme.name}”主题示例,可编辑后另存为 Markdown`
);
window.requestAnimationFrame(() => editorRef.current?.focus());
} catch (reason) {
setAppError(
reason instanceof Error ? reason.message : "无法打开主题示例"
);
}
}
async function handleOpenTutorial(tutorial: BuiltinTutorial) {
const theme = themes.find(
(availableTheme) => availableTheme.id === tutorial.themeId
);
if (!theme) {
setAppError(`找不到教程需要的主题:${tutorial.themeId}`);
return;
}
setMoreMenuOpen(false);
setMoreSubmenuOpen(undefined);
if (!confirmDiscardChanges()) {
return;
}
try {
setAppError("");
await window.mdToPdfDesktop?.startNewMarkdown();
setMarkdown(tutorial.markdown);
setFileName(tutorial.fileName);
setDocumentKind("sample");
setSavedMarkdown(tutorial.markdown);
setExportConfig((currentConfig) =>
selectTheme(
{
...currentConfig,
paper: {
...currentConfig.paper,
marginMode: "theme"
},
pageDecorationsMode: "theme"
},
theme
)
);
setStatus(
`已打开“${tutorial.name}”,可编辑后另存为 Markdown`
);
window.requestAnimationFrame(() => editorRef.current?.focus());
} catch (reason) {
setAppError(
reason instanceof Error ? reason.message : "无法打开内置教程"
);
}
}
async function saveMarkdown(forceSaveAs = false) {
const suggestedName = ensureMarkdownFileName(fileName, markdown); const suggestedName = ensureMarkdownFileName(fileName, markdown);
try { try {
setAppError(""); setAppError("");
const bridge = window.mdToPdfDesktop; const bridge = window.mdToPdfDesktop;
let savedFileName = suggestedName; let savedFileName = suggestedName;
if (bridge) { if (bridge) {
const saved = await bridge.saveMarkdown(suggestedName, markdown); const saved = forceSaveAs
? await bridge.saveMarkdownAs(suggestedName, markdown)
: await bridge.saveMarkdown(suggestedName, markdown);
if (!saved) { if (!saved) {
setStatus("已取消 Markdown 保存"); setStatus(
return; forceSaveAs
? "已取消 Markdown 另存为"
: "已取消 Markdown 保存"
);
return false;
} }
savedFileName = saved.fileName; savedFileName = saved.fileName;
} else { } else {
@@ -847,16 +1131,39 @@ export function App() {
setFileName(savedFileName); setFileName(savedFileName);
setDocumentKind("file"); setDocumentKind("file");
setSavedMarkdown(markdown); setSavedMarkdown(markdown);
setStatus(`Markdown 已保存:${savedFileName}`); setStatus(
forceSaveAs
? `Markdown 已另存为:${savedFileName}`
: `Markdown 已保存:${savedFileName}`
);
return true;
} catch (reason) { } catch (reason) {
setAppError( setAppError(
reason instanceof Error ? reason.message : "Markdown 保存失败" reason instanceof Error
? reason.message
: forceSaveAs
? "Markdown 另存为失败"
: "Markdown 保存失败"
); );
return false;
} }
} }
async function handleSaveMarkdown() {
setMoreMenuOpen(false);
setMoreSubmenuOpen(undefined);
return saveMarkdown();
}
async function handleSaveMarkdownAs() {
setMoreMenuOpen(false);
setMoreSubmenuOpen(undefined);
return saveMarkdown(true);
}
async function handleOpenThemeDirectory() { async function handleOpenThemeDirectory() {
setMoreMenuOpen(false); setMoreMenuOpen(false);
setMoreSubmenuOpen(undefined);
try { try {
setAppError(""); setAppError("");
const themeDirectory = const themeDirectory =
@@ -875,6 +1182,7 @@ export function App() {
async function handleRefreshThemes() { async function handleRefreshThemes() {
setMoreMenuOpen(false); setMoreMenuOpen(false);
setMoreSubmenuOpen(undefined);
try { try {
setAppError(""); setAppError("");
await window.mdToPdfDesktop?.refreshThemes(); await window.mdToPdfDesktop?.refreshThemes();
@@ -1011,7 +1319,10 @@ export function App() {
aria-controls="document-actions-menu" aria-controls="document-actions-menu"
aria-expanded={moreMenuOpen} aria-expanded={moreMenuOpen}
aria-haspopup="menu" aria-haspopup="menu"
onClick={() => setMoreMenuOpen((open) => !open)} onClick={() => {
setMoreMenuOpen((open) => !open);
setMoreSubmenuOpen(undefined);
}}
> >
</button> </button>
@@ -1023,20 +1334,129 @@ export function App() {
> >
<button <button
type="button" type="button"
className="menu-command"
role="menuitem" role="menuitem"
onClick={() => void handleNewMarkdown()} onClick={requestNewMarkdown}
> >
Markdown <span></span>
{window.mdToPdfDesktop ? <kbd>Ctrl+N</kbd> : null}
</button> </button>
<button <button
type="button" type="button"
className="menu-command"
role="menuitem" role="menuitem"
onClick={() => void handleSaveMarkdown()} onClick={() => void handleSaveMarkdown()}
> >
Markdown <span></span>
<kbd>Ctrl+S</kbd>
</button> </button>
{window.mdToPdfDesktop ? (
<button
type="button"
className="menu-command"
role="menuitem"
onClick={() => void handleSaveMarkdownAs()}
>
<span></span>
<kbd>Ctrl+Shift+S</kbd>
</button>
) : null}
<div className="menu-separator" role="separator" />
<div
className="more-menu-submenu-item"
onPointerEnter={() =>
setMoreSubmenuOpen("tutorials")
}
onPointerLeave={() => setMoreSubmenuOpen(undefined)}
>
<button
type="button"
className="submenu-trigger"
role="menuitem"
aria-controls="tutorials-menu"
aria-expanded={moreSubmenuOpen === "tutorials"}
aria-haspopup="menu"
onFocus={() => setMoreSubmenuOpen("tutorials")}
onClick={() => setMoreSubmenuOpen("tutorials")}
>
<span>使</span>
<span aria-hidden="true"></span>
</button>
{moreSubmenuOpen === "tutorials" ? (
<div
id="tutorials-menu"
className="more-menu-submenu"
role="menu"
aria-label="使用教程"
>
{builtinTutorials.map((tutorial) => (
<button
type="button"
role="menuitem"
key={tutorial.id}
onClick={() =>
void handleOpenTutorial(tutorial)
}
>
{tutorial.name}
</button>
))}
</div>
) : null}
</div>
<div
className="more-menu-submenu-item"
onPointerEnter={() => setMoreSubmenuOpen("samples")}
onPointerLeave={() => setMoreSubmenuOpen(undefined)}
>
<button
type="button"
className="submenu-trigger"
role="menuitem"
aria-controls="theme-samples-menu"
aria-expanded={moreSubmenuOpen === "samples"}
aria-haspopup="menu"
onFocus={() => setMoreSubmenuOpen("samples")}
onClick={() => setMoreSubmenuOpen("samples")}
>
<span></span>
<span aria-hidden="true"></span>
</button>
{moreSubmenuOpen === "samples" ? (
<div
id="theme-samples-menu"
className="more-menu-submenu"
role="menu"
aria-label="主题示例"
>
{sampleThemeGroups.map((group) => (
<section
className="sample-menu-group"
key={group.category}
>
<span className="sample-menu-group-label">
{group.label}
</span>
{group.themes.map((theme) => (
<button
type="button"
role="menuitem"
key={theme.id}
onClick={() =>
void handleOpenThemeSample(theme)
}
>
{theme.name}
</button>
))}
</section>
))}
</div>
) : null}
</div>
{window.mdToPdfDesktop ? ( {window.mdToPdfDesktop ? (
<> <>
<div className="menu-separator" role="separator" />
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
@@ -1175,20 +1595,31 @@ export function App() {
<select <select
aria-label="预览主题" aria-label="预览主题"
value={themeId} value={themeId}
onChange={(event) => onChange={(event) => {
setExportConfig((currentConfig) => ({ const nextTheme = themes.find(
...currentConfig, (theme) => theme.id === event.target.value
themeId: event.target.value );
})) if (nextTheme) {
} setExportConfig((currentConfig) =>
selectTheme(currentConfig, nextTheme)
);
}
}}
> >
{themes.length === 0 ? ( {themes.length === 0 ? (
<option value={themeId}></option> <option value={themeId}></option>
) : ( ) : (
themes.map((theme) => ( themeGroups.map((group) => (
<option key={theme.id} value={theme.id}> <optgroup
{theme.name} key={group.category}
</option> label={group.label}
>
{group.themes.map((theme) => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>
))}
</optgroup>
)) ))
)} )}
</select> </select>
@@ -1280,11 +1711,28 @@ export function App() {
{settingsOpen ? ( {settingsOpen ? (
<ExportSettingsDrawer <ExportSettingsDrawer
config={exportConfig} config={exportConfig}
theme={selectedTheme}
onChange={setExportConfig} onChange={setExportConfig}
onClose={() => setSettingsOpen(false)} onClose={() => setSettingsOpen(false)}
onReset={() => setExportConfig(resetExportConfig())} onReset={() => setExportConfig(resetExportConfig())}
/> />
) : null} ) : null}
{documentDialog?.type === "new-location" ? (
<DocumentActionDialog
type="new-location"
onCurrentWindow={requestCurrentWindowNew}
onNewWindow={() => void createMarkdownInNewWindow()}
onCancel={cancelDocumentDialog}
/>
) : documentDialog?.type === "unsaved" ? (
<DocumentActionDialog
type="unsaved"
busy={documentActionBusy}
onSave={() => void saveBeforePendingDocumentAction()}
onDiscard={discardPendingDocumentChanges}
onCancel={cancelDocumentDialog}
/>
) : null}
</main> </main>
); );
} }
+102
View File
@@ -0,0 +1,102 @@
import { useEffect } from "react";
type DocumentActionDialogProps =
| {
type: "new-location";
busy?: false;
onCurrentWindow: () => void;
onNewWindow: () => void;
onCancel: () => void;
}
| {
type: "unsaved";
busy: boolean;
onSave: () => void;
onDiscard: () => void;
onCancel: () => void;
};
export function DocumentActionDialog(
props: DocumentActionDialogProps
) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && !props.busy) {
event.preventDefault();
props.onCancel();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [props]);
const isNewLocation = props.type === "new-location";
return (
<div className="document-dialog-layer">
<div className="document-dialog-backdrop" />
<section
className="document-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="document-dialog-title"
aria-describedby="document-dialog-description"
>
<span className="panel-kicker"></span>
<h2 id="document-dialog-title">
{isNewLocation ? "新建 Markdown" : "保存当前修改?"}
</h2>
<p id="document-dialog-description">
{isNewLocation
? "请选择在当前窗口新建,或者保留当前文档并打开一个新窗口。"
: "当前文档包含尚未保存的修改。继续操作前,可以先保存这些内容。"}
</p>
<div className="document-dialog-actions">
<button
type="button"
disabled={props.busy}
onClick={props.onCancel}
>
</button>
{isNewLocation ? (
<>
<button
type="button"
onClick={props.onNewWindow}
>
</button>
<button
type="button"
className="is-primary"
autoFocus
onClick={props.onCurrentWindow}
>
</button>
</>
) : (
<>
<button
type="button"
disabled={props.busy}
onClick={props.onDiscard}
>
</button>
<button
type="button"
className="is-primary"
disabled={props.busy}
autoFocus
onClick={props.onSave}
>
{props.busy ? "正在保存…" : "保存"}
</button>
</>
)}
</div>
</section>
</div>
);
}
+222 -2
View File
@@ -1,4 +1,5 @@
import { import {
defaultExportConfig,
getPaperDimensionsMm, getPaperDimensionsMm,
lengthToMillimeters, lengthToMillimeters,
paperFormatLabels, paperFormatLabels,
@@ -12,9 +13,11 @@ import {
type MermaidExportConfig type MermaidExportConfig
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { ThemeSummary } from "./application-backend";
interface ExportSettingsDrawerProps { interface ExportSettingsDrawerProps {
config: ExportConfig; config: ExportConfig;
theme: ThemeSummary | undefined;
onChange: (config: ExportConfig) => void; onChange: (config: ExportConfig) => void;
onClose: () => void; onClose: () => void;
onReset: () => void; onReset: () => void;
@@ -24,6 +27,9 @@ interface LengthInputProps {
label: string; label: string;
value: string; value: string;
maximum: number; maximum: number;
disabled?: boolean;
precision?: number;
step?: number;
onChange: (value: string) => void; onChange: (value: string) => void;
} }
@@ -32,6 +38,7 @@ const pageNumberFormatLabels: Record<FooterConfig["format"], string> = {
"page-total": "1 / 10", "page-total": "1 / 10",
"chinese-page-total": "第 1 页 / 共 10 页", "chinese-page-total": "第 1 页 / 共 10 页",
"dash-page": "- 1 -", "dash-page": "- 1 -",
"official-page": "— 1 —(标准公文)",
custom: "自定义模板" custom: "自定义模板"
}; };
@@ -98,6 +105,9 @@ function LengthInput({
label, label,
value, value,
maximum, maximum,
disabled = false,
precision = 1,
step = 0.1,
onChange onChange
}: LengthInputProps) { }: LengthInputProps) {
const [draft, setDraft] = useState( const [draft, setDraft] = useState(
@@ -115,7 +125,8 @@ function LengthInput({
return; return;
} }
const normalized = Math.min(Math.max(number, 0), maximum); const normalized = Math.min(Math.max(number, 0), maximum);
const rounded = Math.round(normalized * 10) / 10; const factor = 10 ** precision;
const rounded = Math.round(normalized * factor) / factor;
setDraft(String(rounded)); setDraft(String(rounded));
onChange(`${rounded}mm`); onChange(`${rounded}mm`);
} }
@@ -128,8 +139,9 @@ function LengthInput({
type="number" type="number"
min="0" min="0"
max={maximum} max={maximum}
step="0.1" step={step}
value={draft} value={draft}
disabled={disabled}
onBlur={commit} onBlur={commit}
onChange={(event) => setDraft(event.target.value)} onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
@@ -144,6 +156,57 @@ function LengthInput({
); );
} }
function PageDecorationFontInput({
label,
value,
disabled,
onChange
}: {
label: string;
value: string;
disabled: boolean;
onChange: (value: string) => void;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
function commit() {
const normalized = draft.trim();
if (
!normalized ||
!/^[\p{L}\p{N}\s,"'-]+$/u.test(normalized)
) {
setDraft(value);
return;
}
setDraft(normalized);
onChange(normalized);
}
return (
<label className="setting-field stacked">
<span>{label}</span>
<input
type="text"
maxLength={300}
value={draft}
disabled={disabled}
placeholder='"Segoe UI", "Microsoft YaHei", sans-serif'
onBlur={commit}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
</label>
);
}
function MermaidFontInput({ function MermaidFontInput({
value, value,
onChange onChange
@@ -208,6 +271,7 @@ function updateHeaderSlot(
export function ExportSettingsDrawer({ export function ExportSettingsDrawer({
config, config,
theme,
onChange, onChange,
onClose, onClose,
onReset onReset
@@ -259,6 +323,7 @@ export function ExportSettingsDrawer({
value: string value: string
) { ) {
updatePaper({ updatePaper({
marginMode: "custom",
margins: { margins: {
...margins, ...margins,
[side]: value [side]: value
@@ -266,9 +331,22 @@ export function ExportSettingsDrawer({
}); });
} }
function followThemeMargins() {
updatePaper({
marginMode: "theme",
margins: theme?.pageDefaults?.margins ?? {
top: "16mm",
right: "16mm",
bottom: "16mm",
left: "16mm"
}
});
}
function updateHeader(header: Partial<HeaderConfig>) { function updateHeader(header: Partial<HeaderConfig>) {
onChange({ onChange({
...config, ...config,
pageDecorationsMode: "custom",
header: { header: {
...config.header, ...config.header,
...header ...header
@@ -279,6 +357,7 @@ export function ExportSettingsDrawer({
function updateFooter(footer: Partial<FooterConfig>) { function updateFooter(footer: Partial<FooterConfig>) {
onChange({ onChange({
...config, ...config,
pageDecorationsMode: "custom",
footer: { footer: {
...config.footer, ...config.footer,
...footer ...footer
@@ -286,6 +365,19 @@ export function ExportSettingsDrawer({
}); });
} }
function followThemePageDecorations() {
onChange({
...config,
pageDecorationsMode: "theme",
header: structuredClone(
theme?.pageDefaults?.header ?? defaultExportConfig.header
),
footer: structuredClone(
theme?.pageDefaults?.footer ?? defaultExportConfig.footer
)
});
}
function updateMermaid(mermaid: Partial<MermaidExportConfig>) { function updateMermaid(mermaid: Partial<MermaidExportConfig>) {
onChange({ onChange({
...config, ...config,
@@ -374,6 +466,34 @@ export function ExportSettingsDrawer({
<section className="settings-section"> <section className="settings-section">
<h3></h3> <h3></h3>
<fieldset className="segmented-control margin-mode-control">
<legend></legend>
<label>
<input
type="radio"
name="margin-mode"
checked={config.paper.marginMode === "theme"}
onChange={followThemeMargins}
/>
</label>
<label>
<input
type="radio"
name="margin-mode"
checked={config.paper.marginMode === "custom"}
onChange={() => updatePaper({ marginMode: "custom" })}
/>
</label>
</fieldset>
<p className="setting-hint margin-source-hint">
{config.paper.marginMode === "theme"
? theme?.pageDefaults
? `当前采用“${theme.name}”推荐值`
: "当前主题未设置推荐值,采用全局默认 16mm"
: "当前采用自定义值;修改任意页边距会自动进入此模式"}
</p>
<div className="margin-grid"> <div className="margin-grid">
<LengthInput <LengthInput
label="上" label="上"
@@ -416,6 +536,15 @@ export function ExportSettingsDrawer({
onChange={(value) => updateMargin("left", value)} onChange={(value) => updateMargin("left", value)}
/> />
</div> </div>
{config.paper.marginMode === "custom" ? (
<button
type="button"
className="secondary-action margin-reset-action"
onClick={followThemeMargins}
>
</button>
) : null}
</section> </section>
<section className="settings-section"> <section className="settings-section">
@@ -485,6 +614,53 @@ export function ExportSettingsDrawer({
</p> </p>
</section> </section>
<section className="settings-section">
<h3></h3>
<fieldset className="segmented-control margin-mode-control">
<legend></legend>
<label>
<input
type="radio"
name="page-decorations-mode"
checked={config.pageDecorationsMode === "theme"}
onChange={followThemePageDecorations}
/>
</label>
<label>
<input
type="radio"
name="page-decorations-mode"
checked={config.pageDecorationsMode === "custom"}
onChange={() =>
onChange({
...config,
pageDecorationsMode: "custom"
})
}
/>
</label>
</fieldset>
<p className="setting-hint margin-source-hint">
{config.pageDecorationsMode === "theme"
? theme?.pageDefaults?.header ||
theme?.pageDefaults?.footer
? `当前采用“${theme.name}”推荐的页眉与页码`
: "当前主题未设置推荐值,采用全局默认配置"
: "当前采用自定义配置;修改页眉或页码会自动进入此模式"}
</p>
{config.pageDecorationsMode === "custom" ? (
<button
type="button"
className="secondary-action margin-reset-action"
onClick={followThemePageDecorations}
>
</button>
) : null}
</section>
<section className="settings-section"> <section className="settings-section">
<div className="setting-section-title"> <div className="setting-section-title">
<h3></h3> <h3></h3>
@@ -502,6 +678,21 @@ export function ExportSettingsDrawer({
<p className="setting-hint"> <p className="setting-hint">
{"${title}"}{"${author}"} {"${filename}"} {"${title}"}{"${author}"} {"${filename}"}
</p> </p>
<PageDecorationFontInput
label="页眉字体"
value={config.header.fontFamily}
disabled={!config.header.enabled}
onChange={(fontFamily) => updateHeader({ fontFamily })}
/>
<LengthInput
label="页眉字号"
value={config.header.fontSize}
maximum={20}
disabled={!config.header.enabled}
precision={2}
step={0.01}
onChange={(fontSize) => updateHeader({ fontSize })}
/>
{(["left", "center", "right"] as const).map((slot) => { {(["left", "center", "right"] as const).map((slot) => {
const labels = { left: "左侧", center: "中间", right: "右侧" }; const labels = { left: "左侧", center: "中间", right: "右侧" };
return ( return (
@@ -567,6 +758,21 @@ export function ExportSettingsDrawer({
<span>{config.footer.enabled ? "开启" : "关闭"}</span> <span>{config.footer.enabled ? "开启" : "关闭"}</span>
</label> </label>
</div> </div>
<PageDecorationFontInput
label="页码字体"
value={config.footer.fontFamily}
disabled={!config.footer.enabled}
onChange={(fontFamily) => updateFooter({ fontFamily })}
/>
<LengthInput
label="页码字号"
value={config.footer.fontSize}
maximum={20}
disabled={!config.footer.enabled}
precision={2}
step={0.01}
onChange={(fontSize) => updateFooter({ fontSize })}
/>
<label className="setting-field"> <label className="setting-field">
<span></span> <span></span>
<select <select
@@ -616,6 +822,7 @@ export function ExportSettingsDrawer({
<option value="left"></option> <option value="left"></option>
<option value="center"></option> <option value="center"></option>
<option value="right"></option> <option value="right"></option>
<option value="outer"></option>
</select> </select>
</label> </label>
<label className="setting-field"> <label className="setting-field">
@@ -636,6 +843,19 @@ export function ExportSettingsDrawer({
} }
/> />
</label> </label>
<label className="switch-control divider-control">
<input
type="checkbox"
checked={config.footer.showOnFirstPage}
disabled={!config.footer.enabled}
onChange={(event) =>
updateFooter({
showOnFirstPage: event.target.checked
})
}
/>
<span></span>
</label>
<label className="switch-control divider-control"> <label className="switch-control divider-control">
<input <input
type="checkbox" type="checkbox"
+15 -1
View File
@@ -1,10 +1,24 @@
import type { RenderedMarkdownDocument } from "@md-to-pdf/core"; import type {
DocumentProfileName,
FooterConfig,
HeaderConfig,
PageMargins,
RenderedMarkdownDocument,
ThemeCategory
} from "@md-to-pdf/core";
export interface ThemeSummary { export interface ThemeSummary {
id: string; id: string;
name: string; name: string;
version: string; version: string;
description: string; description: string;
category: ThemeCategory;
compatibleProfiles: DocumentProfileName[];
pageDefaults?: {
margins: PageMargins;
header?: HeaderConfig;
footer?: FooterConfig;
};
bundled: boolean; bundled: boolean;
source: "bundled" | "local"; source: "bundled" | "local";
} }
+41
View File
@@ -0,0 +1,41 @@
const sampleModules = import.meta.glob<string>(
"../../../samples/themes/*.md",
{
eager: true,
query: "?raw",
import: "default"
}
);
export interface BuiltinThemeSample {
themeId: string;
fileName: string;
markdown: string;
}
function getFileName(modulePath: string) {
return modulePath.split("/").at(-1) ?? "";
}
export const builtinThemeSamples: BuiltinThemeSample[] = Object.entries(
sampleModules
)
.map(([modulePath, markdown]) => {
const fileName = getFileName(modulePath);
return {
themeId: fileName.replace(/\.md$/i, ""),
fileName,
markdown
};
})
.sort((first, second) =>
first.themeId.localeCompare(second.themeId, "en")
);
const builtinThemeSampleMap = new Map(
builtinThemeSamples.map((sample) => [sample.themeId, sample])
);
export function getBuiltinThemeSample(themeId: string) {
return builtinThemeSampleMap.get(themeId);
}
+49
View File
@@ -0,0 +1,49 @@
const tutorialModules = import.meta.glob<string>(
"../../../samples/tutorials/*.md",
{
eager: true,
query: "?raw",
import: "default"
}
);
export interface BuiltinTutorial {
id: string;
name: string;
fileName: string;
themeId: string;
markdown: string;
}
const tutorialDefinitions = [
{
id: "echarts-tutorial",
name: "ECharts 图表教程",
themeId: "typora-github"
},
{
id: "formal-document-tutorial",
name: "公文主题使用教程",
themeId: "typora-github"
}
] as const;
const tutorialMarkdownById = new Map(
Object.entries(tutorialModules).map(([modulePath, markdown]) => {
const fileName = modulePath.split("/").at(-1) ?? "";
return [fileName.replace(/\.md$/i, ""), markdown];
})
);
export const builtinTutorials: BuiltinTutorial[] =
tutorialDefinitions.map((definition) => {
const markdown = tutorialMarkdownById.get(definition.id);
if (!markdown) {
throw new Error(`缺少内置教程:${definition.id}.md`);
}
return {
...definition,
fileName: `${definition.id}.md`,
markdown
};
});
+45
View File
@@ -0,0 +1,45 @@
export type DocumentShortcut =
| "new"
| "save"
| "save-as"
| "close";
export interface DocumentShortcutEvent {
key: string;
ctrlKey: boolean;
metaKey: boolean;
altKey: boolean;
shiftKey: boolean;
isComposing: boolean;
repeat: boolean;
}
export function getDocumentShortcut(
event: DocumentShortcutEvent,
desktop: boolean
): DocumentShortcut | undefined {
if (
event.isComposing ||
event.repeat ||
event.altKey ||
(!event.ctrlKey && !event.metaKey)
) {
return undefined;
}
const key = event.key.toLowerCase();
if (event.shiftKey) {
return desktop && key === "s" ? "save-as" : undefined;
}
switch (key) {
case "s":
return "save";
case "n":
return desktop ? "new" : undefined;
case "w":
return desktop ? "close" : undefined;
default:
return undefined;
}
}
+121 -14
View File
@@ -4,8 +4,10 @@ import {
type ExportConfig type ExportConfig
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v3"; export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v4";
export const LEGACY_EXPORT_CONFIG_STORAGE_KEY = export const LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY =
"md-to-pdf.export-config.v3";
export const LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY =
"md-to-pdf.export-config.v2"; "md-to-pdf.export-config.v2";
type SettingsStorage = Pick<Storage, "getItem" | "setItem">; type SettingsStorage = Pick<Storage, "getItem" | "setItem">;
@@ -14,6 +16,119 @@ function cloneDefaultExportConfig() {
return structuredClone(defaultExportConfig); return structuredClone(defaultExportConfig);
} }
function pickLegacyPageDecorations(value: unknown) {
if (typeof value !== "object" || value === null) {
return value;
}
const source = value as Record<string, unknown>;
if ("left" in source) {
return {
enabled: source.enabled,
height: source.height,
showDivider: source.showDivider,
fontSize: source.fontSize,
color: source.color,
left: source.left,
center: source.center,
right: source.right
};
}
return {
enabled: source.enabled,
height: source.height,
showDivider: source.showDivider,
fontSize: source.fontSize,
color: source.color,
alignment: source.alignment,
format: source.format,
template: source.template,
startFrom: source.startFrom
};
}
function usesLegacyPageDecorationDefaults(
header: unknown,
footer: unknown
) {
return (
JSON.stringify(pickLegacyPageDecorations(header)) ===
JSON.stringify(
pickLegacyPageDecorations(defaultExportConfig.header)
) &&
JSON.stringify(pickLegacyPageDecorations(footer)) ===
JSON.stringify(
pickLegacyPageDecorations(defaultExportConfig.footer)
)
);
}
function migrateStoredExportConfig(value: unknown): unknown {
if (
typeof value !== "object" ||
value === null ||
!("version" in value) ||
!("paper" in value)
) {
return value;
}
if (
value.version !== 2 &&
value.version !== 3 &&
value.version !== defaultExportConfig.version
) {
return value;
}
const source = value as Record<string, unknown>;
const hasPageDecorationsMode =
typeof source.pageDecorationsMode === "string";
const paper =
typeof value.paper === "object" && value.paper !== null
? value.paper
: {};
const margins =
"margins" in paper &&
typeof paper.margins === "object" &&
paper.margins !== null
? paper.margins
: {};
const usesLegacyDefaults =
JSON.stringify(margins) ===
JSON.stringify(defaultExportConfig.paper.margins);
const hasMarginMode =
typeof (paper as Record<string, unknown>).marginMode === "string";
return {
...value,
...(value.version === 2 || value.version === 3
? { version: defaultExportConfig.version }
: {}),
...(value.version === 2
? { mermaid: structuredClone(defaultExportConfig.mermaid) }
: {}),
...(!hasPageDecorationsMode
? {
pageDecorationsMode: usesLegacyPageDecorationDefaults(
source.header,
source.footer
)
? "theme"
: "custom"
}
: {}),
paper: {
...paper,
...(!hasMarginMode
? {
marginMode: usesLegacyDefaults ? "theme" : "custom"
}
: {})
}
};
}
export function parseStoredExportConfig(rawValue: string | null): ExportConfig { export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
if (!rawValue) { if (!rawValue) {
return cloneDefaultExportConfig(); return cloneDefaultExportConfig();
@@ -21,17 +136,7 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
try { try {
const value: unknown = JSON.parse(rawValue); const value: unknown = JSON.parse(rawValue);
const migratedValue = const migratedValue = migrateStoredExportConfig(value);
typeof value === "object" &&
value !== null &&
"version" in value &&
value.version === 2
? {
...value,
version: defaultExportConfig.version,
mermaid: structuredClone(defaultExportConfig.mermaid)
}
: value;
const parsed = exportConfigSchema.safeParse(migratedValue); const parsed = exportConfigSchema.safeParse(migratedValue);
return parsed.success ? parsed.data : cloneDefaultExportConfig(); return parsed.success ? parsed.data : cloneDefaultExportConfig();
} catch { } catch {
@@ -47,7 +152,9 @@ export function loadExportConfig(
return parseStoredExportConfig(currentValue); return parseStoredExportConfig(currentValue);
} }
const legacyValue = storage.getItem(LEGACY_EXPORT_CONFIG_STORAGE_KEY); const legacyValue =
storage.getItem(LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY) ??
storage.getItem(LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY);
const config = parseStoredExportConfig(legacyValue); const config = parseStoredExportConfig(legacyValue);
if (legacyValue) { if (legacyValue) {
storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config)); storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config));
+159
View File
@@ -188,6 +188,72 @@ h1 {
background: #edf3f0; background: #edf3f0;
} }
.more-menu-popover .menu-command {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.menu-command kbd {
color: #78857e;
font-family: inherit;
font-size: 0.68rem;
font-weight: 600;
}
.more-menu-submenu-item {
position: relative;
}
.menu-separator {
height: 1px;
margin: 5px 7px;
background: #e0e6e3;
}
.submenu-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.more-menu-submenu {
position: absolute;
z-index: 31;
top: -7px;
right: calc(100% + 8px);
width: 250px;
max-height: min(70vh, 560px);
padding: 6px;
overflow-y: auto;
border: 1px solid #c8d1cc;
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 30px rgb(38 58 50 / 18%);
}
.sample-menu-group {
display: flex;
flex-direction: column;
margin: 0;
}
.sample-menu-group + .sample-menu-group {
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid #e3e8e5;
}
.sample-menu-group-label {
padding: 6px 11px 3px;
color: #718078;
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.06em;
}
.editor-layout { .editor-layout {
display: grid; display: grid;
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr); grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
@@ -749,6 +815,59 @@ textarea:focus {
inset: 0; inset: 0;
} }
.document-dialog-layer {
position: fixed;
z-index: 50;
display: grid;
place-items: center;
padding: 24px;
inset: 0;
}
.document-dialog-backdrop {
position: absolute;
background: rgb(24 35 30 / 46%);
inset: 0;
}
.document-dialog {
position: relative;
width: min(480px, 100%);
padding: 24px;
border: 1px solid #c9d2cd;
border-radius: 14px;
background: #fff;
box-shadow: 0 24px 70px rgb(20 34 28 / 28%);
}
.document-dialog h2 {
margin: 4px 0 10px;
color: #25332d;
font-family: Georgia, "Songti SC", serif;
font-size: 1.45rem;
font-weight: 500;
}
.document-dialog p {
margin: 0;
color: #5d6a64;
font-size: 0.88rem;
line-height: 1.65;
}
.document-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 24px;
}
.document-dialog-actions .is-primary {
border-color: #326b54;
background: #326b54;
color: #fff;
}
.settings-backdrop { .settings-backdrop {
position: absolute; position: absolute;
width: 100%; width: 100%;
@@ -927,6 +1046,19 @@ textarea:focus {
gap: 8px 18px; gap: 8px 18px;
} }
.margin-mode-control {
margin-bottom: 8px;
}
.margin-source-hint {
margin: 0 0 10px;
}
.margin-reset-action {
width: 100%;
margin-top: 10px;
}
.margin-grid .setting-field { .margin-grid .setting-field {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
@@ -1133,4 +1265,31 @@ textarea:focus {
.settings-drawer { .settings-drawer {
width: 100vw; width: 100vw;
} }
.more-menu-popover {
max-height: min(72vh, 620px);
overflow-y: auto;
}
.more-menu-submenu {
position: static;
width: auto;
max-height: 48vh;
margin: 4px 0 0 8px;
box-shadow: none;
}
.document-dialog-layer {
align-items: end;
padding: 12px;
}
.document-dialog {
padding: 20px;
}
.document-dialog-actions {
align-items: stretch;
flex-direction: column-reverse;
}
} }
+34
View File
@@ -0,0 +1,34 @@
import type { ThemeCategory } from "@md-to-pdf/core";
import type { ThemeSummary } from "./application-backend";
const themeCategoryOrder: readonly ThemeCategory[] = [
"general",
"red-letter",
"formal",
"tender"
];
const themeCategoryLabels: Record<ThemeCategory, string> = {
general: "通用主题",
"red-letter": "政企红头",
formal: "政企正式",
tender: "标书正式"
};
export interface ThemeGroup {
category: ThemeCategory;
label: string;
themes: ThemeSummary[];
}
export function groupThemesByCategory(
themes: readonly ThemeSummary[]
): ThemeGroup[] {
return themeCategoryOrder
.map((category) => ({
category,
label: themeCategoryLabels[category],
themes: themes.filter((theme) => theme.category === category)
}))
.filter((group) => group.themes.length > 0);
}
+92
View File
@@ -0,0 +1,92 @@
import {
defaultExportConfig,
resolvePageMargins,
type ExportConfig
} from "@md-to-pdf/core";
import type { ThemeSummary } from "./application-backend";
function marginsEqual(
first: ExportConfig["paper"]["margins"],
second: ExportConfig["paper"]["margins"]
) {
return (
first.top === second.top &&
first.right === second.right &&
first.bottom === second.bottom &&
first.left === second.left
);
}
function applyThemePageDecorations(
config: ExportConfig,
theme?: ThemeSummary
): ExportConfig {
if (config.pageDecorationsMode !== "theme") {
return config;
}
const header =
theme?.pageDefaults?.header ?? defaultExportConfig.header;
const footer =
theme?.pageDefaults?.footer ?? defaultExportConfig.footer;
if (
JSON.stringify(config.header) === JSON.stringify(header) &&
JSON.stringify(config.footer) === JSON.stringify(footer)
) {
return config;
}
return {
...config,
header: structuredClone(header),
footer: structuredClone(footer)
};
}
export function applyThemeMargins(
config: ExportConfig,
theme?: ThemeSummary
): ExportConfig {
if (config.paper.marginMode !== "theme") {
return config;
}
const margins = resolvePageMargins(
config.paper,
theme?.pageDefaults?.margins
);
if (marginsEqual(config.paper.margins, margins)) {
return config;
}
return {
...config,
paper: {
...config.paper,
margins
}
};
}
export function applyThemeDefaults(
config: ExportConfig,
theme?: ThemeSummary
): ExportConfig {
return applyThemePageDecorations(
applyThemeMargins(config, theme),
theme
);
}
export function selectTheme(
config: ExportConfig,
theme: ThemeSummary
): ExportConfig {
return applyThemeDefaults(
{
...config,
themeId: theme.id
},
theme
);
}
+12 -1
View File
@@ -3,7 +3,9 @@
import type { import type {
PagedDocumentPayload, PagedDocumentPayload,
PagedDocumentRenderResult, PagedDocumentRenderResult,
RenderedMarkdownDocument RenderedMarkdownDocument,
ThemeCategory,
DocumentProfileName
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
declare global { declare global {
@@ -36,12 +38,19 @@ declare global {
listener: (reason: "open" | "reload" | "replace") => void listener: (reason: "open" | "reload" | "replace") => void
): () => void; ): () => void;
startNewMarkdown(): Promise<void>; startNewMarkdown(): Promise<void>;
createNewWindow(): Promise<void>;
resolveWindowClose(close: boolean): Promise<void>;
onWindowCloseRequested(listener: () => void): () => void;
setDocumentDirty(dirty: boolean): Promise<void>; setDocumentDirty(dirty: boolean): Promise<void>;
openDocumentLink(href: string): Promise<void>; openDocumentLink(href: string): Promise<void>;
saveMarkdown( saveMarkdown(
fileName: string, fileName: string,
markdown: string markdown: string
): Promise<{ fileName: string } | undefined>; ): Promise<{ fileName: string } | undefined>;
saveMarkdownAs(
fileName: string,
markdown: string
): Promise<{ fileName: string } | undefined>;
openThemeDirectory(): Promise<string>; openThemeDirectory(): Promise<string>;
refreshThemes(): Promise<void>; refreshThemes(): Promise<void>;
renderMarkdown(input: { renderMarkdown(input: {
@@ -58,6 +67,8 @@ declare global {
name: string; name: string;
version: string; version: string;
description: string; description: string;
category: ThemeCategory;
compatibleProfiles: DocumentProfileName[];
bundled: boolean; bundled: boolean;
source: "bundled" | "local"; source: "bundled" | "local";
}>; }>;
+13 -2
View File
@@ -1,12 +1,23 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
APP_VERSION, APP_VERSION,
APP_VERSION_LABEL APP_VERSION_LABEL
} from "../src/app-version"; } from "../src/app-version";
const webRoot = fileURLToPath(new URL("../", import.meta.url));
describe("应用版本", () => { describe("应用版本", () => {
it("从统一构建版本生成标题徽标", () => { it("从统一构建版本生成标题徽标", () => {
expect(APP_VERSION).toBe("0.5.0"); expect(APP_VERSION).toBe("0.5.1");
expect(APP_VERSION_LABEL).toBe("v0.5.0"); expect(APP_VERSION_LABEL).toBe("v0.5.1");
});
it("允许浏览器加载同源 Web Manifest", () => {
const indexHtml = readFileSync(`${webRoot}/index.html`, "utf8");
expect(indexHtml).toContain('rel="manifest"');
expect(indexHtml).toContain("manifest-src 'self'");
}); });
}); });
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
builtinThemeSamples,
getBuiltinThemeSample
} from "../src/builtin-theme-samples";
const expectedThemeIds = [
"enterprise-red-simple",
"formal-feasibility",
"formal-regulation",
"formal-report",
"gov-red-letter",
"gov-red-standard",
"red-briefing",
"tender-blind",
"tender-business-blue",
"tender-classic",
"typora-github",
"typora-like",
"typora-pixyll",
"typora-whitey"
];
describe("内置主题示例", () => {
it("为全部内置主题注册同名 Markdown 文件", () => {
expect(
builtinThemeSamples.map((sample) => sample.themeId).sort()
).toEqual(expectedThemeIds);
expect(
builtinThemeSamples.every(
(sample) => sample.fileName === `${sample.themeId}.md`
)
).toBe(true);
});
it("提供可直接编辑的非空 Markdown 内容", () => {
for (const sample of builtinThemeSamples) {
expect(sample.markdown.startsWith("---")).toBe(true);
expect(sample.markdown.length).toBeGreaterThan(300);
expect(getBuiltinThemeSample(sample.themeId)).toBe(sample);
}
expect(getBuiltinThemeSample("unknown-theme")).toBeUndefined();
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { builtinTutorials } from "../src/builtin-tutorials";
describe("内置教程", () => {
it("注册 ECharts 与公文主题教程", () => {
expect(
builtinTutorials.map(({ id, name, themeId, fileName }) => ({
id,
name,
themeId,
fileName
}))
).toEqual([
{
id: "echarts-tutorial",
name: "ECharts 图表教程",
themeId: "typora-github",
fileName: "echarts-tutorial.md"
},
{
id: "formal-document-tutorial",
name: "公文主题使用教程",
themeId: "typora-github",
fileName: "formal-document-tutorial.md"
}
]);
});
it("教程内容完整且能够直接作为 Markdown 打开", () => {
for (const tutorial of builtinTutorials) {
expect(tutorial.markdown.startsWith("---")).toBe(true);
expect(tutorial.markdown.length).toBeGreaterThan(1_000);
}
expect(builtinTutorials[0].markdown).toContain("```echarts");
expect(builtinTutorials[1].markdown).toContain(
"document.profile: official"
);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { getDocumentShortcut } from "../src/document-shortcuts";
function shortcutEvent(
key: string,
overrides: Partial<Parameters<typeof getDocumentShortcut>[0]> = {}
) {
return {
key,
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
isComposing: false,
repeat: false,
...overrides
};
}
describe("文档快捷键", () => {
it("在桌面端识别新建、保存、另存为和关闭", () => {
expect(getDocumentShortcut(shortcutEvent("n"), true)).toBe("new");
expect(getDocumentShortcut(shortcutEvent("S"), true)).toBe("save");
expect(
getDocumentShortcut(
shortcutEvent("S", { shiftKey: true }),
true
)
).toBe("save-as");
expect(getDocumentShortcut(shortcutEvent("w"), true)).toBe("close");
expect(
getDocumentShortcut(
shortcutEvent("s", { ctrlKey: false, metaKey: true }),
true
)
).toBe("save");
});
it("Web 端只接管保存", () => {
expect(getDocumentShortcut(shortcutEvent("n"), false)).toBeUndefined();
expect(getDocumentShortcut(shortcutEvent("w"), false)).toBeUndefined();
expect(getDocumentShortcut(shortcutEvent("s"), false)).toBe("save");
expect(
getDocumentShortcut(
shortcutEvent("s", { shiftKey: true }),
false
)
).toBeUndefined();
});
it("忽略组合输入、长按和带附加修饰键的按键", () => {
expect(
getDocumentShortcut(
shortcutEvent("s", { isComposing: true }),
true
)
).toBeUndefined();
expect(
getDocumentShortcut(shortcutEvent("s", { repeat: true }), true)
).toBeUndefined();
expect(
getDocumentShortcut(shortcutEvent("s", { altKey: true }), true)
).toBeUndefined();
expect(
getDocumentShortcut(
shortcutEvent("n", { shiftKey: true }),
true
)
).toBeUndefined();
expect(
getDocumentShortcut(
shortcutEvent("s", { shiftKey: true, altKey: true }),
true
)
).toBeUndefined();
});
});
+89 -7
View File
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
import { defaultExportConfig } from "@md-to-pdf/core"; import { defaultExportConfig } from "@md-to-pdf/core";
import { import {
EXPORT_CONFIG_STORAGE_KEY, EXPORT_CONFIG_STORAGE_KEY,
LEGACY_EXPORT_CONFIG_STORAGE_KEY, LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY,
LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY,
loadExportConfig, loadExportConfig,
parseStoredExportConfig, parseStoredExportConfig,
saveExportConfig saveExportConfig
@@ -10,14 +11,15 @@ import {
function createMemoryStorage( function createMemoryStorage(
initialValue: string | null = null, initialValue: string | null = null,
legacyValue: string | null = null legacyValue: string | null = null,
legacyKey = LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY
) { ) {
const values = new Map<string, string>(); const values = new Map<string, string>();
if (initialValue) { if (initialValue) {
values.set(EXPORT_CONFIG_STORAGE_KEY, initialValue); values.set(EXPORT_CONFIG_STORAGE_KEY, initialValue);
} }
if (legacyValue) { if (legacyValue) {
values.set(LEGACY_EXPORT_CONFIG_STORAGE_KEY, legacyValue); values.set(legacyKey, legacyValue);
} }
return { return {
getItem(key: string) { getItem(key: string) {
@@ -52,26 +54,106 @@ describe("导出设置缓存", () => {
).toEqual(defaultExportConfig); ).toEqual(defaultExportConfig);
}); });
it("将 v2 缓存迁移为 v3保留已有设置", () => { it("将 v2 缓存迁移为 v4让默认页边距跟随主题", () => {
const legacyConfig = { const legacyConfig = {
...defaultExportConfig, ...defaultExportConfig,
version: 2, version: 2,
mermaid: undefined, mermaid: undefined,
paper: { paper: {
...defaultExportConfig.paper, ...defaultExportConfig.paper,
marginMode: undefined,
format: "A5" format: "A5"
} }
}; };
const storage = createMemoryStorage( const storage = createMemoryStorage(
null, null,
JSON.stringify(legacyConfig) JSON.stringify(legacyConfig),
LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY
); );
const migrated = loadExportConfig(storage); const migrated = loadExportConfig(storage);
expect(migrated.version).toBe(3); expect(migrated.version).toBe(4);
expect(migrated.paper.format).toBe("A5"); expect(migrated.paper.format).toBe("A5");
expect(migrated.paper.marginMode).toBe("theme");
expect(migrated.mermaid).toEqual(defaultExportConfig.mermaid); expect(migrated.mermaid).toEqual(defaultExportConfig.mermaid);
expect(storage.read()).toContain('"version":3'); expect(storage.read()).toContain('"version":4');
});
it("将改过页边距的 v3 缓存迁移为自定义模式", () => {
const legacyConfig = {
...defaultExportConfig,
version: 3,
paper: {
...defaultExportConfig.paper,
marginMode: undefined,
margins: {
top: "20mm",
right: "18mm",
bottom: "20mm",
left: "18mm"
}
}
};
const migrated = parseStoredExportConfig(
JSON.stringify(legacyConfig)
);
expect(migrated.version).toBe(4);
expect(migrated.paper.marginMode).toBe("custom");
expect(migrated.paper.margins).toEqual(legacyConfig.paper.margins);
});
it("将未改页眉页码的旧 v4 缓存迁移为跟随主题", () => {
const legacyConfig = {
...defaultExportConfig,
pageDecorationsMode: undefined,
header: {
...defaultExportConfig.header,
fontFamily: undefined
},
footer: {
...defaultExportConfig.footer,
fontFamily: undefined,
showOnFirstPage: undefined
}
};
const migrated = parseStoredExportConfig(
JSON.stringify(legacyConfig)
);
expect(migrated.pageDecorationsMode).toBe("theme");
expect(migrated.header.fontFamily).toContain("Microsoft YaHei");
expect(migrated.footer.showOnFirstPage).toBe(true);
});
it("保留旧 v4 缓存中用户修改过的页眉页码", () => {
const legacyConfig = {
...defaultExportConfig,
pageDecorationsMode: undefined,
header: {
...defaultExportConfig.header,
enabled: true,
fontFamily: undefined,
center: {
enabled: true,
content: "${title}"
}
},
footer: {
...defaultExportConfig.footer,
alignment: "right",
fontFamily: undefined,
showOnFirstPage: undefined
}
};
const migrated = parseStoredExportConfig(
JSON.stringify(legacyConfig)
);
expect(migrated.pageDecorationsMode).toBe("custom");
expect(migrated.header.enabled).toBe(true);
expect(migrated.header.center.content).toBe("${title}");
expect(migrated.footer.alignment).toBe("right");
}); });
it("保存并恢复经过校验的配置", () => { it("保存并恢复经过校验的配置", () => {
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { ThemeSummary } from "../src/application-backend";
import { groupThemesByCategory } from "../src/theme-groups";
function createTheme(
id: string,
name: string,
category: ThemeSummary["category"]
): ThemeSummary {
return {
id,
name,
category,
version: "1.0.0",
description: "",
compatibleProfiles: [],
bundled: true,
source: "bundled"
};
}
describe("groupThemesByCategory", () => {
it("按稳定顺序输出非空主题分组", () => {
const groups = groupThemesByCategory([
createTheme("tender", "标书", "tender"),
createTheme("general", "通用", "general"),
createTheme("red", "红头", "red-letter")
]);
expect(groups.map(({ category, label }) => ({ category, label })))
.toEqual([
{ category: "general", label: "通用主题" },
{ category: "red-letter", label: "政企红头" },
{ category: "tender", label: "标书正式" }
]);
expect(groups[0]?.themes[0]?.id).toBe("general");
});
});
+140
View File
@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import { defaultExportConfig } from "@md-to-pdf/core";
import type { ThemeSummary } from "../src/application-backend";
import {
applyThemeDefaults,
applyThemeMargins,
selectTheme
} from "../src/theme-margins";
function createTheme(
id: string,
margins?: ThemeSummary["pageDefaults"]
): ThemeSummary {
return {
id,
name: id,
version: "1.0.0",
description: "",
category: "formal",
compatibleProfiles: [],
pageDefaults: margins,
bundled: true,
source: "bundled"
};
}
const formalTheme = createTheme("formal", {
margins: {
top: "37mm",
right: "26mm",
bottom: "35mm",
left: "28mm"
}
});
const officialTheme = createTheme("official", {
...formalTheme.pageDefaults,
header: {
...defaultExportConfig.header,
fontFamily: '"Mdpdf Fandol Song", SimSun, serif'
},
footer: {
...defaultExportConfig.footer,
height: "7mm",
fontSize: "4.94mm",
fontFamily: '"Mdpdf Fandol Song", SimSun, serif',
color: "#000000",
alignment: "outer",
format: "official-page",
showOnFirstPage: false
}
});
describe("主题推荐页边距", () => {
it("跟随模式切换主题时同步推荐值", () => {
const selected = selectTheme(defaultExportConfig, formalTheme);
expect(selected.themeId).toBe("formal");
expect(selected.paper.marginMode).toBe("theme");
expect(selected.paper.margins).toEqual(
formalTheme.pageDefaults?.margins
);
});
it("主题未声明推荐值时恢复全局 16mm", () => {
const config = selectTheme(defaultExportConfig, formalTheme);
const selected = selectTheme(config, createTheme("general"));
expect(selected.paper.margins).toEqual({
top: "16mm",
right: "16mm",
bottom: "16mm",
left: "16mm"
});
});
it("自定义模式切换主题时保留用户值", () => {
const config = {
...defaultExportConfig,
paper: {
...defaultExportConfig.paper,
marginMode: "custom" as const,
margins: {
top: "20mm",
right: "18mm",
bottom: "20mm",
left: "18mm"
}
}
};
const selected = selectTheme(config, formalTheme);
expect(selected.themeId).toBe("formal");
expect(selected.paper.margins).toEqual(config.paper.margins);
});
it("主题清单加载后补齐当前主题推荐值", () => {
expect(
applyThemeMargins(defaultExportConfig, formalTheme).paper.margins
).toEqual(formalTheme.pageDefaults?.margins);
});
it("跟随主题时同步推荐的页眉和页码", () => {
const selected = selectTheme(defaultExportConfig, officialTheme);
expect(selected.pageDecorationsMode).toBe("theme");
expect(selected.header.fontFamily).toContain("Fandol Song");
expect(selected.footer).toMatchObject({
alignment: "outer",
format: "official-page",
showOnFirstPage: false
});
});
it("页面装饰自定义模式切换主题时保留用户配置", () => {
const config = {
...defaultExportConfig,
pageDecorationsMode: "custom" as const,
footer: {
...defaultExportConfig.footer,
alignment: "left" as const,
format: "page" as const
}
};
const selected = selectTheme(config, officialTheme);
expect(selected.header).toEqual(config.header);
expect(selected.footer).toEqual(config.footer);
});
it("主题清单加载后补齐当前主题页面装饰", () => {
const applied = applyThemeDefaults(
defaultExportConfig,
officialTheme
);
expect(applied.footer.format).toBe("official-page");
expect(applied.footer.alignment).toBe("outer");
});
});
+2
View File
@@ -21,6 +21,7 @@ COPY tsconfig.base.json ./
COPY apps ./apps COPY apps ./apps
COPY packages ./packages COPY packages ./packages
COPY logos ./logos COPY logos ./logos
COPY samples ./samples
RUN npm run build -w @md-to-pdf/markdown-echarts \ 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/core \
&& npm run build -w @md-to-pdf/renderer \ && npm run build -w @md-to-pdf/renderer \
@@ -89,6 +90,7 @@ COPY --chown=pwuser:pwuser --from=build /app/packages/application/dist ./package
COPY --chown=pwuser:pwuser --from=build /app/packages/markdown-echarts/dist ./packages/markdown-echarts/dist COPY --chown=pwuser:pwuser --from=build /app/packages/markdown-echarts/dist ./packages/markdown-echarts/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/preview-engine/dist ./packages/preview-engine/dist COPY --chown=pwuser:pwuser --from=build /app/packages/preview-engine/dist ./packages/preview-engine/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/renderer/dist ./packages/renderer/dist COPY --chown=pwuser:pwuser --from=build /app/packages/renderer/dist ./packages/renderer/dist
COPY --chown=pwuser:pwuser --from=build /app/samples ./samples
COPY --chown=pwuser:pwuser themes ./themes COPY --chown=pwuser:pwuser themes ./themes
COPY deploy/nginx.conf /etc/nginx/nginx.conf COPY deploy/nginx.conf /etc/nginx/nginx.conf
COPY --chown=pwuser:pwuser deploy/entrypoint.sh /app/deploy/entrypoint.sh COPY --chown=pwuser:pwuser deploy/entrypoint.sh /app/deploy/entrypoint.sh
+5 -5
View File
@@ -37,7 +37,7 @@ docker compose -f deploy\compose.yaml down
可以直接指定版本化镜像名称构建: 可以直接指定版本化镜像名称构建:
```powershell ```powershell
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.5.0' $env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.5.1'
docker compose -f deploy\compose.yaml build docker compose -f deploy\compose.yaml build
``` ```
@@ -47,13 +47,13 @@ docker compose -f deploy\compose.yaml build
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层: 已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
```powershell ```powershell
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.5.0' $env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.5.1'
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.4.5' $env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.4.5'
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1' $env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
docker compose -f deploy\compose.yaml build docker compose -f deploy\compose.yaml build
``` ```
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.5.0 应用代码和生产 复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.5.1 应用代码和生产
依赖。正式跨机器构建仍建议使用默认完整路径。 依赖。正式跨机器构建仍建议使用默认完整路径。
## 主题挂载 ## 主题挂载
@@ -69,8 +69,8 @@ docker compose -f deploy\compose.yaml up -d
目录下每套主题都应包含 `theme.json`。主题规范详见 目录下每套主题都应包含 `theme.json`。主题规范详见
[`docs/THEMES.md`](../docs/THEMES.md)。 [`docs/THEMES.md`](../docs/THEMES.md)。
当前内置 Typora Github、Typora Pixyll、Typora whitey 当前内置 4 套 Typora 风格主题、4 套红头主题、3 套正式文档主题
Typora Clean 四套主题;挂载目录只用于额外的本机扩展主题,无需重复 3 套标书主题;挂载目录只用于额外的本机扩展主题,无需重复
放置内置主题。内置主题与挂载主题 ID 重复时以内置版本为准。 放置内置主题。内置主题与挂载主题 ID 重复时以内置版本为准。
## 运行参数 ## 运行参数
+75 -1
View File
@@ -1,6 +1,6 @@
# Markdown PDF 导出器进度 # Markdown PDF 导出器进度
最后更新:2026-07-28 最后更新:2026-07-29
## 1. 当前概况 ## 1. 当前概况
@@ -55,6 +55,12 @@ Web/Compose 发布仍保持 `v0.4.1`。
聚焦时外部文件变化提示。四套主题统一命名,并修复围栏代码块的 Typora 聚焦时外部文件变化提示。四套主题统一命名,并修复围栏代码块的 Typora
DOM 兼容和桌面发行包复用陈旧 Web 产物的问题。 DOM 兼容和桌面发行包复用陈旧 Web 产物的问题。
`v0.5.1` 正式版新增 10 套政企与标书主题、规范页边距和页面装饰、
结构化公文 Front Matter、内置中文字体、教程及 14 份主题示例;同时
重组“更多”菜单,补齐桌面文件快捷键、未保存确认和跟随新路径的另存为,
并修复红头标题居中、代码块自动换行、左侧内容留白和跨页分页。Windows
安装包、Docker Compose、本机安装与发布验证均已完成。
## 2. 已完成 ## 2. 已完成
### 2.1 项目骨架 ### 2.1 项目骨架
@@ -507,8 +513,76 @@ DOM 兼容和桌面发行包复用陈旧 Web 产物的问题。
`package``make` 强制先调用该脚本,防止安装包复制陈旧 `package``make` 强制先调用该脚本,防止安装包复制陈旧
`apps/web/dist` `apps/web/dist`
### 3.20 v0.5.1 正式文档主题与桌面文件工作流
- 围栏代码按正文宽度自动换行,长单词允许安全断行;代码内容统一保留
`8px` 左侧内边距,并保留代码块外框、语法高亮和 Typora 兼容 DOM。
- 多行代码块按至少两行一组生成可分页分片,当前页能容纳一组时不再将
整块代码推到下一页;跨页分片保持连续边框和完整文本。
- 新增政企标准红头、函件红头、企业简版红头、简报红头 4 套红头主题,
正式制度、正式报告、可研报告 3 套正式文档主题,以及经典标书、商务蓝
标书、暗标 3 套标书主题。
- 主题清单新增分类、文档结构预设、推荐导出设置和页面装饰;没有主题推荐
时保持 16mm 默认页边距,正式主题自动采用规范页边距、页眉页脚和页码。
- Renderer 根据结构化 Front Matter 生成红头、文号、签发人、密级、
主送、正文、附件、落款、抄送和印发信息,也支持项目报告与标书封面。
- 红头标题使用居中网格布局,签发人不再挤压发文机关;正文、红头标题、
页眉页脚使用主题内置 Fandol 中文字体,降低跨机器显示差异。
- `samples/tutorials` 内置 ECharts 和公文主题教程,包含 YAML 示例、
字段说明、图表标题和图例渲染;公文教程保持 Typora Github 阅读主题。
- `samples/themes` 为全部 14 套主题提供示例;Desktop 发行配置将教程、
示例和字体随应用复制,打开主题示例时自动切换相应主题。
- 移除顶部加号入口,将文件操作、教程与示例、主题管理整合为三个菜单
分区;文件操作统一命名为“新建文件”“保存文件”“另存为文件”。
- Desktop 支持 `Ctrl+N``Ctrl+W``Ctrl+S``Ctrl+Shift+S`,新建和
关闭时对未保存内容进行确认;另存为成功后当前窗口追踪新文件路径,
Web 端不显示或拦截“另存为”。
## 4. 已执行验证 ## 4. 已执行验证
2026-07-29 对 v0.5.1 完整工作区及最终代码块修复执行:
```text
npm test
npm run typecheck
npm run build
git diff --check
```
- 最终修复前的完整工作区共 65 个测试文件、286 项测试全部通过;将
左侧内边距调整到共享分页规则后,Preview Engine 18 项和 Application
9 项定向测试再次通过;
- Markdown ECharts、Core、Renderer、Application、Preview Engine、
Web、Server 和 Desktop 类型检查全部通过;
- Web 生产资源、Server 和 Desktop 主进程/Preload 构建通过,生产资源
中的应用版本为 `0.5.1`
- 保留的手动验收服务继续运行:Web 与主题 API 均返回 HTTP 200
Electron 应用及其 GPU、网络、渲染进程正常存活;
- `git diff --check` 通过,仅有 Git 对 Windows 工作区换行转换的提示。
- v0.5.1 Windows 目录版、NSIS 和 ZIP 经完整内嵌 Web 重建后生成;
目录版主程序文件版本为 `0.5.1`、产品版本为 `0.5.1.0`,启动后正常
响应,并保留独立验收窗口。
- 目录版包含 14 套主题、14 份主题示例、2 份教程、5 个 Fandol WOFF2
中文字体及其许可文件,语言包仅保留 `zh-CN``en-US`ZIP 中同样
检出主题、示例和字体资源。
- NSIS 安装包 `md-to-pdf-0.5.1-x86_64-Setup.exe` 为 114,688,014
字节,SHA-256 为
`676CCE73D782B0B15AC6BC68F253CFBC4740383583E4DAA98F746EDF9999C5CC`
- 免安装包 `md-to-pdf-0.5.1-x86_64.zip` 为 152,183,607 字节,
SHA-256 为
`82BF6ADF1200604F145CC86FA3ED193955CF6741EBEE3DF8953483515CFF621F`
- 主程序与安装包 Authenticode 状态均为未签名,与当前发行说明一致。
- 正式镜像 `yixiong/md-to-pdf:v0.5.1` 内容 ID 为
`sha256:72f519a69bfd6cb2f6df30c2be938e58ceb6f20e4748a32551874de3d436b276`
Compose 强制替换后健康运行在 `http://localhost:8080`
- 容器确认包含 14 套主题和 17 份 Markdown 教程/示例;生产 Bundle
同时包含代码自动换行、任意长单词断行和 `8px` 左侧内边距。
- 容器代码块回归 PDF 返回 HTTP 200,共 2 页、64,721 字节,
Mermaid/ECharts 错误数均为 0;视觉检查确认长 SQL 正确换行、跨页
连续、24 个字段完整且没有右侧越界。
- 本机 NSIS 安装项版本为 `0.5.1`,发布者为 `YIXIONG Tech.ltd`
用户完成安装和桌面端手动验收,未发现发布阻塞问题。
2026-07-28 在当前完整工作区成功执行: 2026-07-28 在当前完整工作区成功执行:
```text ```text
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "md-to-pdf", "name": "md-to-pdf",
"version": "0.5.0", "version": "0.5.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "md-to-pdf", "name": "md-to-pdf",
"version": "0.5.0", "version": "0.5.1",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"
@@ -21,7 +21,7 @@
}, },
"apps/desktop": { "apps/desktop": {
"name": "@md-to-pdf/desktop", "name": "@md-to-pdf/desktop",
"version": "0.5.0", "version": "0.5.1",
"devDependencies": { "devDependencies": {
"@md-to-pdf/application": "0.4.1", "@md-to-pdf/application": "0.4.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "md-to-pdf", "name": "md-to-pdf",
"version": "0.5.0", "version": "0.5.1",
"private": true, "private": true,
"description": "可配置、可主题化的 Markdown PDF 导出工具", "description": "可配置、可主题化的 Markdown PDF 导出工具",
"workspaces": [ "workspaces": [
@@ -104,6 +104,9 @@ export function createApplicationService(
name: manifest.name, name: manifest.name,
version: manifest.version, version: manifest.version,
description: manifest.description, description: manifest.description,
category: manifest.category,
compatibleProfiles: manifest.compatibleProfiles,
pageDefaults: manifest.pageDefaults,
bundled: manifest.bundled, bundled: manifest.bundled,
source source
})) }))
+35 -4
View File
@@ -46,6 +46,8 @@ const assetContentTypes: Record<string, string> = {
const cssImportPattern = const cssImportPattern =
/@import\s+(?:url\(\s*(?:\"([^\"]+)\"|'([^']+)'|([^'\"\s)]+))\s*\)|\"([^\"]+)\"|'([^']+)')\s*;/gi; /@import\s+(?:url\(\s*(?:\"([^\"]+)\"|'([^']+)'|([^'\"\s)]+))\s*\)|\"([^\"]+)\"|'([^']+)')\s*;/gi;
const maximumCssImportDepth = 8; const maximumCssImportDepth = 8;
const sharedThemeAssetId = "_shared";
const sharedThemeAssetScheme = "theme-shared:";
function isMissingFileError(error: unknown) { function isMissingFileError(error: unknown) {
return ( return (
@@ -110,7 +112,7 @@ async function readThemeRoot(
const records: ThemeRecord[] = []; const records: ThemeRecord[] = [];
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) { if (!entry.isDirectory() || entry.name.startsWith("_")) {
continue; continue;
} }
@@ -165,7 +167,8 @@ function prepareCssSegment(
css: string, css: string,
themeId: string, themeId: string,
cssDirectory: string, cssDirectory: string,
createAssetUrl: (themeId: string, assetPath: string) => string createAssetUrl: (themeId: string, assetPath: string) => string,
allowSharedAssets: boolean
) { ) {
return css.replace( return css.replace(
/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi,
@@ -174,6 +177,18 @@ function prepareCssSegment(
if (value.startsWith("data:") || value.startsWith("#")) { if (value.startsWith("data:") || value.startsWith("#")) {
return match; return match;
} }
if (value.startsWith(sharedThemeAssetScheme)) {
if (!allowSharedAssets) {
throw new Error("本地主题不能引用内置共享资源");
}
const sharedPath = normalizeCssPath(
value.slice(sharedThemeAssetScheme.length)
);
if (!sharedPath || sharedPath === ".") {
throw new Error("内置共享资源路径不能为空");
}
return `url("${createAssetUrl(sharedThemeAssetId, sharedPath)}")`;
}
if ( if (
value.startsWith("/") || value.startsWith("/") ||
value.startsWith("//") || value.startsWith("//") ||
@@ -250,7 +265,8 @@ async function loadThemeCssFile(
css.slice(previousEnd, matchStart), css.slice(previousEnd, matchStart),
theme.manifest.id, theme.manifest.id,
cssDirectory, cssDirectory,
createAssetUrl createAssetUrl,
theme.source === "bundled"
); );
const importedPath = readImportedPath(match); const importedPath = readImportedPath(match);
@@ -273,7 +289,8 @@ async function loadThemeCssFile(
remainder, remainder,
theme.manifest.id, theme.manifest.id,
cssDirectory, cssDirectory,
createAssetUrl createAssetUrl,
theme.source === "bundled"
); );
if (/@import\b/i.test(result)) { if (/@import\b/i.test(result)) {
throw new Error("主题包含不支持的 CSS @import 语法"); throw new Error("主题包含不支持的 CSS @import 语法");
@@ -378,6 +395,20 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
} }
async function getAsset(themeId: string, assetPath: string) { async function getAsset(themeId: string, assetPath: string) {
if (themeId === sharedThemeAssetId) {
const extension = extname(assetPath).toLowerCase();
const contentType = assetContentTypes[extension];
if (!contentType) {
throw new Error("不支持的主题资源类型");
}
const sharedRoot = resolve(options.bundledRoot, sharedThemeAssetId);
const path = await resolveThemeFile(sharedRoot, assetPath);
return {
contentType,
content: await readFile(path)
};
}
const theme = await get(themeId); const theme = await get(themeId);
if (!theme) { if (!theme) {
return undefined; return undefined;
@@ -37,7 +37,13 @@ async function createThemeFixture() {
const bundledRoot = join(temporaryDirectory, "bundled"); const bundledRoot = join(temporaryDirectory, "bundled");
const localRoot = join(temporaryDirectory, "local"); const localRoot = join(temporaryDirectory, "local");
const themeRoot = join(bundledRoot, "test-theme"); const themeRoot = join(bundledRoot, "test-theme");
const sharedFontRoot = join(
bundledRoot,
"_shared",
"official-fonts"
);
await mkdir(join(themeRoot, "fonts"), { recursive: true }); await mkdir(join(themeRoot, "fonts"), { recursive: true });
await mkdir(sharedFontRoot, { recursive: true });
await mkdir(localRoot, { recursive: true }); await mkdir(localRoot, { recursive: true });
await writeFile( await writeFile(
join(themeRoot, "theme.json"), join(themeRoot, "theme.json"),
@@ -53,6 +59,14 @@ async function createThemeFixture() {
domPreset: "typora", domPreset: "typora",
defaultFontSize: "16px", defaultFontSize: "16px",
supportedFeatures: ["code", "table"], supportedFeatures: ["code", "table"],
pageDefaults: {
margins: {
top: "37mm",
right: "26mm",
bottom: "35mm",
left: "28mm"
}
},
bundled: true bundled: true
}), }),
"utf8" "utf8"
@@ -64,6 +78,10 @@ async function createThemeFixture() {
" font-family: Test;", " font-family: Test;",
" src: url('./fonts/test.woff2') format('woff2');", " src: url('./fonts/test.woff2') format('woff2');",
"}", "}",
"@font-face {",
" font-family: SharedTest;",
" src: url('theme-shared:official-fonts/shared.woff2') format('woff2');",
"}",
"#write { font-family: Test; }" "#write { font-family: Test; }"
].join("\n"), ].join("\n"),
"utf8" "utf8"
@@ -72,6 +90,10 @@ async function createThemeFixture() {
join(themeRoot, "fonts", "test.woff2"), join(themeRoot, "fonts", "test.woff2"),
Buffer.from([0, 1, 2, 3]) Buffer.from([0, 1, 2, 3])
); );
await writeFile(
join(sharedFontRoot, "shared.woff2"),
Buffer.from([4, 5, 6, 7])
);
return { bundledRoot, localRoot }; return { bundledRoot, localRoot };
} }
@@ -147,6 +169,16 @@ describe("共享应用服务", () => {
themes: [ themes: [
expect.objectContaining({ expect.objectContaining({
id: "test-theme", id: "test-theme",
category: "general",
compatibleProfiles: [],
pageDefaults: {
margins: {
top: "37mm",
right: "26mm",
bottom: "35mm",
left: "28mm"
}
},
bundled: true, bundled: true,
source: "bundled" source: "bundled"
}) })
@@ -157,6 +189,11 @@ describe("共享应用服务", () => {
).resolves.toContain( ).resolves.toContain(
"mdpdf://theme/test-theme/fonts/test.woff2" "mdpdf://theme/test-theme/fonts/test.woff2"
); );
await expect(
service.getThemeCss("test-theme")
).resolves.toContain(
"mdpdf://theme/_shared/official-fonts/shared.woff2"
);
}); });
it("安全读取主题二进制资源", async () => { it("安全读取主题二进制资源", async () => {
@@ -172,6 +209,18 @@ describe("共享应用服务", () => {
await expect( await expect(
service.getThemeAsset("test-theme", "../test.woff2") service.getThemeAsset("test-theme", "../test.woff2")
).rejects.toThrow("主题资源路径不安全"); ).rejects.toThrow("主题资源路径不安全");
await expect(
service.getThemeAsset(
"_shared",
"official-fonts/shared.woff2"
)
).resolves.toEqual({
contentType: "font/woff2",
content: Buffer.from([4, 5, 6, 7])
});
await expect(
service.getThemeAsset("_shared", "../shared.woff2")
).rejects.toThrow("主题资源路径不安全");
}); });
it("内置主题优先于同 ID 的旧本地副本", async () => { it("内置主题优先于同 ID 的旧本地副本", async () => {
@@ -1,4 +1,4 @@
import { readFile } from "node:fs/promises"; import { readFile, readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
@@ -6,10 +6,65 @@ const bundledThemeNames = {
"typora-github": "Typora Github", "typora-github": "Typora Github",
"typora-pixyll": "Typora Pixyll", "typora-pixyll": "Typora Pixyll",
"typora-whitey": "Typora whitey", "typora-whitey": "Typora whitey",
"typora-like": "Typora Clean" "typora-like": "Typora Clean",
"gov-red-standard": "政企红头·标准文件",
"gov-red-letter": "政企红头·标准信函",
"enterprise-red-simple": "政企红头·企业简版",
"red-briefing": "政企红头·纪要简报",
"formal-report": "政企正式·工作报告",
"formal-regulation": "政企正式·规章制度",
"formal-feasibility": "政企正式·可研报告",
"tender-classic": "标书正式·经典黑白",
"tender-business-blue": "标书正式·商务蓝",
"tender-blind": "标书正式·技术暗标"
} as const; } as const;
describe("内置主题清单", () => { describe("内置主题清单", () => {
it("每个内置主题都有同名 Markdown 示例", async () => {
const samplesRoot = fileURLToPath(
new URL("../../../samples/themes/", import.meta.url)
);
const sampleFiles = (await readdir(samplesRoot))
.filter((fileName) => fileName.endsWith(".md"))
.sort();
const expectedFiles = Object.keys(bundledThemeNames)
.map((id) => `${id}.md`)
.sort();
expect(sampleFiles).toEqual(expectedFiles);
expect(sampleFiles).not.toContain("_shared.md");
for (const fileName of sampleFiles) {
const markdown = await readFile(
`${samplesRoot}${fileName}`,
"utf8"
);
expect(markdown.length).toBeGreaterThan(300);
expect(markdown).toMatch(/^---[\s\S]+---/u);
}
});
it("提供可随桌面发行包分发的内置教程", async () => {
const tutorialsRoot = fileURLToPath(
new URL("../../../samples/tutorials/", import.meta.url)
);
const tutorialFiles = (await readdir(tutorialsRoot))
.filter((fileName) => fileName.endsWith(".md"))
.sort();
expect(tutorialFiles).toEqual([
"echarts-tutorial.md",
"formal-document-tutorial.md"
]);
for (const fileName of tutorialFiles) {
const markdown = await readFile(
`${tutorialsRoot}${fileName}`,
"utf8"
);
expect(markdown.startsWith("---")).toBe(true);
expect(markdown.length).toBeGreaterThan(1_000);
}
});
it("使用稳定主题 ID 和面向用户的显示名称", async () => { it("使用稳定主题 ID 和面向用户的显示名称", async () => {
const themesRoot = fileURLToPath( const themesRoot = fileURLToPath(
new URL("../../../themes/", import.meta.url) new URL("../../../themes/", import.meta.url)
@@ -27,4 +82,175 @@ describe("内置主题清单", () => {
expect(manifest.name).toBe(name); expect(manifest.name).toBe(name);
} }
}); });
it("为红头主题声明分类与兼容的结构化文档", async () => {
const themesRoot = fileURLToPath(
new URL("../../../themes/", import.meta.url)
);
const expectedProfiles = {
"gov-red-standard": "official",
"gov-red-letter": "official",
"enterprise-red-simple": "official",
"red-briefing": "briefing"
} as const;
for (const [id, profile] of Object.entries(expectedProfiles)) {
const manifest = JSON.parse(
await readFile(`${themesRoot}${id}/theme.json`, "utf8")
) as {
category?: unknown;
compatibleProfiles?: unknown;
};
expect(manifest.category).toBe("red-letter");
expect(manifest.compatibleProfiles).toContain(profile);
}
});
it("标准信函主题提供首页版头和末页底部红色双线", async () => {
const [themeCss, printCss] = await Promise.all([
readFile(
new URL(
"../../../themes/gov-red-letter/theme.css",
import.meta.url
),
"utf8"
),
readFile(
new URL(
"../../../themes/gov-red-letter/print.css",
import.meta.url
),
"utf8"
)
]);
expect(themeCss).toContain(".doc-masthead-official::after");
expect(printCss).toContain(
".pagedjs_pages > .pagedjs_page:last-child .pagedjs_pagebox::after"
);
expect(printCss).toContain("bottom: 20mm");
});
it("为正式文档与标书主题声明正确分类", async () => {
const themesRoot = fileURLToPath(
new URL("../../../themes/", import.meta.url)
);
const expectedThemes = {
"formal-report": {
category: "formal",
profile: undefined
},
"formal-regulation": {
category: "formal",
profile: undefined
},
"formal-feasibility": {
category: "formal",
profile: "project-report"
},
"tender-classic": {
category: "tender",
profile: "tender"
},
"tender-business-blue": {
category: "tender",
profile: "tender"
},
"tender-blind": {
category: "tender",
profile: "tender"
}
} as const;
for (const [id, expected] of Object.entries(expectedThemes)) {
const manifest = JSON.parse(
await readFile(`${themesRoot}${id}/theme.json`, "utf8")
) as {
category?: unknown;
compatibleProfiles?: unknown[];
};
expect(manifest.category).toBe(expected.category);
if (expected.profile) {
expect(manifest.compatibleProfiles).toContain(
expected.profile
);
} else {
expect(manifest.compatibleProfiles).toEqual([]);
}
}
});
it("技术暗标主题隐藏结构化身份字段", async () => {
const css = await readFile(
new URL(
"../../../themes/tender-blind/theme.css",
import.meta.url
),
"utf8"
);
expect(css).toMatch(
/\.doc-cover-bidder,[\s\S]*\.doc-cover-representative\s*\{[\s\S]*display:\s*none\s*!important;/u
);
});
it("正式主题声明符合用途的推荐页边距", async () => {
const themesRoot = fileURLToPath(
new URL("../../../themes/", import.meta.url)
);
const expectedMargins = {
"gov-red-standard": ["37mm", "26mm", "35mm", "28mm"],
"gov-red-letter": ["37mm", "26mm", "35mm", "28mm"],
"enterprise-red-simple": ["25mm", "25mm", "25mm", "28mm"],
"red-briefing": ["25mm", "25mm", "25mm", "25mm"],
"formal-report": ["37mm", "26mm", "35mm", "28mm"],
"formal-regulation": ["37mm", "26mm", "35mm", "28mm"],
"formal-feasibility": ["25mm", "20mm", "25mm", "30mm"],
"tender-classic": ["25mm", "25mm", "25mm", "30mm"],
"tender-business-blue": ["25mm", "25mm", "25mm", "30mm"],
"tender-blind": ["25.4mm", "25.4mm", "25.4mm", "31.7mm"]
} as const;
for (const [id, expected] of Object.entries(expectedMargins)) {
const manifest = JSON.parse(
await readFile(`${themesRoot}${id}/theme.json`, "utf8")
) as {
pageDefaults?: {
margins?: Record<"top" | "right" | "bottom" | "left", string>;
};
};
const margins = manifest.pageDefaults?.margins;
expect([
margins?.top,
margins?.right,
margins?.bottom,
margins?.left
]).toEqual(expected);
}
});
it("红头机关名称不继承正文首行缩进", async () => {
const themesRoot = fileURLToPath(
new URL("../../../themes/", import.meta.url)
);
for (const id of [
"gov-red-standard",
"gov-red-letter",
"enterprise-red-simple"
]) {
const css = await readFile(
`${themesRoot}${id}/theme.css`,
"utf8"
);
expect(css).toMatch(
/#write \.doc-issuer\s*\{[^}]*text-align:\s*center;[^}]*text-indent:\s*0;/su
);
}
});
}); });
+99
View File
@@ -0,0 +1,99 @@
import { z } from "zod";
const scalarTextSchema = (maximumLength: number) =>
z.preprocess(
(value) => {
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return String(value).trim();
}
return value;
},
z.string().min(1).max(maximumLength)
);
const shortTextSchema = scalarTextSchema(120);
const longTextSchema = scalarTextSchema(500);
const textListSchema = z.preprocess(
(value) =>
typeof value === "string"
? value
.split(/[,]/u)
.map((item) => item.trim())
.filter(Boolean)
: value,
z.array(shortTextSchema).max(30)
);
export const documentProfileNameSchema = z.enum([
"official",
"briefing",
"project-report",
"tender"
]);
const officialDocumentProfileSchema = z
.object({
profile: z.literal("official"),
issuer: shortTextSchema.optional(),
number: shortTextSchema.optional(),
secrecy: shortTextSchema.optional(),
urgency: shortTextSchema.optional(),
signatory: shortTextSchema.optional(),
date: shortTextSchema.optional(),
copyTo: textListSchema.optional(),
printingOffice: shortTextSchema.optional()
})
.strict();
const briefingDocumentProfileSchema = z
.object({
profile: z.literal("briefing"),
masthead: shortTextSchema.optional(),
issue: shortTextSchema.optional(),
publisher: shortTextSchema.optional(),
signatory: shortTextSchema.optional(),
date: shortTextSchema.optional(),
contact: longTextSchema.optional()
})
.strict();
const projectReportDocumentProfileSchema = z
.object({
profile: z.literal("project-report"),
projectName: longTextSchema.optional(),
documentType: shortTextSchema.optional(),
owner: longTextSchema.optional(),
preparedBy: longTextSchema.optional(),
version: shortTextSchema.optional(),
date: shortTextSchema.optional()
})
.strict();
const tenderDocumentProfileSchema = z
.object({
profile: z.literal("tender"),
copyMark: shortTextSchema.optional(),
projectName: longTextSchema.optional(),
projectNumber: shortTextSchema.optional(),
volume: shortTextSchema.optional(),
bidder: longTextSchema.optional(),
representative: shortTextSchema.optional(),
date: shortTextSchema.optional()
})
.strict();
export const documentProfileSchema = z.discriminatedUnion("profile", [
officialDocumentProfileSchema,
briefingDocumentProfileSchema,
projectReportDocumentProfileSchema,
tenderDocumentProfileSchema
]);
export type DocumentProfileName = z.infer<
typeof documentProfileNameSchema
>;
export type DocumentProfile = z.infer<typeof documentProfileSchema>;
+2
View File
@@ -1,4 +1,5 @@
import type { ExportConfig } from "./export-config.js"; import type { ExportConfig } from "./export-config.js";
import type { DocumentProfile } from "./document-profile.js";
import type { ThemeFeature } from "./theme.js"; import type { ThemeFeature } from "./theme.js";
export interface MarkdownDocumentMetadata { export interface MarkdownDocumentMetadata {
@@ -7,6 +8,7 @@ export interface MarkdownDocumentMetadata {
subject: string; subject: string;
keywords: string[]; keywords: string[];
language: string; language: string;
document?: DocumentProfile;
} }
export interface RenderedMarkdownDocument { export interface RenderedMarkdownDocument {
+66 -15
View File
@@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
export const EXPORT_CONFIG_VERSION = 3; export const EXPORT_CONFIG_VERSION = 4;
export const CSS_PIXELS_PER_INCH = 96; export const CSS_PIXELS_PER_INCH = 96;
export const PDF_POINTS_PER_INCH = 72; export const PDF_POINTS_PER_INCH = 72;
export const MILLIMETERS_PER_INCH = 25.4; export const MILLIMETERS_PER_INCH = 25.4;
@@ -86,23 +86,39 @@ export function millimetersToPdfPoints(value: number) {
return (value * PDF_POINTS_PER_INCH) / MILLIMETERS_PER_INCH; return (value * PDF_POINTS_PER_INCH) / MILLIMETERS_PER_INCH;
} }
const marginSchema = z.object({ export const pageMarginsSchema = z.object({
top: lengthSchema, top: lengthSchema,
right: lengthSchema, right: lengthSchema,
bottom: lengthSchema, bottom: lengthSchema,
left: lengthSchema left: lengthSchema
}); });
const headerSlotSchema = z.object({ export const marginModeSchema = z.enum(["theme", "custom"]);
export const pageDecorationsModeSchema = z.enum(["theme", "custom"]);
export const headerSlotSchema = z.object({
enabled: z.boolean(), enabled: z.boolean(),
content: z.string().max(500) content: z.string().max(500)
}); });
const headerSchema = z.object({ const pageDecorationFontFamilySchema = z
.string()
.trim()
.min(1)
.max(300)
.regex(
/^[\p{L}\p{N}\s,"'-]+$/u,
"页眉页码字体只能包含字体名称、引号和逗号"
);
export const headerSchema = z.object({
enabled: z.boolean(), enabled: z.boolean(),
height: lengthSchema, height: lengthSchema,
showDivider: z.boolean(), showDivider: z.boolean(),
fontSize: lengthSchema, fontSize: lengthSchema,
fontFamily: pageDecorationFontFamilySchema.default(
'"Segoe UI", "Microsoft YaHei", sans-serif'
),
color: z.string(), color: z.string(),
left: headerSlotSchema, left: headerSlotSchema,
center: headerSlotSchema, center: headerSlotSchema,
@@ -114,26 +130,39 @@ export const pageNumberFormatSchema = z.enum([
"page-total", "page-total",
"chinese-page-total", "chinese-page-total",
"dash-page", "dash-page",
"official-page",
"custom" "custom"
]); ]);
const footerSchema = z.object({ export const footerAlignmentSchema = z.enum([
"left",
"center",
"right",
"outer"
]);
export const footerSchema = z.object({
enabled: z.boolean(), enabled: z.boolean(),
height: lengthSchema, height: lengthSchema,
showDivider: z.boolean(), showDivider: z.boolean(),
fontSize: lengthSchema, fontSize: lengthSchema,
fontFamily: pageDecorationFontFamilySchema.default(
'"Segoe UI", "Microsoft YaHei", sans-serif'
),
color: z.string(), color: z.string(),
alignment: z.enum(["left", "center", "right"]), alignment: footerAlignmentSchema,
format: pageNumberFormatSchema, format: pageNumberFormatSchema,
template: z.string().max(500).optional(), template: z.string().max(500).optional(),
startFrom: z.number().int().positive() startFrom: z.number().int().positive(),
showOnFirstPage: z.boolean().default(true)
}); });
const paperSchema = z const paperSchema = z
.object({ .object({
format: z.enum(supportedPaperFormats), format: z.enum(supportedPaperFormats),
orientation: z.enum(["portrait", "landscape"]), orientation: z.enum(["portrait", "landscape"]),
margins: marginSchema marginMode: marginModeSchema,
margins: pageMarginsSchema
}) })
.superRefine((paper, context) => { .superRefine((paper, context) => {
const sourceDimensions = paperDimensionsMm[paper.format]; const sourceDimensions = paperDimensionsMm[paper.format];
@@ -178,6 +207,7 @@ export const exportConfigSchema = z.object({
version: z.literal(EXPORT_CONFIG_VERSION), version: z.literal(EXPORT_CONFIG_VERSION),
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
themeId: z.string().min(1), themeId: z.string().min(1),
pageDecorationsMode: pageDecorationsModeSchema.default("theme"),
mermaid: mermaidConfigSchema, mermaid: mermaidConfigSchema,
paper: paperSchema, paper: paperSchema,
header: headerSchema, header: headerSchema,
@@ -200,9 +230,30 @@ export type ExportConfig = z.infer<typeof exportConfigSchema>;
export type MermaidExportConfig = ExportConfig["mermaid"]; export type MermaidExportConfig = ExportConfig["mermaid"];
export type PaperFormat = ExportConfig["paper"]["format"]; export type PaperFormat = ExportConfig["paper"]["format"];
export type PaperOrientation = ExportConfig["paper"]["orientation"]; export type PaperOrientation = ExportConfig["paper"]["orientation"];
export type PageMargins = ExportConfig["paper"]["margins"];
export type MarginMode = ExportConfig["paper"]["marginMode"];
export type PageDecorationsMode = ExportConfig["pageDecorationsMode"];
export type HeaderConfig = ExportConfig["header"]; export type HeaderConfig = ExportConfig["header"];
export type FooterConfig = ExportConfig["footer"]; export type FooterConfig = ExportConfig["footer"];
export const defaultPageMargins: PageMargins = {
top: "16mm",
right: "16mm",
bottom: "16mm",
left: "16mm"
};
export function resolvePageMargins(
paper: Pick<ExportConfig["paper"], "marginMode" | "margins">,
themeMargins?: PageMargins
): PageMargins {
const source =
paper.marginMode === "theme"
? themeMargins ?? defaultPageMargins
: paper.margins;
return { ...source };
}
export function getPaperDimensionsMm( export function getPaperDimensionsMm(
format: PaperFormat, format: PaperFormat,
orientation: PaperOrientation orientation: PaperOrientation
@@ -222,6 +273,7 @@ export const defaultExportConfig: ExportConfig = {
version: EXPORT_CONFIG_VERSION, version: EXPORT_CONFIG_VERSION,
name: "默认 A4 文档", name: "默认 A4 文档",
themeId: "typora-github", themeId: "typora-github",
pageDecorationsMode: "theme",
mermaid: { mermaid: {
layout: "dagre", layout: "dagre",
theme: "default", theme: "default",
@@ -231,18 +283,15 @@ export const defaultExportConfig: ExportConfig = {
paper: { paper: {
format: "A4", format: "A4",
orientation: "portrait", orientation: "portrait",
margins: { marginMode: "theme",
top: "16mm", margins: { ...defaultPageMargins }
right: "16mm",
bottom: "16mm",
left: "16mm"
}
}, },
header: { header: {
enabled: false, enabled: false,
height: "8mm", height: "8mm",
showDivider: false, showDivider: false,
fontSize: "3mm", fontSize: "3mm",
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
color: "#6b7280", color: "#6b7280",
left: disabledHeaderSlot, left: disabledHeaderSlot,
center: disabledHeaderSlot, center: disabledHeaderSlot,
@@ -253,10 +302,12 @@ export const defaultExportConfig: ExportConfig = {
height: "8mm", height: "8mm",
showDivider: false, showDivider: false,
fontSize: "3mm", fontSize: "3mm",
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
color: "#6b7280", color: "#6b7280",
alignment: "center", alignment: "center",
format: "page-total", format: "page-total",
startFrom: 1 startFrom: 1,
showOnFirstPage: true
}, },
metadata: { metadata: {
title: "", title: "",
+1
View File
@@ -1,4 +1,5 @@
export * from "./document.js"; export * from "./document.js";
export * from "./document-profile.js";
export * from "./document-link.js"; export * from "./document-link.js";
export * from "./export-config.js"; export * from "./export-config.js";
export * from "./theme.js"; export * from "./theme.js";
+25
View File
@@ -1,4 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { documentProfileNameSchema } from "./document-profile.js";
import {
footerSchema,
headerSchema,
pageMarginsSchema
} from "./export-config.js";
export const THEME_MANIFEST_VERSION = 1; export const THEME_MANIFEST_VERSION = 1;
@@ -12,6 +18,13 @@ export const themeFeatureSchema = z.enum([
"echarts" "echarts"
]); ]);
export const themeCategorySchema = z.enum([
"general",
"red-letter",
"formal",
"tender"
]);
export const themeManifestSchema = z.object({ export const themeManifestSchema = z.object({
manifestVersion: z.literal(THEME_MANIFEST_VERSION), manifestVersion: z.literal(THEME_MANIFEST_VERSION),
id: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), id: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
@@ -26,8 +39,20 @@ export const themeManifestSchema = z.object({
domPreset: z.enum(["typora", "github", "generic"]), domPreset: z.enum(["typora", "github", "generic"]),
defaultFontSize: z.string(), defaultFontSize: z.string(),
supportedFeatures: z.array(themeFeatureSchema), supportedFeatures: z.array(themeFeatureSchema),
category: themeCategorySchema.default("general"),
compatibleProfiles: z
.array(documentProfileNameSchema)
.default([]),
pageDefaults: z
.object({
margins: pageMarginsSchema,
header: headerSchema.optional(),
footer: footerSchema.optional()
})
.optional(),
bundled: z.boolean() bundled: z.boolean()
}); });
export type ThemeManifest = z.infer<typeof themeManifestSchema>; export type ThemeManifest = z.infer<typeof themeManifestSchema>;
export type ThemeFeature = z.infer<typeof themeFeatureSchema>; export type ThemeFeature = z.infer<typeof themeFeatureSchema>;
export type ThemeCategory = z.infer<typeof themeCategorySchema>;
+100
View File
@@ -7,6 +7,7 @@ import {
millimetersToCssPixels, millimetersToCssPixels,
millimetersToPdfPoints, millimetersToPdfPoints,
paperDimensionsMm, paperDimensionsMm,
resolvePageMargins,
supportedMermaidLayouts, supportedMermaidLayouts,
supportedMermaidLooks, supportedMermaidLooks,
supportedMermaidThemes, supportedMermaidThemes,
@@ -33,9 +34,11 @@ describe("导出配置", () => {
it("使用 A4 和 16mm 作为默认纸张配置", () => { it("使用 A4 和 16mm 作为默认纸张配置", () => {
expect(defaultExportConfig.themeId).toBe("typora-github"); expect(defaultExportConfig.themeId).toBe("typora-github");
expect(defaultExportConfig.pageDecorationsMode).toBe("theme");
expect(defaultExportConfig.paper).toEqual({ expect(defaultExportConfig.paper).toEqual({
format: "A4", format: "A4",
orientation: "portrait", orientation: "portrait",
marginMode: "theme",
margins: { margins: {
top: "16mm", top: "16mm",
right: "16mm", right: "16mm",
@@ -46,6 +49,103 @@ describe("导出配置", () => {
expect(exportConfigSchema.safeParse(defaultExportConfig).success).toBe(true); expect(exportConfigSchema.safeParse(defaultExportConfig).success).toBe(true);
}); });
it("提供可由主题推荐的页眉和页码配置", () => {
expect(defaultExportConfig.header).toMatchObject({
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif'
});
expect(defaultExportConfig.footer).toMatchObject({
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
alignment: "center",
format: "page-total",
showOnFirstPage: true
});
const parsedLegacyV4 = exportConfigSchema.parse({
...defaultExportConfig,
pageDecorationsMode: undefined,
header: {
...defaultExportConfig.header,
fontFamily: undefined
},
footer: {
...defaultExportConfig.footer,
fontFamily: undefined,
showOnFirstPage: undefined
}
});
expect(parsedLegacyV4.pageDecorationsMode).toBe("theme");
expect(parsedLegacyV4.header.fontFamily).toContain("Microsoft YaHei");
expect(parsedLegacyV4.footer.fontFamily).toContain("Microsoft YaHei");
expect(parsedLegacyV4.footer.showOnFirstPage).toBe(true);
});
it("支持公文页码格式和奇偶页外侧对齐", () => {
expect(
exportConfigSchema.safeParse({
...defaultExportConfig,
footer: {
...defaultExportConfig.footer,
fontFamily: '"Mdpdf Fandol Song", SimSun, serif',
fontSize: "4.94mm",
color: "#000000",
alignment: "outer",
format: "official-page",
showOnFirstPage: false
}
}).success
).toBe(true);
});
it("拒绝可能逃逸 CSS 字体声明的页眉页码字体", () => {
expect(
exportConfigSchema.safeParse({
...defaultExportConfig,
footer: {
...defaultExportConfig.footer,
fontFamily: 'serif; color: red'
}
}).success
).toBe(false);
});
it("按主题、自定义和全局默认顺序解析页边距", () => {
const themeMargins = {
top: "37mm",
right: "26mm",
bottom: "35mm",
left: "28mm"
};
expect(resolvePageMargins(defaultExportConfig.paper, themeMargins)).toEqual(
themeMargins
);
expect(resolvePageMargins(defaultExportConfig.paper)).toEqual({
top: "16mm",
right: "16mm",
bottom: "16mm",
left: "16mm"
});
expect(
resolvePageMargins(
{
marginMode: "custom",
margins: {
top: "10mm",
right: "11mm",
bottom: "12mm",
left: "13mm"
}
},
themeMargins
)
).toEqual({
top: "10mm",
right: "11mm",
bottom: "12mm",
left: "13mm"
});
});
it("提供 Mermaid 官方布局、主题、外观和字体默认值", () => { it("提供 Mermaid 官方布局、主题、外观和字体默认值", () => {
expect(supportedMermaidLayouts).toEqual(["dagre", "elk"]); expect(supportedMermaidLayouts).toEqual(["dagre", "elk"]);
expect(supportedMermaidLooks).toEqual([ expect(supportedMermaidLooks).toEqual([
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { themeManifestSchema } from "../src/theme.js";
describe("主题清单", () => {
it("允许主题推荐页边距、页眉和页码配置", () => {
const parsed = themeManifestSchema.parse({
manifestVersion: 1,
id: "official-test",
name: "公文测试主题",
version: "0.5.1",
description: "测试主题推荐页面配置。",
author: "md-to-pdf contributors",
license: "项目自有",
entry: "theme.css",
domPreset: "generic",
defaultFontSize: "21.333px",
supportedFeatures: ["table"],
category: "red-letter",
compatibleProfiles: ["official"],
pageDefaults: {
margins: {
top: "37mm",
right: "26mm",
bottom: "35mm",
left: "28mm"
},
header: {
enabled: false,
height: "8mm",
showDivider: false,
fontSize: "4.94mm",
fontFamily: '"Mdpdf Fandol Song", SimSun, serif',
color: "#000000",
left: { enabled: false, content: "" },
center: { enabled: false, content: "" },
right: { enabled: false, content: "" }
},
footer: {
enabled: true,
height: "7mm",
showDivider: false,
fontSize: "4.94mm",
fontFamily: '"Mdpdf Fandol Song", SimSun, serif',
color: "#000000",
alignment: "outer",
format: "official-page",
startFrom: 1,
showOnFirstPage: false
}
},
bundled: true
});
expect(parsed.pageDefaults?.footer).toMatchObject({
alignment: "outer",
format: "official-page",
showOnFirstPage: false
});
});
});
@@ -0,0 +1,134 @@
const PREPARED_ATTRIBUTE = "data-code-pagination-prepared";
const CHUNK_CLASS = "md-code-pagination-chunk";
const CHUNK_POSITION_ATTRIBUTE = "data-code-pagination-position";
const LINE_GROUP_CLASS = "md-code-line-group";
const LINE_CLASS = "md-code-line";
function splitNodeIntoLines(node: Node): Node[][] {
const documentRef = node.ownerDocument;
if (!documentRef) {
throw new Error("代码块分页节点缺少 ownerDocument");
}
if (node.nodeType === node.TEXT_NODE) {
return (node.textContent ?? "").split("\n").map((text) => [
documentRef.createTextNode(text)
]);
}
if (node.nodeType !== node.ELEMENT_NODE) {
return [[node.cloneNode(true)]];
}
const lineChildren = splitNodesIntoLines(
Array.from(node.childNodes)
);
return lineChildren.map((children) => {
const clone = node.cloneNode(false) as Element;
clone.append(...children);
return [clone];
});
}
function splitNodesIntoLines(nodes: Node[]): Node[][] {
const lines: Node[][] = [[]];
for (const node of nodes) {
const nodeLines = splitNodeIntoLines(node);
lines.at(-1)?.push(...(nodeLines[0] ?? []));
for (const line of nodeLines.slice(1)) {
lines.push([...line]);
}
}
return lines;
}
function lineHasContent(nodes: Node[]) {
return nodes.some((node) => (node.textContent ?? "").length > 0);
}
function createLineGroups(
documentRef: Document,
lines: Node[][]
) {
const groups: HTMLElement[] = [];
let lineIndex = 0;
while (lineIndex < lines.length) {
const remaining = lines.length - lineIndex;
const groupSize =
remaining === 1 ? 1 : remaining === 3 ? 3 : 2;
const group = documentRef.createElement("span");
group.className = LINE_GROUP_CLASS;
for (
let offset = 0;
offset < groupSize && lineIndex < lines.length;
offset += 1, lineIndex += 1
) {
const line = documentRef.createElement("span");
line.className = LINE_CLASS;
line.append(...(lines[lineIndex] ?? []));
group.append(line);
}
groups.push(group);
}
return groups;
}
export function prepareCodeBlockPagination(root: ParentNode) {
const codeBlocks = Array.from(
root.querySelectorAll<HTMLElement>(
`pre.md-fences:not([${PREPARED_ATTRIBUTE}])`
)
);
for (const codeBlock of codeBlocks) {
const code = codeBlock.querySelector<HTMLElement>(
":scope > code"
);
if (!code) {
continue;
}
const sourceEndsWithNewline =
code.textContent?.endsWith("\n") ?? false;
const lines = splitNodesIntoLines(Array.from(code.childNodes));
if (
sourceEndsWithNewline &&
lines.length > 1 &&
!lineHasContent(lines.at(-1) ?? [])
) {
lines.pop();
}
const groups = createLineGroups(code.ownerDocument, lines);
const chunks = groups.map((group, index) => {
const chunk = code.ownerDocument.createElement("div");
for (const attribute of Array.from(codeBlock.attributes)) {
chunk.setAttribute(attribute.name, attribute.value);
}
chunk.classList.add(CHUNK_CLASS);
chunk.setAttribute(PREPARED_ATTRIBUTE, "true");
chunk.setAttribute(
CHUNK_POSITION_ATTRIBUTE,
groups.length === 1
? "only"
: index === 0
? "first"
: index === groups.length - 1
? "last"
: "middle"
);
const chunkCode = code.cloneNode(false) as HTMLElement;
chunkCode.append(group);
chunk.append(chunkCode);
return chunk;
});
codeBlock.replaceWith(...chunks);
}
}
@@ -11,6 +11,7 @@ import {
findCommonPrefixLength, findCommonPrefixLength,
mountContinuousRenderStage mountContinuousRenderStage
} from "./continuous-preview.js"; } from "./continuous-preview.js";
import { prepareCodeBlockPagination } from "./code-block-pagination.js";
import { fitOversizedEChartsToPage } from "./echarts-page-fit.js"; import { fitOversizedEChartsToPage } from "./echarts-page-fit.js";
import { import {
fitDocumentImagesToPage, fitDocumentImagesToPage,
@@ -44,6 +45,8 @@ import {
documentGeometryCss, documentGeometryCss,
documentInteractionCss, documentInteractionCss,
formatPageNumber, formatPageNumber,
resolvePageNumberAlignment,
shouldRenderPageNumber,
type PagedPreviewPayload type PagedPreviewPayload
} from "./paged-preview.js"; } from "./paged-preview.js";
import type { PagedRenderTarget } from "./paged-render-target.js"; import type { PagedRenderTarget } from "./paged-render-target.js";
@@ -156,12 +159,23 @@ function applyPageNumbers(
return; return;
} }
const alignment = payload.exportConfig.footer.alignment;
const pages = Array.from( const pages = Array.from(
container.querySelectorAll<HTMLElement>(".pagedjs_page") container.querySelectorAll<HTMLElement>(".pagedjs_page")
); );
for (const [pageIndex, page] of pages.entries()) { for (const [pageIndex, page] of pages.entries()) {
if (
!shouldRenderPageNumber(
payload.exportConfig.footer,
pageIndex
)
) {
continue;
}
const alignment = resolvePageNumberAlignment(
payload.exportConfig.footer,
pageIndex
);
const content = page.querySelector<HTMLElement>( const content = page.querySelector<HTMLElement>(
`.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content` `.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content`
); );
@@ -640,6 +654,7 @@ export class PagedDocumentRuntime {
this.resetPagedState(); this.resetPagedState();
this.root.replaceChildren(); this.root.replaceChildren();
} }
prepareCodeBlockPagination(nextArticle);
const setupMs = performance.now() - setupStartedAt; const setupMs = performance.now() - setupStartedAt;
const mermaidStartedAt = performance.now(); const mermaidStartedAt = performance.now();
+81 -2
View File
@@ -75,10 +75,22 @@ svg {
max-width: 100%; max-width: 100%;
} }
#write pre.md-fences > code { #write pre.md-fences,
#write .md-code-pagination-chunk {
min-width: 0;
max-width: 100%;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
#write pre.md-fences > code,
#write .md-code-pagination-chunk > code {
display: block; display: block;
min-width: 0; min-width: 0;
max-width: 100%;
padding: 0; padding: 0;
padding-left: 8px;
border: 0; border: 0;
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;
@@ -87,6 +99,9 @@ svg {
font-size: inherit; font-size: inherit;
font-weight: inherit; font-weight: inherit;
line-height: inherit; line-height: inherit;
white-space: inherit;
overflow-wrap: inherit;
word-break: inherit;
} }
.md-document-image-block { .md-document-image-block {
@@ -250,6 +265,9 @@ export function formatPageNumber(
if (config.format === "dash-page") { if (config.format === "dash-page") {
return `- ${page} -`; return `- ${page} -`;
} }
if (config.format === "official-page") {
return `${page}`;
}
return (config.template || "${page} / ${pages}") return (config.template || "${page} / ${pages}")
.replace(/\$\{page\}/g, String(page)) .replace(/\$\{page\}/g, String(page))
@@ -262,6 +280,7 @@ function marginBox(
content: string, content: string,
options: { options: {
color: string; color: string;
fontFamily: string;
fontSize: string; fontSize: string;
height: string; height: string;
showDivider: boolean; showDivider: boolean;
@@ -280,7 +299,7 @@ function marginBox(
content: ${content}; content: ${content};
height: ${options.height}; height: ${options.height};
color: ${options.color}; color: ${options.color};
font-family: inherit; font-family: ${options.fontFamily};
font-size: ${options.fontSize}; font-size: ${options.fontSize};
font-weight: 400; font-weight: 400;
line-height: 1.25; line-height: 1.25;
@@ -300,6 +319,7 @@ function buildHeaderCss(
const options = { const options = {
color: config.header.color, color: config.header.color,
fontFamily: config.header.fontFamily,
fontSize: config.header.fontSize, fontSize: config.header.fontSize,
height: config.header.height, height: config.header.height,
showDivider: config.header.showDivider showDivider: config.header.showDivider
@@ -328,6 +348,7 @@ function buildFooterCss(config: ExportConfig) {
const options = { const options = {
color: config.footer.color, color: config.footer.color,
fontFamily: config.footer.fontFamily,
fontSize: config.footer.fontSize, fontSize: config.footer.fontSize,
height: config.footer.height, height: config.footer.height,
showDivider: config.footer.showDivider showDivider: config.footer.showDivider
@@ -347,6 +368,23 @@ function buildFooterCss(config: ExportConfig) {
.join("\n"); .join("\n");
} }
export function resolvePageNumberAlignment(
config: ExportConfig["footer"],
pageIndex: number
): "left" | "center" | "right" {
if (config.alignment !== "outer") {
return config.alignment;
}
return pageIndex % 2 === 0 ? "right" : "left";
}
export function shouldRenderPageNumber(
config: ExportConfig["footer"],
pageIndex: number
) {
return config.showOnFirstPage || pageIndex !== 0;
}
export function buildPagedMediaCss( export function buildPagedMediaCss(
config: ExportConfig, config: ExportConfig,
payload: Pick<PagedPreviewPayload, "fileName" | "metadata"> payload: Pick<PagedPreviewPayload, "fileName" | "metadata">
@@ -398,6 +436,47 @@ ${buildFooterCss(config)}
break-inside: avoid; break-inside: avoid;
} }
#write .md-code-pagination-chunk {
break-inside: avoid;
page-break-inside: avoid;
}
#write .md-code-pagination-chunk > code {
display: block;
}
#write .md-code-pagination-chunk:not(
[data-code-pagination-position="first"]
):not([data-code-pagination-position="only"]) {
margin-top: 0 !important;
padding-top: 0 !important;
border-top: 0 !important;
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
}
#write .md-code-pagination-chunk:not(
[data-code-pagination-position="last"]
):not([data-code-pagination-position="only"]) {
margin-bottom: 0 !important;
padding-bottom: 0 !important;
border-bottom: 0 !important;
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
#write .md-code-line-group {
display: block;
}
#write .md-code-line {
display: block;
min-height: 1lh;
white-space: inherit;
overflow-wrap: inherit;
word-break: inherit;
}
#write table[data-empty-split-table="true"] { #write table[data-empty-split-table="true"] {
display: none; display: none;
} }
@@ -0,0 +1,141 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { prepareCodeBlockPagination } from "../src/code-block-pagination.js";
function createRoot(codeHtml: string) {
const root = document.createElement("article");
root.innerHTML = `<pre class="md-fences"><code>${codeHtml}</code></pre>`;
return root;
}
function getGroups(root: ParentNode) {
return Array.from(
root.querySelectorAll<HTMLElement>(".md-code-line-group")
);
}
function getLines(root: ParentNode) {
return Array.from(
root.querySelectorAll<HTMLElement>(".md-code-line")
);
}
function getChunks(root: ParentNode) {
return Array.from(
root.querySelectorAll<HTMLElement>(".md-code-pagination-chunk")
);
}
describe("代码块分页预处理", () => {
it("偶数行按两行分组", () => {
const root = createRoot("line-1\nline-2\nline-3\nline-4\n");
prepareCodeBlockPagination(root);
expect(getGroups(root).map((group) => group.children.length)).toEqual([
2,
2
]);
expect(getLines(root).map((line) => line.textContent)).toEqual([
"line-1",
"line-2",
"line-3",
"line-4"
]);
expect(
getChunks(root).map((chunk) =>
chunk.getAttribute("data-code-pagination-position")
)
).toEqual(["first", "last"]);
});
it("奇数行将最后三行合组并保留单行代码块", () => {
const oddRoot = createRoot(
"line-1\nline-2\nline-3\nline-4\nline-5\n"
);
const singleRoot = createRoot("only-line\n");
prepareCodeBlockPagination(oddRoot);
prepareCodeBlockPagination(singleRoot);
expect(
getGroups(oddRoot).map((group) => group.children.length)
).toEqual([2, 3]);
expect(
getGroups(singleRoot).map((group) => group.children.length)
).toEqual([1]);
expect(
getChunks(singleRoot)[0]?.getAttribute(
"data-code-pagination-position"
)
).toBe("only");
});
it("跨行克隆语法高亮结构并保留空行", () => {
const root = createRoot(
'<span class="hljs-string">first\nsecond</span>\n\n' +
'<span class="hljs-keyword">return</span> value;\n'
);
prepareCodeBlockPagination(root);
const lines = getLines(root);
expect(lines.map((line) => line.textContent)).toEqual([
"first",
"second",
"",
"return value;"
]);
expect(
lines[0]?.querySelector(".hljs-string")?.textContent
).toBe("first");
expect(
lines[1]?.querySelector(".hljs-string")?.textContent
).toBe("second");
expect(
lines[3]?.querySelector(".hljs-keyword")?.textContent
).toBe("return");
});
it("重复调用不会再次包装已经准备的代码块", () => {
const root = createRoot("line-1\nline-2\n");
prepareCodeBlockPagination(root);
const firstHtml = root.innerHTML;
prepareCodeBlockPagination(root);
expect(root.innerHTML).toBe(firstHtml);
expect(root.querySelector("pre.md-fences")).toBeNull();
const paginationChunks = getChunks(root);
expect(paginationChunks).toHaveLength(1);
expect(
paginationChunks[0]?.classList.contains("md-fences")
).toBe(true);
expect(
paginationChunks[0]?.getAttribute(
"data-code-pagination-prepared"
)
).toBe("true");
});
it("复制代码元素属性并把每个双行组拆成同级分页片段", () => {
const root = document.createElement("article");
root.innerHTML =
'<pre class="md-fences custom" data-source="demo">' +
'<code class="hljs language-js" data-language="js">' +
"line-1\nline-2\nline-3\nline-4\n" +
"</code></pre>";
prepareCodeBlockPagination(root);
const chunks = getChunks(root);
expect(chunks).toHaveLength(2);
expect(Array.from(root.children)).toEqual(chunks);
expect(chunks[0]?.classList.contains("custom")).toBe(true);
expect(chunks[1]?.getAttribute("data-source")).toBe("demo");
expect(
chunks[1]?.querySelector("code")?.getAttribute("data-language")
).toBe("js");
});
});
@@ -11,6 +11,8 @@ import {
isPagedPreviewFrameMessage, isPagedPreviewFrameMessage,
isPagedPreviewRenderRequest, isPagedPreviewRenderRequest,
PAGED_PREVIEW_MESSAGE_SCOPE, PAGED_PREVIEW_MESSAGE_SCOPE,
resolvePageNumberAlignment,
shouldRenderPageNumber,
type PagedPreviewPayload type PagedPreviewPayload
} from "../src/paged-preview.js"; } from "../src/paged-preview.js";
@@ -36,6 +38,9 @@ describe("分页预览协议", () => {
expect(css).toContain("size: 210mm 297mm"); expect(css).toContain("size: 210mm 297mm");
expect(css).toContain("margin: 16mm 16mm"); expect(css).toContain("margin: 16mm 16mm");
expect(css).toContain("@bottom-center"); expect(css).toContain("@bottom-center");
expect(css).toContain(
'font-family: "Segoe UI", "Microsoft YaHei", sans-serif'
);
expect(css).not.toContain("counter-reset: page"); expect(css).not.toContain("counter-reset: page");
expect(css).toContain("#write thead"); expect(css).toContain("#write thead");
expect(css).toContain("display: table-header-group"); expect(css).toContain("display: table-header-group");
@@ -149,6 +154,36 @@ describe("分页预览协议", () => {
5 5
) )
).toBe("- 5 -"); ).toBe("- 5 -");
expect(
formatPageNumber(
{
...defaultExportConfig.footer,
format: "official-page"
},
4,
5
)
).toBe("— 5 —");
});
it("将公文页码放在奇偶页外侧并支持首页隐藏", () => {
const officialFooter = {
...defaultExportConfig.footer,
alignment: "outer" as const,
showOnFirstPage: false
};
expect(resolvePageNumberAlignment(officialFooter, 0)).toBe(
"right"
);
expect(resolvePageNumberAlignment(officialFooter, 1)).toBe(
"left"
);
expect(resolvePageNumberAlignment(officialFooter, 2)).toBe(
"right"
);
expect(shouldRenderPageNumber(officialFooter, 0)).toBe(false);
expect(shouldRenderPageNumber(officialFooter, 1)).toBe(true);
}); });
it("在主题 CSS 之后强制分页画布保持透明", () => { it("在主题 CSS 之后强制分页画布保持透明", () => {
@@ -168,6 +203,39 @@ describe("分页预览协议", () => {
); );
}); });
it("只在至少两行的代码组之间分页", () => {
const css = buildPagedMediaCss(defaultExportConfig, payload);
expect(css).toContain("#write .md-code-pagination-chunk");
expect(css).toContain("#write .md-code-line-group");
expect(css).toContain("page-break-inside: avoid;");
expect(css).toContain(
'[data-code-pagination-position="first"]'
);
expect(css).toContain(
'[data-code-pagination-position="last"]'
);
expect(css).toContain("#write .md-code-line");
expect(css).toContain("min-height: 1lh;");
});
it("代码围栏按正文宽度保留缩进并自动折行", () => {
expect(documentBaseCss).toContain("#write pre.md-fences,");
expect(documentBaseCss).toContain(
"#write .md-code-pagination-chunk"
);
expect(documentBaseCss).toContain("max-width: 100%;");
expect(documentBaseCss).toContain("padding-left: 8px;");
expect(documentBaseCss).toContain("white-space: pre-wrap;");
expect(documentBaseCss).toContain("overflow-wrap: anywhere;");
expect(documentBaseCss).toContain("word-break: break-word;");
const css = buildPagedMediaCss(defaultExportConfig, payload);
expect(css).toContain("#write .md-code-line");
expect(css).toContain("overflow-wrap: inherit;");
expect(css).toContain("word-break: inherit;");
});
it("以低优先级兜底样式保留链接识别和焦点反馈", () => { it("以低优先级兜底样式保留链接识别和焦点反馈", () => {
expect(documentInteractionCss).toContain(":where(#write a[href])"); expect(documentInteractionCss).toContain(":where(#write a[href])");
expect(documentInteractionCss).toContain( expect(documentInteractionCss).toContain(
@@ -0,0 +1,290 @@
import type {
DocumentProfile,
MarkdownDocumentMetadata
} from "@md-to-pdf/core";
export interface RenderedDocumentStructure {
profile: DocumentProfile["profile"];
prefixHtml: string;
suffixHtml: string;
}
export function renderDocumentStructure(
metadata: MarkdownDocumentMetadata
): RenderedDocumentStructure | undefined {
const document = metadata.document;
if (!document) {
return undefined;
}
switch (document.profile) {
case "official":
return renderOfficialStructure(metadata.title, document);
case "briefing":
return renderBriefingStructure(metadata.title, document);
case "project-report":
return renderProjectReportStructure(metadata.title, document);
case "tender":
return renderTenderStructure(metadata.title, document);
}
}
function renderOfficialStructure(
title: string,
document: Extract<DocumentProfile, { profile: "official" }>
): RenderedDocumentStructure {
const classification = joinParts(
[
renderText("span", "doc-secrecy", document.secrecy),
renderText("span", "doc-urgency", document.urgency)
],
"doc-classification"
);
const issueRow = joinParts(
[
renderText("span", "doc-number", document.number),
renderLabeledText(
"span",
"doc-signatory",
"签发人:",
document.signatory
)
],
"doc-issue-row"
);
const masthead = joinParts(
[
classification,
renderText("p", "doc-issuer", document.issuer),
issueRow
],
"doc-masthead doc-masthead-official",
"header"
);
const signature = joinParts(
[
renderText("p", "doc-signature-issuer", document.issuer),
renderTime("doc-signature-date", document.date)
],
"doc-signature",
"section"
);
const edition = joinParts(
[
document.copyTo?.length
? `<p class="doc-copy-to"><span>抄送:</span>${escapeHtml(document.copyTo.join("、"))}</p>`
: "",
joinParts(
[
renderText(
"span",
"doc-printing-office",
document.printingOffice
),
document.date
? `<time class="doc-printing-date">${escapeHtml(document.date)}印发</time>`
: ""
],
"doc-printing-row"
)
],
"doc-edition",
"footer"
);
return {
profile: document.profile,
prefixHtml:
masthead + renderText("h1", "doc-title", title),
suffixHtml: signature + edition
};
}
function renderBriefingStructure(
title: string,
document: Extract<DocumentProfile, { profile: "briefing" }>
): RenderedDocumentStructure {
const details = joinParts(
[
renderText("span", "doc-briefing-issue", document.issue),
renderText(
"span",
"doc-briefing-publisher",
document.publisher
),
renderLabeledText(
"span",
"doc-briefing-signatory",
"签发:",
document.signatory
),
renderTime("doc-briefing-date", document.date)
],
"doc-briefing-meta"
);
const masthead = joinParts(
[
renderText(
"p",
"doc-briefing-masthead",
document.masthead
),
details
],
"doc-masthead doc-masthead-briefing",
"header"
);
const contact = renderText(
"footer",
"doc-briefing-contact",
document.contact
);
return {
profile: document.profile,
prefixHtml:
masthead + renderText("h1", "doc-title", title),
suffixHtml: contact
};
}
function renderProjectReportStructure(
title: string,
document: Extract<
DocumentProfile,
{ profile: "project-report" }
>
): RenderedDocumentStructure {
const cover = joinParts(
[
renderText(
"p",
"doc-cover-project-name",
document.projectName
),
renderText(
"h1",
"doc-cover-title",
document.documentType || title
),
renderText("p", "doc-cover-version", document.version),
renderLabeledText(
"p",
"doc-cover-owner",
"建设单位:",
document.owner
),
renderLabeledText(
"p",
"doc-cover-prepared-by",
"编制单位:",
document.preparedBy
),
renderTime("doc-cover-date", document.date)
],
"doc-cover doc-cover-project-report",
"header"
);
return {
profile: document.profile,
prefixHtml: cover,
suffixHtml: ""
};
}
function renderTenderStructure(
title: string,
document: Extract<DocumentProfile, { profile: "tender" }>
): RenderedDocumentStructure {
const cover = joinParts(
[
renderText("p", "doc-cover-copy-mark", document.copyMark),
renderText(
"p",
"doc-cover-project-name",
document.projectName
),
renderText(
"p",
"doc-cover-project-number",
document.projectNumber
),
renderText("h1", "doc-cover-title", title),
renderText("p", "doc-cover-volume", document.volume),
renderLabeledText(
"p",
"doc-cover-bidder",
"投标人:",
document.bidder
),
renderLabeledText(
"p",
"doc-cover-representative",
"法定代表人或授权代表:",
document.representative
),
renderTime("doc-cover-date", document.date)
],
"doc-cover doc-cover-tender",
"header"
);
return {
profile: document.profile,
prefixHtml: cover,
suffixHtml: ""
};
}
function renderText(
tagName: string,
className: string,
value: string | undefined
) {
return value
? `<${tagName} class="${className}">${escapeHtml(value)}</${tagName}>`
: "";
}
function renderLabeledText(
tagName: string,
className: string,
label: string,
value: string | undefined
) {
return value
? `<${tagName} class="${className}"><span>${label}</span>${escapeHtml(value)}</${tagName}>`
: "";
}
function renderTime(className: string, value: string | undefined) {
return value
? `<time class="${className}">${escapeHtml(value)}</time>`
: "";
}
function joinParts(
parts: readonly string[],
className: string,
tagName = "div"
) {
const content = parts.filter(Boolean).join("");
return content
? `<${tagName} class="${className}">${content}</${tagName}>`
: "";
}
function escapeHtml(value: string) {
return value.replace(
/[&<>"']/gu,
(character) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
})[character] ?? character
);
}
+29 -2
View File
@@ -3,12 +3,14 @@ import { footnote } from "@mdit/plugin-footnote";
import { katex } from "@mdit/plugin-katex"; import { katex } from "@mdit/plugin-katex";
import { tasklist } from "@mdit/plugin-tasklist"; import { tasklist } from "@mdit/plugin-tasklist";
import type { import type {
DocumentProfile,
MarkdownDocumentMetadata, MarkdownDocumentMetadata,
RenderedMarkdownDocument, RenderedMarkdownDocument,
ThemeFeature ThemeFeature
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
import { import {
classifyDocumentLink, classifyDocumentLink,
documentProfileSchema,
isSafeDocumentLink isSafeDocumentLink
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
import { import {
@@ -22,6 +24,7 @@ import type { Options as MarkdownItOptions } from "markdown-it";
import type Renderer from "markdown-it/lib/renderer.mjs"; import type Renderer from "markdown-it/lib/renderer.mjs";
import type Token from "markdown-it/lib/token.mjs"; import type Token from "markdown-it/lib/token.mjs";
import sanitizeHtml from "sanitize-html"; import sanitizeHtml from "sanitize-html";
import { renderDocumentStructure } from "./render-document-structure.js";
export const RENDERER_VERSION = 1; export const RENDERER_VERSION = 1;
@@ -350,7 +353,11 @@ export function renderMarkdown(
options.language ?? "zh-CN", options.language ?? "zh-CN",
extractFirstHeading(parsed.content) extractFirstHeading(parsed.content)
); );
const articleHtml = `<article id="write" class="markdown-body" lang="${escapeAttribute(metadata.language)}">${bodyHtml}</article>`; const structure = renderDocumentStructure(metadata);
const profileAttribute = structure
? ` data-document-profile="${structure.profile}"`
: "";
const articleHtml = `<article id="write" class="markdown-body" lang="${escapeAttribute(metadata.language)}"${profileAttribute}>${structure?.prefixHtml ?? ""}${bodyHtml}${structure?.suffixHtml ?? ""}</article>`;
return { return {
rendererVersion: RENDERER_VERSION, rendererVersion: RENDERER_VERSION,
@@ -405,10 +412,30 @@ function normalizeMetadata(
author: readString(data.author), author: readString(data.author),
subject: readString(data.subject) || readString(data.description), subject: readString(data.subject) || readString(data.description),
keywords: readStringList(data.keywords ?? data.tags), keywords: readStringList(data.keywords ?? data.tags),
language: readString(data.language ?? data.lang) || fallbackLanguage language: readString(data.language ?? data.lang) || fallbackLanguage,
...(data.document === undefined
? {}
: { document: normalizeDocumentProfile(data.document) })
}; };
} }
function normalizeDocumentProfile(value: unknown): DocumentProfile {
const parsed = documentProfileSchema.safeParse(value);
if (parsed.success) {
return parsed.data;
}
const details = parsed.error.issues
.map((issue) => {
const path = ["document", ...issue.path].join(".");
return `${path}${issue.message}`;
})
.join("");
throw new MarkdownDocumentParseError(
`文档结构配置无效:${details}`
);
}
function readString(value: unknown): string { function readString(value: unknown): string {
if (typeof value === "string") { if (typeof value === "string") {
return value.trim(); return value.trim();
@@ -106,6 +106,122 @@ title: [未闭合
).toThrow(MarkdownDocumentParseError); ).toThrow(MarkdownDocumentParseError);
}); });
it("生成政企公文的语义化版头、落款和版记", () => {
const result = renderMarkdown(`---
title: 关于推进示范项目建设的通知
document:
profile: official
issuer: 示例市人民政府办公室
number: 示例办发〔202612号
secrecy: 内部
urgency: 加急
signatory: 张三
date: 2026年7月29日
copyTo: 市发展改革委, 市财政局
printingOffice: 示例市人民政府办公室
---
## 工作要求
正文内容。
`);
expect(result.metadata.document).toEqual({
profile: "official",
issuer: "示例市人民政府办公室",
number: "示例办发〔202612号",
secrecy: "内部",
urgency: "加急",
signatory: "张三",
date: "2026年7月29日",
copyTo: ["市发展改革委", "市财政局"],
printingOffice: "示例市人民政府办公室"
});
expect(result.articleHtml).toContain(
'data-document-profile="official"'
);
expect(result.articleHtml).toContain(
'<header class="doc-masthead doc-masthead-official">'
);
expect(result.articleHtml).toContain(
'<h1 class="doc-title">关于推进示范项目建设的通知</h1>'
);
expect(result.articleHtml).toContain(
'<section class="doc-signature">'
);
expect(result.articleHtml).toContain(
'<footer class="doc-edition">'
);
expect(result.bodyHtml).not.toContain("doc-masthead");
});
it("生成项目报告和标书封面的稳定语义节点", () => {
const projectReport = renderMarkdown(`---
title: 可行性研究报告
document:
profile: project-report
projectName: 智慧园区建设项目
documentType: 可行性研究报告
owner: 示例建设集团
preparedBy: 示例咨询有限公司
version: V1.0
date: 2026年7月
---
# 编制说明
`);
const tender = renderMarkdown(`---
title: 投标文件
document:
profile: tender
copyMark: 正本
projectName: 数据中心建设项目
projectNumber: ZB-2026-001
volume: 技术标
bidder: 示例科技有限公司
representative: 李四
date: 2026年7月29日
---
# 技术方案
`);
expect(projectReport.articleHtml).toContain(
'class="doc-cover doc-cover-project-report"'
);
expect(tender.articleHtml).toContain(
'class="doc-cover doc-cover-tender"'
);
expect(tender.articleHtml).toContain(
'<p class="doc-cover-copy-mark">正本</p>'
);
});
it("拒绝未知结构字段并转义语义节点内容", () => {
expect(() =>
renderMarkdown(`---
document:
profile: official
unknown: value
---
正文`)
).toThrow(MarkdownDocumentParseError);
const result = renderMarkdown(`---
title: "<img src=x onerror=alert(1)>"
document:
profile: briefing
masthead: "<script>alert(1)</script>"
---
正文`);
expect(result.articleHtml).not.toContain("<script>");
expect(result.articleHtml).not.toContain("<img");
expect(result.articleHtml).toContain(
"&lt;script&gt;alert(1)&lt;/script&gt;"
);
});
it("服务端渲染公式并保留 Mermaid 安全占位", () => { it("服务端渲染公式并保留 Mermaid 安全占位", () => {
const result = renderMarkdown(` const result = renderMarkdown(`
内联公式 $E=mc^2$ 内联公式 $E=mc^2$
+41
View File
@@ -0,0 +1,41 @@
# 内置教程与主题示例
`themes/` 中的 Markdown 可直接在应用中打开;应用内置的示例菜单会根据
文件名自动切换到同名主题。
`tutorials/` 包含应用“更多 → 使用教程”菜单中的两份教程:
| 教程 | 默认主题 |
| --- | --- |
| `tutorials/echarts-tutorial.md` | Typora Github |
| `tutorials/formal-document-tutorial.md` | 政企红头·标准文件 |
## 推荐主题
| 示例 | 推荐主题 |
| --- | --- |
| `themes/typora-like.md` | Typora Clean |
| `themes/typora-github.md` | Typora Github |
| `themes/typora-pixyll.md` | Typora Pixyll |
| `themes/typora-whitey.md` | Typora whitey |
| `themes/gov-red-standard.md` | 政企红头·标准文件 |
| `themes/gov-red-letter.md` | 政企红头·标准信函 |
| `themes/enterprise-red-simple.md` | 政企红头·企业简版 |
| `themes/red-briefing.md` | 政企红头·纪要简报 |
| `themes/formal-report.md` | 政企正式·工作报告 |
| `themes/formal-regulation.md` | 政企正式·规章制度 |
| `themes/formal-feasibility.md` | 政企正式·可研报告 |
| `themes/tender-classic.md` | 标书正式·经典黑白 |
| `themes/tender-business-blue.md` | 标书正式·商务蓝 |
| `themes/tender-blind.md` | 标书正式·技术暗标 |
## 导出配置提示
- “政企红头·标准文件”“政企红头·标准信函”“政企正式·工作报告”和“政企正式·规章制度”采用党政机关公文版心对应的推荐页边距:上 37mm、下 35mm、左 28mm、右 26mm。
- 企业简版采用上、下、右 25mm、左 28mm;纪要简报采用四边 25mm。
- 可研报告采用上、下 25mm、左 30mm、右 20mm,给左侧装订和批注意见预留空间。
- 经典黑白与商务蓝标书采用上、下、右 25mm、左 30mm。
- 技术暗标采用上、下、右 25.4mm、左 31.7mm,作为常见暗标版式的起始值。
- 可研报告和标书主题会把结构化封面单独分页。
- 技术暗标主题只负责隐藏结构化封面中的投标人和代表字段,不能检查正文、图片、文件名或 PDF 元数据中是否包含身份信息。
- 可研和标书不存在适用于所有项目的统一页边距;暗标的字体、字号、页边距、页码和装订要求尤其应以具体招标文件为准。正式导出前应关闭不允许出现的页眉、页脚和页码,并进行人工合规复核。
+34
View File
@@ -0,0 +1,34 @@
---
title: 关于发布项目交付管理办法的通知
author: 示例科技集团有限公司
subject: 项目交付管理办法
keywords: [企业发文, 管理制度, 项目交付]
document:
profile: official
issuer: 示例科技集团有限公司
number: 示例科发〔202608号
signatory: 李四
date: 2026年7月29日
copyTo: 各事业部, 各区域公司
printingOffice: 集团综合管理部
---
集团各部门、各所属公司:
《项目交付管理办法》已经集团经营管理会议审议通过,现予发布,自2026年8月1日起施行。
## 一、适用范围
本办法适用于集团及所属公司承担的咨询、软件开发、系统集成和运维服务项目。
## 二、关键控制点
| 阶段 | 必备成果 | 责任人 |
| --- | --- | --- |
| 启动 | 项目章程、实施计划 | 项目经理 |
| 建设 | 周报、风险清单、变更记录 | 项目团队 |
| 验收 | 验收报告、移交清单 | 交付负责人 |
## 三、执行要求
各单位应在项目管理平台维护完整记录。重大风险应在一个工作日内升级报告,未经审批不得擅自变更项目范围、预算和关键里程碑。
+44
View File
@@ -0,0 +1,44 @@
---
title: 可行性研究报告
author: 示例工程咨询有限公司
subject: 智慧园区建设项目可行性研究
keywords: [可行性研究, 项目建设, 投资估算]
document:
profile: project-report
projectName: 智慧园区一体化平台建设项目
documentType: 可行性研究报告
owner: 示例产业园区管理委员会
preparedBy: 示例工程咨询有限公司
version: 送审稿 V1.0
date: 2026年7月
---
# 第一章 总论
## 一、项目概况
本项目拟建设统一的园区管理、企业服务和运行监测平台,形成“一网统管、一企一档、一体服务”的数字化能力。
## 二、建设依据
项目建设依据园区总体规划、数字化转型行动方案以及相关信息安全和数据管理制度开展。
# 第二章 建设必要性
现有系统分散建设、数据标准不统一,跨部门业务协同成本较高。建设统一平台有利于提升园区管理效率和企业服务水平。
# 第三章 建设方案
| 建设内容 | 主要能力 | 计划周期 |
| --- | --- | --- |
| 数据底座 | 数据目录、治理、共享 | 6个月 |
| 管理平台 | 运行监测、事件协同 | 8个月 |
| 企业服务 | 政策匹配、诉求办理 | 6个月 |
# 第四章 投资估算
项目估算总投资为1800万元,包括软件建设、基础设施、安全服务、实施服务和第三方测评费用。
# 第五章 结论
项目建设目标明确,技术路线可行,投资规模合理,具备实施条件。
+46
View File
@@ -0,0 +1,46 @@
---
title: 信息系统项目管理办法
author: 示例集团有限公司
subject: 信息系统项目管理制度
keywords: [规章制度, 项目管理, 信息系统]
---
# 信息系统项目管理办法
## 第一章 总则
### 第一条
为规范集团信息系统项目全生命周期管理,控制项目风险,提高投资效益,根据国家有关规定和集团管理要求,制定本办法。
### 第二条
本办法适用于集团总部及所属单位新建、改建和升级的信息系统项目。
## 第二章 职责分工
### 第三条
数字化管理部门负责项目统筹、技术审查、过程监督和验收管理;需求单位负责业务需求确认、试运行和应用推广。
### 第四条
项目实行负责人制度。项目负责人对建设范围、质量、进度、投资和安全承担管理责任。
## 第三章 项目实施
### 第五条
项目实施应当依次完成立项、采购、建设、测试、试运行、验收和移交。
| 阶段 | 必备材料 | 审批责任 |
| --- | --- | --- |
| 立项 | 需求说明、投资估算 | 需求单位 |
| 建设 | 实施计划、风险清单 | 项目负责人 |
| 验收 | 测试报告、移交清单 | 数字化管理部门 |
## 第四章 附则
### 第六条
本办法由集团数字化管理部门负责解释,自发布之日起施行。
+38
View File
@@ -0,0 +1,38 @@
---
title: 2026年上半年重点工作报告
author: 示例集团战略发展部
subject: 半年度重点工作报告
keywords: [工作报告, 经营分析, 重点任务]
---
# 2026年上半年重点工作报告
2026年上半年,集团围绕年度经营目标,持续推进业务结构优化、重点项目建设和管理能力提升,总体经营运行保持稳中有进。
## 一、主要指标完成情况
| 指标 | 年度目标 | 上半年完成 | 完成率 |
| --- | ---: | ---: | ---: |
| 营业收入 | 20亿元 | 11.2亿元 | 56% |
| 新签合同额 | 25亿元 | 14.8亿元 | 59.2% |
| 重点项目节点 | 48项 | 27项 | 56.3% |
## 二、重点工作进展
### (一)业务结构持续优化
数字化服务和专业咨询业务收入占比较上年同期提高8个百分点,区域市场协同机制初步形成。
### (二)重大项目有序推进
列入年度计划的12个重大项目全部开工,其中5个项目提前完成阶段目标。
## 三、存在的主要问题
部分区域项目储备不足,跨部门数据共享仍需加强,专业人才结构与新业务发展要求存在差距。
## 四、下半年工作安排
1. 加快重点项目交付和回款。
2. 完善区域市场协同考核机制。
3. 推进经营数据治理和人才专项培养。
+39
View File
@@ -0,0 +1,39 @@
---
title: 关于征求数字化建设方案意见的函
author: 示例市数据管理局
subject: 征求数字化建设方案意见
keywords: [公文, 函, 数字化]
document:
profile: official
issuer: 示例市数据管理局
number: 示例数函〔202618号
date: 2026年7月29日
copyTo:
- 市发展改革委
- 市财政局
printingOffice: 示例市数据管理局办公室
---
市直各有关单位:
为统筹推进全市数字基础设施建设,我局组织编制了《示例市数字化建设三年行动方案(征求意见稿)》。现就方案内容征求你单位意见。
## 一、重点征求事项
请重点围绕建设目标、年度任务、项目清单、资金安排和数据安全责任等内容提出意见。涉及跨部门协同事项的,请明确牵头单位、配合单位和完成时限。
## 二、反馈方式
请于2026年8月12日前将加盖单位公章的书面意见及电子版反馈至我局。无意见也请书面反馈。
## 三、联系人
联系人:王某,联系电话:0000-12345678,电子邮箱:example@example.com。
感谢对我市数字化建设工作的支持。
为验证末页双线位置,本示例继续提供一段较长的补充说明。各单位在提出意见时,应同步评估现有系统、数据资源、网络环境和运维队伍情况,避免重复建设。对需要纳入年度财政预算的项目,应说明投资估算、资金来源和预期绩效;对涉及公共数据授权运营的事项,应明确数据分类分级、授权边界、安全审计和退出机制。
同时,请结合本单位职责梳理可共享的数据目录,说明更新频率、质量责任和使用限制。涉及个人信息、商业秘密或者重要数据的,应当提出相应的脱敏、访问控制和风险评估措施。意见应当具体、清晰、可执行,原则上不只提出方向性表述。
我局将对反馈意见逐条研究,形成采纳情况说明,并根据论证结果修改完善行动方案。后续需要进一步协调的事项,将另行组织专题会议。
+40
View File
@@ -0,0 +1,40 @@
---
title: 关于进一步规范项目管理工作的通知
author: 示例市人民政府办公室
subject: 项目管理工作通知
keywords: [公文, 通知, 项目管理]
document:
profile: official
issuer: 示例市人民政府办公室
number: 示例办发〔202612号
urgency: 加急
signatory: 张三
date: 2026年7月29日
copyTo:
- 市发展改革委
- 市财政局
- 市审计局
printingOffice: 示例市人民政府办公室
---
各区人民政府,市政府各部门、各直属机构:
为进一步加强全市重点项目全过程管理,提升项目谋划、建设和验收质量,经市政府同意,现将有关事项通知如下。
## 一、完善项目分级管理机制
各单位应当按照投资规模、建设周期和社会影响,将项目划分为重点项目、一般项目和储备项目,分别建立动态管理台账。
## 二、规范项目建设过程
(一)严格执行立项、规划、用地、环境评价和招标采购等程序。
(二)建立月度调度机制,及时协调解决项目建设中的堵点问题。
(三)重大事项应当形成书面记录并按照权限履行审批程序。
## 三、强化监督检查
市有关部门将定期开展联合检查。对推进有力、成效明显的单位予以通报;对工作滞后、整改不力的单位按规定进行督办。
请各单位结合实际认真贯彻执行。
+32
View File
@@ -0,0 +1,32 @@
---
title: 重点项目建设工作推进情况
author: 示例市重点项目办公室
subject: 重点项目工作简报
keywords: [简报, 项目建设, 工作推进]
document:
profile: briefing
masthead: 工作简报
issue: 2026年第12期
publisher: 示例市重点项目办公室
signatory: 王五
date: 2026年7月29日
contact: "报送:市政府领导;发送:各区人民政府、市直有关单位。编辑:综合协调处,电话:0000-12345678。"
---
本周,全市重点项目总体进展平稳,计划内节点完成率为96%,新开工项目8个,完成投资12.6亿元。
## 一、主要进展
一是产业园基础设施项目完成主体结构封顶。二是城市更新一期项目完成首批居民回迁。三是数字政府基础平台完成试运行评审。
## 二、需要关注的问题
部分项目存在前期手续衔接不畅、材料价格波动和专业人员不足等问题。相关责任单位已制定专项措施,预计两周内完成整改。
> 对跨部门协调事项实行清单管理、销号推进;逾期未完成的,由市重点项目办公室提请专题调度。
## 三、下周工作安排
1. 开展三季度项目集中督导。
2. 组织重大项目融资对接活动。
3. 完成年度项目储备库中期评估。
+43
View File
@@ -0,0 +1,43 @@
---
title: 技术文件
subject: 城市运行管理平台技术暗标
keywords: [技术暗标, 技术方案, 项目实施]
document:
profile: tender
copyMark: 技术暗标
projectName: 城市运行管理平台建设项目
projectNumber: JS-2026-009
volume: 技术文件
date: 2026年7月
---
# 第一章 项目理解
项目建设应形成城市运行监测、事件协同、指挥调度和综合评价能力,提升跨部门协同效率。
## 一、需求分析
现状系统存在数据标准不统一、事件闭环不完整和运行指标分散等问题,需要建立统一的技术与数据体系。
## 二、建设原则
1. 统一规划,分步实施。
2. 数据驱动,业务协同。
3. 安全可控,稳定可靠。
4. 标准开放,持续演进。
# 第二章 技术方案
## 一、总体架构
采用基础设施层、数据资源层、能力支撑层、业务应用层和展现层的五层架构。
| 层级 | 建设内容 |
| --- | --- |
| 数据资源层 | 目录、治理、共享、指标 |
| 能力支撑层 | 事件、规则、流程、消息 |
| 业务应用层 | 监测、协同、调度、评价 |
# 第三章 实施方案
项目按照准备、设计、建设、试运行和验收五个阶段推进,建立质量、进度、风险和安全管理机制。
+44
View File
@@ -0,0 +1,44 @@
---
title: 投标文件
author: 示例数字科技有限公司
subject: 智慧办公平台采购项目投标文件
keywords: [投标文件, 商务方案, 智慧办公]
document:
profile: tender
copyMark: 副本
projectName: 智慧办公一体化平台采购项目
projectNumber: CG-2026-018
volume: 商务与解决方案
bidder: 示例数字科技有限公司
representative: 王五
date: 2026年7月29日
---
# 项目理解
本项目旨在建设统一、安全、易用的智慧办公平台,实现组织、流程、消息、知识和移动办公的一体化。
## 业务目标
- 统一用户和组织身份。
- 贯通核心审批流程。
- 建立企业知识资产体系。
- 提供可量化的运营分析。
# 解决方案
## 功能规划
| 模块 | 主要内容 | 交付方式 |
| --- | --- | --- |
| 统一门户 | 待办、消息、应用导航 | 标准产品 |
| 流程中心 | 表单、流程、规则引擎 | 配置实施 |
| 知识中心 | 文档、搜索、权限 | 标准产品 |
## 项目计划
项目分为调研设计、平台建设、试点运行和全面推广四个阶段,总周期为120日历天。
# 服务体系
提供驻场支持、远程服务、定期巡检和管理员培训,形成持续运营机制。
+43
View File
@@ -0,0 +1,43 @@
---
title: 投标文件
author: 示例科技有限公司
subject: 数据中心建设项目投标文件
keywords: [投标文件, 技术方案, 项目实施]
document:
profile: tender
copyMark: 正本
projectName: 数据中心基础设施建设项目
projectNumber: ZB-2026-001
volume: 技术及商务文件
bidder: 示例科技有限公司
representative: 李四
date: 2026年7月29日
---
# 第一章 投标响应
我方已认真研究招标文件及相关补充文件,承诺按照招标要求完成项目建设和服务。
## 一、响应一览
| 项目 | 响应内容 |
| --- | --- |
| 工期 | 合同签订后180日历天 |
| 质量目标 | 一次验收合格 |
| 质保期 | 验收后36个月 |
# 第二章 技术方案
## 一、总体架构
项目采用分层解耦、统一管理的总体架构,覆盖计算、存储、网络、安全和运维管理能力。
## 二、实施计划
1. 项目启动与深化设计。
2. 设备到货和安装调试。
3. 系统联调、试运行与验收。
# 第三章 服务承诺
建立7×24小时服务机制,重大故障15分钟响应、2小时到场。
+59
View File
@@ -0,0 +1,59 @@
---
title: 内网文档渲染服务接入指南
author: 示例研发团队
subject: Markdown 渲染与 PDF API
keywords: [API, Markdown, Mermaid, 代码]
---
# 内网文档渲染服务接入指南
Typora Github 主题适合技术说明、接口文档和包含大量代码的工程资料。本示例
覆盖代码围栏、表格、引用和 Mermaid。
## 渲染流程
```mermaid
flowchart LR
A["Markdown"] --> B["安全渲染"]
B --> C["统一 HTML"]
C --> D["网页预览"]
C --> E["Chromium PDF"]
```
## 请求示例
```ts
const response = await fetch("/api/render", {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify({
markdown: "# 示例",
language: "zh-CN"
})
});
if (!response.ok) {
throw new Error("渲染失败");
}
```
## 接口约束
| 项目 | 限制 |
| --- | --- |
| 原始 HTML | 默认不执行 |
| 远程资源 | PDF 阶段禁止外部网络 |
| 相对图片 | Desktop 从文档目录安全读取 |
| 图表 | 等待渲染完成后再分页 |
> 精确预览与最终 PDF 使用同一份 Chromium 结果;快速预览用于编辑期间的
> 即时反馈。
## 检查清单
- [x] 返回可搜索文本
- [x] 主题资源使用同源地址
- [x] Mermaid 采用严格安全模式
- [ ] 根据部署规模调整 PDF 并发上限
+43
View File
@@ -0,0 +1,43 @@
---
title: 产品需求评审纪要
author: 示例产品团队
subject: 编辑与导出工作流评审
keywords: [产品评审, Markdown, PDF]
---
# 产品需求评审纪要
> 本示例用于观察 Typora Clean 主题的标题、引用、表格、任务列表与代码排版。
## 一、评审结论
本轮确认优先完成稳定的 Markdown 编辑、分页预览和 PDF 导出链路。预览与
最终文件应复用同一份渲染结果,避免浏览器和桌面端出现两套排版逻辑。
## 二、功能清单
| 能力 | 当前结论 | 验收方式 |
| --- | --- | --- |
| Markdown 编辑 | 支持 | 输入后实时刷新 |
| 主题切换 | 支持 | 切换后保持文档内容 |
| PDF 导出 | 支持 | 文本可搜索 |
| 本地图片 | 桌面端支持 | 与 Markdown 同目录 |
## 三、待办事项
- [x] 统一连续预览和分页预览
- [x] 支持主题推荐页边距
- [ ] 补充更多结构化文档示例
- [ ] 完成发布前跨平台检查
## 四、配置示例
```json
{
"paper": "A4",
"orientation": "portrait",
"marginMode": "theme"
}
```
评审人:产品、设计、研发与测试代表。
+38
View File
@@ -0,0 +1,38 @@
---
title: 城市公共空间观察札记
author: 示例研究小组
subject: 街区步行环境观察
keywords: [调研, 观察, 城市空间]
---
# 城市公共空间观察札记
## 清晨:街角与通勤
早高峰开始前,沿街商铺逐渐亮起灯光。步行者会自然选择遮阴连续、路口等待
时间较短的一侧街道。空间是否友好,往往体现在座椅、树荫和过街距离这些
不起眼的细节里。
> 好的公共空间并不要求人们停留,却始终为停留保留可能。
## 午后:共享空间的变化
我们在三个时段记录了广场中的主要活动:
| 时段 | 主要使用者 | 常见活动 |
| --- | --- | --- |
| 08:00—09:00 | 通勤者、学生 | 快速通过、等候 |
| 12:00—14:00 | 周边职员 | 午餐、短暂休息 |
| 17:00—19:00 | 居民、儿童 | 散步、游戏、交流 |
## 傍晚:灯光与安全感
照明亮度并不是唯一指标。连续的临街界面、清晰的步行方向和适当的人群密度,
共同决定了夜间安全感。后续设计应优先修复照明断点,并避免绿化遮挡视线。
## 小结
1. 为主要步行路径提供连续遮阴。
2. 缩短路口等待时间,增加安全岛。
3. 在不阻塞通行的位置增加可移动座椅。
4. 保留适合临时活动的小尺度开放区域。
+36
View File
@@ -0,0 +1,36 @@
---
title: 项目周会备忘
author: 示例项目组
subject: 本周进展与下周安排
keywords: [周会, 备忘, 项目管理]
---
# 项目周会备忘
## 本周完成
- 完成主题清单与分类展示。
- 打通网页预览和桌面 PDF 的统一渲染链路。
- 修复长代码块和媒体元素的分页边界问题。
## 关键数据
| 指标 | 本周结果 |
| --- | ---: |
| 自动化测试 | 全部通过 |
| 阻塞问题 | 0 |
| 待复核项目 | 3 |
## 下周安排
1. 完成正式文档主题的字体复核。
2. 增加主题示例和使用教程。
3. 执行安装包与免安装包验收。
## 风险提示
> 暗标、政府公文和企业模板可能存在项目级差异,最终格式应以对应制度或招标
> 文件为准。
负责人:示例项目经理<br>
记录日期:2026年7月29日
+470
View File
@@ -0,0 +1,470 @@
---
title: Markdown ECharts 图表教程
author: Markdown PDF 导出器
keywords: [ECharts, Markdown, 图表, 教程]
---
# Markdown ECharts 图表教程
本教程介绍如何在 Markdown 中通过 `echarts` 围栏插入可打印的 ECharts
图表。围栏内容使用 YAML,图表会以 SVG 渲染,并与正文一起进入快速
预览、精确预览和最终 PDF。
> `echarts` 围栏不是普通的 `yaml` 代码块。请保留开头和结尾的三个反引号,
> 并将开头写成 `echarts`。
## 一、最小可用示例
下面的内容可以直接复制到 Markdown 正文中:
````markdown
```echarts
version: 1
caption: 各部门季度完成量
height: 58mm
theme: document
option:
xAxis:
type: category
data: [研发, 产品, 设计, 运营]
yAxis:
type: value
series:
- name: 完成量
type: bar
data: [28, 22, 17, 25]
```
````
实际渲染效果如下:
```echarts
version: 1
caption: 各部门季度完成量
height: 58mm
theme: document
option:
xAxis:
type: category
data: [研发, 产品, 设计, 运营]
yAxis:
type: value
series:
- name: 完成量
type: bar
data: [28, 22, 17, 25]
```
## 二、围栏外层字段
围栏最外层只支持下表中的字段,其他字段必须放入 `option`。
| 字段 | 类型 | 必填 | 功能 | 默认值 | 示例 |
| --- | --- | --- | --- | --- | --- |
| `version` | 整数 | 否 | ECharts 围栏协议版本,目前只能是 `1` | `1` | `version: 1` |
| `height` | 长度字符串 | 否 | 固定图表高度,支持 `mm`、`cm`、`in`、`px` | `80mm` | `height: 65mm` |
| `aspectRatio` | 正数 | 否 | 按内容宽度计算宽高比,最大为 `10` | 无 | `aspectRatio: 1.6` |
| `theme` | 枚举 | 否 | 图表配色,可选 `document`、`light`、`dark` | `document` | `theme: light` |
| `caption` | 字符串 | 否 | 显示在图表下方的图注,最长 500 个字符 | 无 | `caption: 图1 月度趋势` |
| `option` | 对象 | 是 | ECharts 的纯数据配置对象 | 无 | `option: { ... }` |
尺寸规则:
- 未设置 `height` 和 `aspectRatio` 时,高度默认为 `80mm`
- 同时设置两者时,以显式 `height` 作为固定高度;
- 普通统计图建议使用 `50mm``85mm`
- 关系图或层级图可使用 `80mm``120mm`
- 图表超过单页内容区时,系统会等比例缩小到一页内。
## 三、`option` 常用字段
`option` 使用 ECharts 官方的纯数据结构。下表列出正式文档最常用的字段,
不是 ECharts 全部能力的穷举。
| 字段 | 类型 | 功能 | 示例 |
| --- | --- | --- | --- |
| `title` | 对象 | 图内标题、副标题及位置 | `title: { text: 月度趋势, left: center }` |
| `legend` | 对象 | 系列图例、方向和位置 | `legend: { top: 0 }` |
| `tooltip` | 对象 | 浏览器预览中的提示方式 | `tooltip: { trigger: axis }` |
| `grid` | 对象 | 直角坐标图的绘图区边距 | `grid: { left: 48, right: 24 }` |
| `xAxis` | 对象或数组 | 横轴类型、类目和标签 | `xAxis: { type: category }` |
| `yAxis` | 对象或数组 | 纵轴类型、范围和名称 | `yAxis: { type: value }` |
| `dataset` | 对象或数组 | 多系列共享的表格型数据源 | `dataset: { source: [...] }` |
| `series` | 数组 | 图表系列;每项必须有受支持的 `type` | `series: [{ type: line, data: [...] }]` |
| `radar` | 对象 | 雷达图指标名称和最大值 | `radar: { indicator: [...] }` |
| `visualMap` | 对象 | 热力图等数值到颜色的映射 | `visualMap: { min: 0, max: 100 }` |
建议用 `caption` 表示“图 1、数据来源、统计口径”等打印说明;`title`
适合需要在图形内部显示的标题。两者同时使用时会分别显示。
### 三种标题不要混用
| 位置 | 写法 | 用途 |
| --- | --- | --- |
| 文档章节标题 | Markdown 的 `## 标题` | 组织整篇文档的章节层级 |
| 图表内部标题 | `option.title.text` | 显示在 SVG 图形内部,可带副标题 |
| 图表下方图注 | 围栏外层的 `caption` | 图号、数据来源、统计口径和补充说明 |
`option.title` 常用字段:
| 字段 | 功能 | 示例 |
| --- | --- | --- |
| `text` | 图内主标题 | `text: 上半年项目交付趋势` |
| `subtext` | 主标题下方的副标题 | `subtext: 统计周期:2026年1—6月` |
| `left` | 水平位置,可用像素、百分比、`left`、`center`、`right` | `left: center` |
| `top` | 垂直位置,可用像素、百分比、`top`、`middle`、`bottom` | `top: 4` |
| `textStyle` | 主标题字体样式 | `textStyle: { fontSize: 18 }` |
| `subtextStyle` | 副标题字体样式 | `subtextStyle: { fontSize: 12 }` |
`option.legend` 会从 `series[].name` 或 `dataset` 列名生成图例。多系列
图表应为每个系列设置清晰的 `name`,并给标题、图例和绘图区留出互不
重叠的空间。
## 四、图表标题与顶部图例:折线图
````markdown
```echarts
version: 1
caption: 图1 项目交付统计;数据来源:项目管理系统
height: 76mm
option:
title:
text: 上半年项目交付趋势
subtext: 统计周期:2026年1—6月
left: center
top: 4
textStyle:
fontSize: 18
subtextStyle:
fontSize: 12
tooltip:
trigger: axis
legend:
data: [计划, 实际]
top: 48
grid:
left: 48
right: 24
bottom: 32
top: 82
containLabel: true
xAxis:
type: category
boundaryGap: false
data: [一月, 二月, 三月, 四月, 五月, 六月]
yAxis:
type: value
name: 项目数
series:
- name: 计划
type: line
smooth: true
data: [12, 15, 18, 20, 24, 28]
- name: 实际
type: line
smooth: true
data: [10, 16, 17, 22, 23, 31]
```
````
```echarts
version: 1
caption: 图1 项目交付统计;数据来源:项目管理系统
height: 76mm
option:
title:
text: 上半年项目交付趋势
subtext: 统计周期:2026年1—6月
left: center
top: 4
textStyle:
fontSize: 18
subtextStyle:
fontSize: 12
tooltip:
trigger: axis
legend:
data: [计划, 实际]
top: 48
grid:
left: 48
right: 24
bottom: 32
top: 82
containLabel: true
xAxis:
type: category
boundaryGap: false
data: [一月, 二月, 三月, 四月, 五月, 六月]
yAxis:
type: value
name: 项目数
series:
- name: 计划
type: line
smooth: true
data: [12, 15, 18, 20, 24, 28]
- name: 实际
type: line
smooth: true
data: [10, 16, 17, 22, 23, 31]
```
## 五、底部图例:使用 `dataset` 的柱状图
`dataset.source` 适合把数据集中写成一张表,多个系列再按列读取。
````markdown
```echarts
version: 1
caption: 图2 分区域合同额与回款额(单位:万元)
height: 70mm
option:
title:
text: 分区域合同执行情况
left: center
top: 4
legend:
bottom: 2
tooltip:
trigger: axis
grid:
left: 48
right: 24
top: 48
bottom: 52
containLabel: true
dataset:
source:
- [区域, 合同额, 回款额]
- [东区, 820, 690]
- [南区, 760, 610]
- [西区, 540, 480]
- [北区, 680, 570]
xAxis:
type: category
yAxis:
type: value
series:
- type: bar
- type: bar
```
````
```echarts
version: 1
caption: 图2 分区域合同额与回款额(单位:万元)
height: 70mm
option:
title:
text: 分区域合同执行情况
left: center
top: 4
legend:
bottom: 2
tooltip:
trigger: axis
grid:
left: 48
right: 24
top: 48
bottom: 52
containLabel: true
dataset:
source:
- [区域, 合同额, 回款额]
- [东区, 820, 690]
- [南区, 760, 610]
- [西区, 540, 480]
- [北区, 680, 570]
xAxis:
type: category
yAxis:
type: value
series:
- type: bar
- type: bar
```
## 六、饼图:名称与数值
饼图数据项使用 `{ name, value }` 结构。正式文档中建议控制分类数量,
避免标签过密。
````markdown
```echarts
version: 1
caption: 图3 年度预算构成
height: 68mm
option:
title:
text: 年度预算构成
subtext: 单位:%
left: "62%"
top: 6
textAlign: center
tooltip:
trigger: item
legend:
orient: vertical
left: left
series:
- name: 预算
type: pie
radius: ["35%", "68%"]
center: ["62%", "50%"]
data:
- { name: 软件采购, value: 36 }
- { name: 基础设施, value: 28 }
- { name: 运维服务, value: 22 }
- { name: 培训推广, value: 14 }
```
````
```echarts
version: 1
caption: 图3 年度预算构成
height: 68mm
option:
title:
text: 年度预算构成
subtext: 单位:%
left: "62%"
top: 6
textAlign: center
tooltip:
trigger: item
legend:
orient: vertical
left: left
series:
- name: 预算
type: pie
radius: ["35%", "68%"]
center: ["62%", "50%"]
data:
- { name: 软件采购, value: 36 }
- { name: 基础设施, value: 28 }
- { name: 运维服务, value: 22 }
- { name: 培训推广, value: 14 }
```
## 七、雷达图:指标与最大值
雷达图必须提供非空的 `radar.indicator`。每组 `series.data[].value`
至少要包含与指标数量相同的数值。
````markdown
```echarts
version: 1
caption: 图4 项目综合评价
height: 72mm
option:
legend:
data: [项目甲, 项目乙]
radar:
indicator:
- { name: 进度, max: 100 }
- { name: 质量, max: 100 }
- { name: 成本, max: 100 }
- { name: 风险, max: 100 }
- { name: 满意度, max: 100 }
series:
- type: radar
data:
- name: 项目甲
value: [86, 92, 78, 81, 90]
- name: 项目乙
value: [91, 84, 88, 76, 85]
```
````
## 八、关系图:节点与连线
关系图使用 `data` 或 `nodes` 描述节点,使用 `links` 或 `edges` 描述关系。
连线的 `source`、`target` 必须引用已存在的节点名称或 ID。
````markdown
```echarts
version: 1
caption: 图5 项目协同关系
height: 78mm
option:
series:
- type: graph
layout: force
roam: false
label:
show: true
force:
repulsion: 260
edgeLength: 90
data:
- { id: pm, name: 项目经理, symbolSize: 58 }
- { id: dev, name: 研发组, symbolSize: 48 }
- { id: test, name: 测试组, symbolSize: 48 }
- { id: ops, name: 运维组, symbolSize: 48 }
links:
- { source: pm, target: dev, value: 3 }
- { source: pm, target: test, value: 2 }
- { source: dev, target: ops, value: 1 }
- { source: test, target: ops, value: 2 }
```
````
## 九、系列类型与数据形状
当前支持 17 种系列。下表重点说明每种系列最容易写错的数据结构。
| `series[].type` | 主要数据结构 | 示例或要求 |
| --- | --- | --- |
| `line`、`bar` | 数值数组或 `dataset.source` | `data: [12, 18, 24]` |
| `scatter` | 坐标二元数组 | `data: [[12, 18], [16, 25]]` |
| `pie`、`funnel`、`gauge` | 名称和有限数值 | `{ name: 已完成, value: 72 }` |
| `radar` | 指标值数组 | `{ name: 项目甲, value: [86, 92, 78] }` |
| `heatmap` | x、y、值三元数组 | `data: [[0, 0, 32], [1, 0, 48]]` |
| `boxplot` | 最小值、Q1、中位数、Q3、最大值 | `data: [[5, 12, 18, 24, 36]]` |
| `candlestick` | 开盘、收盘、最低、最高 | `data: [[20, 24, 18, 28]]` |
| `tree` | 递归 `children` 节点 | `data: [{ name: 总部, children: [...] }]` |
| `treemap`、`sunburst` | 带数值的递归叶节点 | `{ name: 产品线A, value: 36 }` |
| `graph` | 节点和关系边 | `data: [...]` 配合 `links: [...]` |
| `sankey`、`chord` | 节点和带权关系边 | 关系边需要有限数值 `value` |
| `pictorialBar` | 与柱状图相同 | 可使用安全的内嵌 `path://` 矢量路径 |
每个系列必须直接提供非空 `series[].data`,或者通过有效的
`dataset.source` 提供数据。部分关系图使用 `nodes` 和 `links`,不采用
普通 `data` 数组。
## 十、安全与错误处理
为了确保离线、可复现和安全渲染,配置只能包含纯数据:
- 不支持 JavaScript 函数、自定义脚本或 HTML formatter
- 不允许外部图片、跳转链接和远程资源;
- 不支持 `custom` 系列;
- YAML 锚点、别名和自定义标签会被拒绝;
- `option` 中的原型污染属性会被拒绝;
- 单个围栏 YAML 不能超过 512 KiB
- 配置错误只会显示局部错误占位,不会中断整篇文档。
下面是一个会产生局部错误提示的示例,请勿在正式文档中使用:
````markdown
```echarts
version: 1
option:
series:
- type: pie
data: []
```
````
错误信息会指出类似 `option.series.0.data` 的配置路径,便于定位问题。
## 十一、排版建议
1. `caption` 用于图号、数据来源和统计口径,正文标题用于章节结构。
2. 正式报告优先使用少量稳定颜色,并确保黑白打印仍能区分系列。
3. 横轴类目过多时,优先拆图、旋转标签或改用横向柱状图。
4. 图表、图注会作为一个不可拆分媒体块参与分页。
5. 导出前使用“精确”预览检查最终分页、图例和标签。
@@ -0,0 +1,376 @@
---
title: 公文与正式文档主题使用教程
author: Markdown PDF 导出器
keywords: [公文, 红头文件, 正式文档, 标书, 教程]
---
# 公文与正式文档主题使用教程
本教程介绍政企红头、政企正式和标书主题的选择方法,以及如何通过文档
开头的 Front Matter YAML 生成红头、发文字号、签发人、简报刊头、项目
封面、投标封面和文末版记。
## 一、Front Matter 是什么
Front Matter 是 Markdown 文件最前面的 YAML 配置,必须由两行 `---`
包围。结束标记之后才开始写正文。
```yaml
---
title: 关于进一步规范项目管理工作的通知
author: 示例市人民政府办公室
subject: 项目管理工作通知
keywords: [公文, 通知, 项目管理]
document:
profile: official
issuer: 示例市人民政府办公室
number: 示例办发〔202612号
date: 2026年7月29日
---
```
注意:
- YAML 使用空格缩进,不要使用 Tab;
- `document` 下的字段需要比 `document:` 多缩进两个空格;
- 日期建议写成明确的中文字符串,例如 `2026年7月29日`
- 教程或主题示例从“更多”菜单打开时会自动切换主题;
- 普通 Markdown 文件中的 Front Matter 只描述文档结构,不会自动选择主题。
## 二、选择合适的主题
### 政企红头
- **政企红头·国家标准**:适合通知、决定、请示、报告等较完整的党政机关公文结构。
- **政企红头·信函格式**:适合函、征求意见、答复等信函式公文。
- **政企红头·企业简版**:适合企业内部通知、制度和管理文件。
- **政企红头·工作简报**:适合工作动态、专题简报和阶段性通报。
### 政企正式
- **政企正式·规章制度**:适合制度、办法、细则和管理规范。
- **政企正式·工作报告**:适合总结、汇报、调研和会议材料。
- **政企正式·可研报告**:适合可行性研究、项目建议书和建设方案。
### 标书正式
- **标书正式·经典版**:适合常规投标文件和技术方案。
- **标书正式·商务蓝**:适合企业方案、汇报材料和商务投标。
- **标书正式·暗标版**:适合需要弱化投标人识别信息的技术暗标。
## 三、通用元数据字段
这些字段位于 YAML 最外层,可用于所有主题和 profile。
| 字段 | 类型 | 功能 | 示例 |
| --- | --- | --- | --- |
| `title` | 字符串 | 文档标题;结构化主题会将其用于红头标题或封面标题 | `title: 关于开展专项检查的通知` |
| `author` | 字符串 | 作者或编制单位,同时可写入 PDF 作者元数据 | `author: 示例市人民政府办公室` |
| `subject` | 字符串 | 文档主题,可写入 PDF 主题元数据 | `subject: 专项检查工作通知` |
| `keywords` | 字符串数组 | 文档关键词,可写入 PDF 关键词元数据 | `keywords: [公文, 通知, 检查]` |
| `document` | 对象 | 启用并配置结构化公文、简报、项目报告或标书 | `document: { profile: official }` |
`title` 是结构化主题最重要的通用字段。使用 `official``project-report`
`tender` 时,正文通常不再重复书写同名一级标题。
字段路径也可以用点号快速定位,例如 `document.profile: official` 表示
YAML 中 `document` 对象下的 `profile: official`,点号路径仅用于说明,
不能直接照此写入 YAML。
## 四、红头公文:`official`
### 最小配置
最小配置适合企业简版红头或暂不需要文号、签发人的文件:
```yaml
---
title: 关于调整项目评审流程的通知
document:
profile: official
issuer: 示例科技集团有限公司
date: 2026年7月29日
---
```
### 完整配置
```yaml
---
title: 关于进一步规范项目管理工作的通知
author: 示例市人民政府办公室
subject: 项目管理工作通知
keywords: [公文, 通知, 项目管理]
document:
profile: official
issuer: 示例市人民政府办公室
number: 示例办发〔202612号
secrecy: 秘密
urgency: 加急
signatory: 张三
date: 2026年7月29日
copyTo:
- 市发展改革委
- 市财政局
- 市审计局
printingOffice: 示例市人民政府办公室
---
```
### `official` 字段表
| 字段 | 类型 | 必填 | 功能 | 示例 |
| --- | --- | --- | --- | --- |
| `profile` | 固定字符串 | 是 | 启用红头公文结构,必须为 `official` | `profile: official` |
| `issuer` | 字符串 | 否 | 红头发文机关名称,并用于文末落款机关 | `issuer: 示例市人民政府办公室` |
| `number` | 字符串 | 否 | 发文字号 | `number: 示例办发〔202612号` |
| `secrecy` | 字符串 | 否 | 密级或保密期限 | `secrecy: 秘密` |
| `urgency` | 字符串 | 否 | 紧急程度 | `urgency: 加急` |
| `signatory` | 字符串 | 否 | 签发人姓名 | `signatory: 张三` |
| `date` | 字符串 | 否 | 成文日期,并用于版记日期 | `date: 2026年7月29日` |
| `copyTo` | 字符串或数组 | 否 | 文末抄送机关,最多 30 项 | `copyTo: [市财政局, 市审计局]` |
| `printingOffice` | 字符串 | 否 | 文末版记中的印发机关 | `printingOffice: 市政府办公室` |
`copyTo` 支持两种写法:
```yaml
document:
profile: official
copyTo: 市发展改革委, 市财政局, 市审计局
```
```yaml
document:
profile: official
copyTo:
- 市发展改革委
- 市财政局
- 市审计局
```
抄送信息不需要手工写在正文末尾,结构化主题会自动生成;印发机关和日期
也会进入标准化版记区域。
## 五、工作简报:`briefing`
工作简报使用独立的刊头、期号、编发单位和报送说明。
```yaml
---
title: 重点项目建设工作推进情况
author: 示例市重点项目办公室
subject: 重点项目工作简报
keywords: [简报, 项目建设, 工作推进]
document:
profile: briefing
masthead: 工作简报
issue: 2026年第12期
publisher: 示例市重点项目办公室
signatory: 王五
date: 2026年7月29日
contact: "报送:市政府领导;发送:各区人民政府、市直有关单位。编辑:综合协调处,电话:0000-12345678。"
---
```
| 字段 | 类型 | 必填 | 功能 | 示例 |
| --- | --- | --- | --- | --- |
| `profile` | 固定字符串 | 是 | 启用简报结构,必须为 `briefing` | `profile: briefing` |
| `masthead` | 字符串 | 否 | 简报顶部刊头名称 | `masthead: 工作简报` |
| `issue` | 字符串 | 否 | 期号 | `issue: 2026年第12期` |
| `publisher` | 字符串 | 否 | 编发单位 | `publisher: 市重点项目办公室` |
| `signatory` | 字符串 | 否 | 签发或审核人员 | `signatory: 王五` |
| `date` | 字符串 | 否 | 编发日期 | `date: 2026年7月29日` |
| `contact` | 字符串 | 否 | 报送、发送、编辑及联系方式说明,最长 500 字符 | `contact: "报送:市政府领导;编辑:综合处。"` |
## 六、可研与项目报告:`project-report`
`project-report` 会生成项目报告封面,适合可行性研究报告、项目建议书和
建设方案。
```yaml
---
title: 可行性研究报告
author: 示例工程咨询有限公司
subject: 智慧园区建设项目可行性研究
keywords: [可行性研究, 项目建设, 投资估算]
document:
profile: project-report
projectName: 智慧园区一体化平台建设项目
documentType: 可行性研究报告
owner: 示例产业园区管理委员会
preparedBy: 示例工程咨询有限公司
version: 送审稿 V1.0
date: 2026年7月
---
```
| 字段 | 类型 | 必填 | 功能 | 示例 |
| --- | --- | --- | --- | --- |
| `profile` | 固定字符串 | 是 | 启用项目报告封面,必须为 `project-report` | `profile: project-report` |
| `projectName` | 字符串 | 否 | 项目完整名称 | `projectName: 智慧园区一体化平台建设项目` |
| `documentType` | 字符串 | 否 | 报告类型 | `documentType: 可行性研究报告` |
| `owner` | 字符串 | 否 | 建设、委托或申报单位 | `owner: 示例产业园区管理委员会` |
| `preparedBy` | 字符串 | 否 | 编制单位 | `preparedBy: 示例工程咨询有限公司` |
| `version` | 字符串 | 否 | 文件版本或送审状态 | `version: 送审稿 V1.0` |
| `date` | 字符串 | 否 | 编制日期 | `date: 2026年7月` |
封面之后直接从第一章开始书写,例如:
```markdown
# 第一章 总论
## 一、项目概况
本项目拟建设统一的园区管理、企业服务和运行监测平台。
```
## 七、投标文件:`tender`
### 常规投标文件
```yaml
---
title: 投标文件
author: 示例科技有限公司
subject: 数据中心建设项目投标文件
keywords: [投标文件, 技术方案, 项目实施]
document:
profile: tender
copyMark: 正本
projectName: 数据中心基础设施建设项目
projectNumber: ZB-2026-001
volume: 技术及商务文件
bidder: 示例科技有限公司
representative: 李四
date: 2026年7月29日
---
```
### 技术暗标
暗标通常省略投标人和代表信息,具体要求应以招标文件为准:
```yaml
---
title: 技术文件
subject: 城市运行管理平台技术暗标
keywords: [技术暗标, 技术方案, 项目实施]
document:
profile: tender
copyMark: 技术暗标
projectName: 城市运行管理平台建设项目
projectNumber: JS-2026-009
volume: 技术文件
date: 2026年7月
---
```
### `tender` 字段表
| 字段 | 类型 | 必填 | 功能 | 示例 |
| --- | --- | --- | --- | --- |
| `profile` | 固定字符串 | 是 | 启用标书封面,必须为 `tender` | `profile: tender` |
| `copyMark` | 字符串 | 否 | 正本、副本或技术暗标标记 | `copyMark: 正本` |
| `projectName` | 字符串 | 否 | 招标或采购项目名称 | `projectName: 数据中心基础设施建设项目` |
| `projectNumber` | 字符串 | 否 | 项目或招标编号 | `projectNumber: ZB-2026-001` |
| `volume` | 字符串 | 否 | 分册名称 | `volume: 技术及商务文件` |
| `bidder` | 字符串 | 否 | 投标人名称 | `bidder: 示例科技有限公司` |
| `representative` | 字符串 | 否 | 法定代表人或授权代表 | `representative: 李四` |
| `date` | 字符串 | 否 | 投标或编制日期 | `date: 2026年7月29日` |
## 八、普通正式文档
规章制度和工作报告不一定需要结构化封面。只使用通用元数据,并在正文中
正常书写一级标题即可:
```yaml
---
title: 信息系统项目管理办法
author: 示例集团有限公司
subject: 信息系统项目管理制度
keywords: [规章制度, 项目管理, 信息系统]
---
# 信息系统项目管理办法
## 第一章 总则
```
这种写法适用于“政企正式·规章制度”和“政企正式·工作报告”。不要添加
不存在的 `document.profile`,否则文档会显示配置错误。
## 九、正文写法
红头公文的 Front Matter 结束后直接书写主送机关和正文,不要再次添加
`title` 相同的一级标题,否则会重复显示公文标题。
```markdown
各区人民政府,市政府各部门、各直属机构:
为进一步加强全市重点项目全过程管理,现将有关事项通知如下。
## 一、完善项目分级管理机制
各单位应当按照投资规模、建设周期和社会影响建立动态管理台账。
## 二、规范项目建设过程
(一)严格执行项目建设程序。
(二)建立月度调度机制。
```
## 十、页边距、页眉页脚和页码
这些项目不属于 Front Matter 的 `document` 字段,而由主题推荐值和
“导出设置”控制:
| 配置 | 默认行为 | 自定义方式 |
| --- | --- | --- |
| 页边距 | 主题有推荐值时跟随主题,否则四边 `16mm` | 导出设置 → 页边距 → 自定义 |
| 页眉 | 正式主题可提供推荐内容和字体 | 导出设置 → 页眉 |
| 页脚 | 正式主题可提供推荐内容和字体 | 导出设置 → 页脚 |
| 页码 | 红头主题可推荐 `— 1 —` 等公文式页码 | 导出设置 → 页码 |
| 纸张与方向 | 默认 A4 纵向 | 导出设置 → 纸张 |
- 国家标准红头主题使用公文式页码;
- 双面文档页码可按奇偶页放在外侧;
- 信函格式首页可以不显示页码;
- 切换为“自定义”后,导出设置会覆盖主题推荐值。
不要在 Front Matter 中填写 `margin``header``footer``pageNumber`
并期待其生效;当前版本不把这些字段作为文档结构协议解析。
## 十一、字体说明
正式主题内置可再分发的 Fandol 中文字体资源,以减少不同电脑之间的显示
差异:
- 正文以宋体风格为主;
- 一级结构标题使用黑体风格;
- 二级结构标题使用楷体风格;
- 红头机关名称使用项目内置的小标宋近似字体方案;
- 页眉页脚使用正式文档主题规定的对应字体。
内置字体用于稳定排版效果,但不声称等同于需要单独授权的商业字体。对
字体有强制采购或归档要求的单位,应按本单位规定配置经授权字体。
## 十二、常见错误
| 现象 | 原因 | 处理方法 |
| --- | --- | --- |
| YAML 被显示成正文 | 文件开头不是 `---`,或缺少结束的 `---` | 确保 Front Matter 位于文件第一行 |
| 提示未知字段 | `document` 使用严格字段校验 | 对照对应 profile 的字段表删除拼写错误或多余字段 |
| 标题出现两次 | `title` 已生成结构标题,正文又写了同名 `#` 标题 | 删除正文中重复的一级标题 |
| 抄送未显示 | `copyTo` 写在了 `document` 外层 | 将其缩进到 `document:` 下面 |
| 主题没有自动切换 | 普通文件不能通过 Front Matter 选主题 | 手动选择主题,或从“更多 → 主题示例”打开 |
| 页边距字段无效 | 页边距不属于文档 profile | 在导出设置中选择“跟随主题”或“自定义” |
## 十三、导出前检查
1. 确认标题、发文机关、发文字号和日期无误。
2. 检查主送机关、落款和抄送信息是否完整。
3. 暗标文件必须再次对照招标要求检查身份信息和版式限制。
4. 使用“精确”预览核对页码、页边距和分页边界。
5. 检查表格、图片、图表和附件说明是否跨页异常。
6. 最终 PDF 应保持文字可搜索,并在归档前完成内容复核。
Binary file not shown.
+683
View File
@@ -0,0 +1,683 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
As a special exception, if you create a document which uses this font, and
embed this font or unaltered portions of this font into the document, this
font does not by itself cause the resulting document to be covered by the
GNU General Public License. This exception does not however invalidate any
other reasons why the document might be covered by the GNU General Public
License. If you modify this font, you may extend this exception to your version
of the font, but you are not obligated to do so. If you do not wish to do so,
delete this exception statement from your version.
+43
View File
@@ -0,0 +1,43 @@
# Fandol 公文字体资源
本目录提供内置正式文档主题共用的中文字体资源,不会作为独立主题出现在
主题列表中。
## 字体与用途
| 文件 | CSS 字体族 | 主题用途 |
| --- | --- | --- |
| `FandolSong-Regular.woff2` | `Mdpdf Fandol Song` | 表格、一般宋体角色 |
| `FandolSong-Bold.woff2` | `Mdpdf Fandol Song` | 公文红头、主标题的小标宋近似角色 |
| `FandolFang-Regular.woff2` | `Mdpdf Fandol Fang` | 公文正文仿宋角色 |
| `FandolHei-Regular.woff2` | `Mdpdf Fandol Hei` | 黑体角色、强调文字 |
| `FandolKai-Regular.woff2` | `Mdpdf Fandol Kai` | 楷体角色、说明文字 |
## 来源与转换
- 上游项目:Fandol 0.3
- 官方发布源:<https://ctan.org/pkg/fandol>
- 原始文件:CTAN 发布的 OpenType 字体
- 转换工具:fontTools 4.60.1 与 Brotli 1.2.0
- 转换方式:`python -m fontTools.ttLib.woff2 compress <input.otf> -o <output.woff2>`
WOFF2 文件是对上游 OTF 的 Web 字体格式转换,没有删减字符或修改字形。
上游说明保存在 `UPSTREAM-README.txt`,完整许可证保存在 `LICENSE.txt`
FandolSong Bold 是许可证允许内置的宋体粗体,并非方正小标宋;主题将其作为
跨平台一致的小标宋近似字体使用。
## SHA-256
| 文件 | SHA-256 |
| --- | --- |
| `FandolSong-Regular.woff2` | `9AC0EBCAA93C655CD5A35AB6CE3F24EE0624F5F694211CB05C45C930352D15FB` |
| `FandolSong-Bold.woff2` | `1CBAEBABC3506B1DE0A9C10DC502EF1720957939DCFB7EDF33443CD93FB6269C` |
| `FandolFang-Regular.woff2` | `854765A9F95C33C5CEB7D78B221EB1746C789E50A911A878E35932E85031E5EC` |
| `FandolHei-Regular.woff2` | `431AE0CA6CF79BCFDB4A9B7BE3B0B38AF97450795E4CEBB97F15E2D5DA24F42F` |
| `FandolKai-Regular.woff2` | `F3BCA64D93ADB4AB69860354E6C0E080515B060331BD2A6B5621EC88EC2CB0BD` |
## 许可证
Fandol 字体采用 GNU GPL,并附带字体例外条款。分发这些字体资源时必须一并
保留本目录中的许可证和来源说明;使用字体生成的文档不会因此自动受 GPL
约束。
@@ -0,0 +1,25 @@
README
These are the Fandol fonts for Chinese typesetting.
Current version of Fandol fonts contains four
styles: Song, Hei, Kai, Fang.
All the fonts are OpenType.
These fonts are developed by Fandol team.
The member of Fandol team: Clerk Ma & Jie Su.
These fonts's licensing is GPL + GPL font exception.
CHANGELOG
v0.1 2013/07/31
* initital version.
v0.2 2013/08/03
* change GID to CID (Adobe-GB1-5)
* using AFDKO to compile to OpenType
* CID ranges: 940-7702, 7717-8895, 29064-30220
* XUID: 1440
* fontforge + inkscape as outline editor
v0.3 2015/07/18
* added two braille fonts
* fixed bugs of kanji fonts
+8
View File
@@ -0,0 +1,8 @@
@media print {
#write .doc-masthead,
#write .doc-title,
#write .doc-signature,
#write .doc-edition {
break-inside: avoid;
}
}
+189
View File
@@ -0,0 +1,189 @@
:root {
--enterprise-red: #c71920;
--enterprise-ink: #242424;
--enterprise-muted: #666;
--enterprise-soft: #fff7f6;
}
#write {
color: var(--enterprise-ink);
font-family:
"Noto Serif CJK SC", "Source Han Serif SC",
"Microsoft YaHei", "SimSun", serif;
font-size: 18px;
line-height: 1.8;
overflow-wrap: break-word;
}
#write .doc-masthead-official {
margin: 0 0 12mm;
padding-bottom: 3mm;
border-bottom: 0.8mm solid var(--enterprise-red);
break-inside: avoid;
}
#write .doc-classification {
display: flex;
justify-content: space-between;
color: var(--enterprise-muted);
font-size: 0.78em;
}
#write .doc-issuer {
margin: 6mm 0;
color: var(--enterprise-red);
font-family:
"STZhongsong", "Noto Serif CJK SC", "Source Han Serif SC", serif;
font-size: 2em;
font-weight: 700;
letter-spacing: 0.1em;
line-height: 1.3;
text-align: center;
text-indent: 0;
}
#write .doc-issue-row {
display: flex;
justify-content: space-between;
color: #444;
font-size: 0.78em;
}
#write .doc-title {
margin: 0 0 8mm;
color: #171717;
font-size: 1.65em;
font-weight: 700;
line-height: 1.4;
text-align: center;
}
#write h1:not(.doc-title),
#write h2,
#write h3,
#write h4 {
margin: 1.4em 0 0.55em;
color: var(--enterprise-red);
font-family:
"Noto Sans CJK SC", "Source Han Sans SC", "Microsoft YaHei", sans-serif;
font-weight: 700;
line-height: 1.45;
}
#write h1:not(.doc-title) {
font-size: 1.55em;
}
#write h2 {
padding-left: 0.65em;
border-left: 0.22em solid var(--enterprise-red);
font-size: 1.25em;
}
#write h3,
#write h4 {
color: #333;
font-size: 1.08em;
}
#write p {
margin: 0 0 0.8em;
text-align: justify;
text-indent: 2em;
}
#write li > p,
#write blockquote p,
#write td p,
#write th p,
#write .doc-signature p,
#write .doc-edition p {
text-indent: 0;
}
#write a {
color: #a4141a;
text-underline-offset: 0.14em;
}
#write blockquote {
margin: 1em 0;
padding: 0.75em 1em;
border-left: 0.25em solid var(--enterprise-red);
background: var(--enterprise-soft);
}
#write table {
width: 100%;
margin: 1em 0;
border-collapse: collapse;
font-size: 0.88em;
}
#write th,
#write td {
padding: 0.5em 0.7em;
border: 1px solid #c8c8c8;
}
#write th {
color: #7d1116;
background: #fbe9e8;
font-weight: 700;
}
#write code {
padding: 0.1em 0.3em;
border-radius: 3px;
background: #f5f5f5;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 0.82em;
}
#write pre,
#write .md-fences {
margin: 1em 0;
padding: 0.9em 1em;
overflow: auto;
border: 1px solid #ddd;
border-radius: 4px;
background: #f7f7f7;
line-height: 1.6;
}
#write pre code {
padding: 0;
background: transparent;
}
#write .doc-signature {
width: 48%;
margin: 8mm 0 8mm auto;
text-align: center;
}
#write .doc-signature-issuer,
#write .doc-signature-date {
display: block;
text-indent: 0;
}
#write .doc-edition {
margin-top: 8mm;
padding: 2mm 0;
border-top: 1px solid var(--enterprise-red);
border-bottom: 1px solid var(--enterprise-red);
color: var(--enterprise-muted);
font-size: 0.78em;
}
#write .doc-printing-row {
display: flex;
justify-content: space-between;
}
#write img,
#write svg {
max-width: 100%;
height: auto;
}
+33
View File
@@ -0,0 +1,33 @@
{
"manifestVersion": 1,
"id": "enterprise-red-simple",
"name": "政企红头·企业简版",
"version": "0.5.1",
"description": "适合企业通知、制度和内部发文的简洁红头主题,保留识别度并降低机关公文的复杂度。",
"author": "md-to-pdf contributors",
"license": "项目自有",
"entry": "theme.css",
"print": "print.css",
"domPreset": "generic",
"defaultFontSize": "18px",
"supportedFeatures": [
"code",
"table",
"task-list",
"footnote",
"katex",
"mermaid",
"echarts"
],
"category": "red-letter",
"compatibleProfiles": ["official"],
"pageDefaults": {
"margins": {
"top": "25mm",
"right": "25mm",
"bottom": "25mm",
"left": "28mm"
}
},
"bundled": true
}
+5
View File
@@ -0,0 +1,5 @@
@media print {
#write .doc-cover-project-report {
break-after: page;
}
}
+171
View File
@@ -0,0 +1,171 @@
:root {
--feasibility-navy: #17324d;
--feasibility-gold: #b09055;
--feasibility-ink: #20252a;
--feasibility-muted: #69737c;
--feasibility-border: #bbc5cd;
--feasibility-soft: #f2f5f7;
}
#write {
color: var(--feasibility-ink);
font-family:
"Noto Serif CJK SC", "Source Han Serif SC",
"Microsoft YaHei", "SimSun", serif;
font-size: 17px;
line-height: 1.8;
overflow-wrap: break-word;
}
#write .doc-cover-project-report {
display: flex;
min-height: 220mm;
padding: 18mm 12mm;
border-top: 2mm solid var(--feasibility-navy);
border-bottom: 0.7mm solid var(--feasibility-gold);
break-after: page;
break-inside: avoid;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
#write .doc-cover-project-name {
margin: 0 0 22mm;
color: var(--feasibility-navy);
font-size: 1.65em;
font-weight: 700;
line-height: 1.5;
}
#write .doc-cover-title {
margin: 0 0 12mm;
color: #152c42;
font-size: 2.35em;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.35;
}
#write .doc-cover-version {
margin: 0 0 28mm;
color: var(--feasibility-gold);
font-size: 1.05em;
font-weight: 700;
}
#write .doc-cover-owner,
#write .doc-cover-prepared-by,
#write .doc-cover-date {
display: block;
min-width: 70%;
margin: 2mm 0;
color: #35434e;
font-size: 1em;
}
#write h1,
#write h2,
#write h3,
#write h4 {
font-family:
"Noto Sans CJK SC", "Source Han Sans SC",
"Microsoft YaHei", sans-serif;
font-weight: 700;
}
#write h1 {
margin: 0 0 1em;
padding: 0 0 0.45em;
border-bottom: 0.8mm solid var(--feasibility-navy);
color: var(--feasibility-navy);
font-size: 1.75em;
}
#write h2 {
margin: 1.6em 0 0.65em;
color: #234b6d;
font-size: 1.28em;
}
#write h3 {
margin: 1.3em 0 0.5em;
padding-left: 0.6em;
border-left: 0.22em solid var(--feasibility-gold);
font-size: 1.08em;
}
#write h4 {
font-size: 1em;
}
#write p {
margin: 0 0 0.8em;
text-align: justify;
text-indent: 2em;
}
#write .doc-cover p,
#write li > p,
#write blockquote p,
#write td p,
#write th p {
text-indent: 0;
}
#write blockquote {
margin: 1em 0;
padding: 0.75em 1em;
border-left: 0.25em solid var(--feasibility-gold);
background: #faf8f3;
}
#write table {
width: 100%;
margin: 1em 0;
border-collapse: collapse;
font-size: 0.86em;
}
#write th,
#write td {
padding: 0.48em 0.65em;
border: 1px solid var(--feasibility-border);
}
#write th {
color: #fff;
background: var(--feasibility-navy);
}
#write tbody tr:nth-child(even) {
background: var(--feasibility-soft);
}
#write code {
padding: 0.1em 0.3em;
background: #eef1f3;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 0.82em;
}
#write pre,
#write .md-fences {
margin: 1em 0;
padding: 0.9em 1em;
overflow: auto;
border: 1px solid #d0d7dc;
background: #f6f8f9;
}
#write pre code {
padding: 0;
background: transparent;
}
#write img,
#write svg {
max-width: 100%;
height: auto;
}
+25
View File
@@ -0,0 +1,25 @@
{
"manifestVersion": 1,
"id": "formal-feasibility",
"name": "政企正式·可研报告",
"version": "0.5.1",
"description": "面向项目建议书和可行性研究报告的正式封面与章节排版主题。",
"author": "md-to-pdf contributors",
"license": "项目自有",
"entry": "theme.css",
"print": "print.css",
"domPreset": "generic",
"defaultFontSize": "17px",
"supportedFeatures": ["code", "table", "task-list", "footnote", "katex", "mermaid", "echarts"],
"category": "formal",
"compatibleProfiles": ["project-report"],
"pageDefaults": {
"margins": {
"top": "25mm",
"right": "20mm",
"bottom": "25mm",
"left": "30mm"
}
},
"bundled": true
}
+6
View File
@@ -0,0 +1,6 @@
@media print {
#write table,
#write blockquote {
break-inside: avoid;
}
}
+139
View File
@@ -0,0 +1,139 @@
:root {
--regulation-ink: #111;
--regulation-muted: #555;
--regulation-border: #555;
--regulation-soft: #f3f3f3;
}
#write {
color: var(--regulation-ink);
font-family:
"FangSong", "STFangsong", "Noto Serif CJK SC",
"Source Han Serif SC", "SimSun", serif;
font-size: 18px;
line-height: 1.8;
overflow-wrap: break-word;
}
#write > h1:first-child {
margin: 8mm 0 12mm;
font-family:
"STZhongsong", "Noto Serif CJK SC", "Source Han Serif SC", serif;
font-size: 1.8em;
line-height: 1.4;
text-align: center;
}
#write h1,
#write h2,
#write h3,
#write h4,
#write h5,
#write h6 {
margin: 1.2em 0 0.35em;
color: #111;
font-size: 1em;
font-weight: 700;
line-height: 1.8;
}
#write h1 {
font-family:
"STZhongsong", "Noto Serif CJK SC", serif;
}
#write h2 {
font-family:
"SimHei", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
}
#write h3,
#write h4,
#write h5,
#write h6 {
font-family:
"KaiTi", "STKaiti", "Noto Serif CJK SC", serif;
}
#write p {
margin: 0;
text-align: justify;
text-indent: 2em;
}
#write li > p,
#write blockquote p,
#write td p,
#write th p {
text-indent: 0;
}
#write ul,
#write ol {
margin: 0.3em 0 0.65em;
padding-left: 2em;
}
#write blockquote {
margin: 0.8em 0;
padding: 0.55em 0.85em;
border: 1px solid #999;
background: var(--regulation-soft);
}
#write table {
width: 100%;
margin: 0.8em 0;
border-collapse: collapse;
font-family:
"SimSun", "Noto Serif CJK SC", serif;
font-size: 0.84em;
line-height: 1.5;
}
#write th,
#write td {
padding: 0.38em 0.55em;
border: 0.3mm solid var(--regulation-border);
}
#write th {
background: #e9e9e9;
font-family:
"SimHei", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
font-weight: 700;
text-align: center;
}
#write a {
color: inherit;
text-decoration: underline;
}
#write code {
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 0.78em;
}
#write pre,
#write .md-fences {
margin: 0.8em 0;
padding: 0.75em 0.9em;
overflow: auto;
border: 0.25mm solid #999;
background: #f5f5f5;
line-height: 1.55;
}
#write pre code {
padding: 0;
background: transparent;
}
#write .footnotes {
margin-top: 1.5em;
padding-top: 0.6em;
border-top: 0.25mm solid #777;
color: var(--regulation-muted);
font-size: 0.8em;
}
+25
View File
@@ -0,0 +1,25 @@
{
"manifestVersion": 1,
"id": "formal-regulation",
"name": "政企正式·规章制度",
"version": "0.5.1",
"description": "适用于管理办法、制度规范和政策文本的严谨黑白排版主题。",
"author": "md-to-pdf contributors",
"license": "项目自有",
"entry": "theme.css",
"print": "print.css",
"domPreset": "generic",
"defaultFontSize": "18px",
"supportedFeatures": ["code", "table", "task-list", "footnote", "katex", "mermaid", "echarts"],
"category": "formal",
"compatibleProfiles": [],
"pageDefaults": {
"margins": {
"top": "37mm",
"right": "26mm",
"bottom": "35mm",
"left": "28mm"
}
},
"bundled": true
}
+5
View File
@@ -0,0 +1,5 @@
@media print {
#write > h1:first-child {
break-after: avoid;
}
}
+159
View File
@@ -0,0 +1,159 @@
:root {
--report-navy: #183b5b;
--report-blue: #2f628c;
--report-ink: #20262d;
--report-muted: #66717b;
--report-border: #b9c6d0;
--report-soft: #eef4f8;
}
#write {
color: var(--report-ink);
font-family:
"Noto Serif CJK SC", "Source Han Serif SC",
"Microsoft YaHei", "SimSun", serif;
font-size: 18px;
line-height: 1.8;
overflow-wrap: break-word;
}
#write > h1:first-child {
margin: 10mm 0 12mm;
padding: 0 0 5mm;
border-bottom: 1mm solid var(--report-navy);
color: var(--report-navy);
font-size: 2em;
line-height: 1.35;
text-align: center;
}
#write h1,
#write h2,
#write h3,
#write h4 {
font-family:
"Noto Sans CJK SC", "Source Han Sans SC",
"Microsoft YaHei", sans-serif;
font-weight: 700;
}
#write h1 {
margin: 1.8em 0 0.8em;
color: var(--report-navy);
font-size: 1.6em;
}
#write h2 {
margin: 1.55em 0 0.65em;
padding: 0.25em 0.65em;
border-left: 0.28em solid var(--report-blue);
color: var(--report-navy);
background: linear-gradient(90deg, var(--report-soft), transparent);
font-size: 1.25em;
}
#write h3 {
margin: 1.3em 0 0.5em;
color: #2d4f6b;
font-size: 1.08em;
}
#write h4 {
margin: 1.1em 0 0.45em;
font-size: 1em;
}
#write p {
margin: 0 0 0.8em;
text-align: justify;
text-indent: 2em;
}
#write li > p,
#write blockquote p,
#write td p,
#write th p {
text-indent: 0;
}
#write ul,
#write ol {
margin: 0.55em 0 0.9em;
padding-left: 2em;
}
#write blockquote {
margin: 1em 0;
padding: 0.8em 1em;
border-left: 0.25em solid var(--report-blue);
color: #405566;
background: var(--report-soft);
}
#write table {
width: 100%;
margin: 1em 0;
border-collapse: collapse;
font-size: 0.88em;
line-height: 1.55;
}
#write th,
#write td {
padding: 0.5em 0.7em;
border: 1px solid var(--report-border);
}
#write th {
color: #fff;
background: var(--report-navy);
font-family:
"Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
text-align: center;
}
#write tbody tr:nth-child(even) {
background: #f7fafc;
}
#write a {
color: var(--report-blue);
}
#write code {
padding: 0.1em 0.3em;
border-radius: 3px;
background: #eef1f4;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 0.82em;
}
#write pre,
#write .md-fences {
margin: 1em 0;
padding: 0.9em 1em;
overflow: auto;
border: 1px solid #d3dbe1;
border-radius: 4px;
background: #f6f8fa;
line-height: 1.6;
}
#write pre code {
padding: 0;
background: transparent;
}
#write img,
#write svg {
max-width: 100%;
height: auto;
}
#write .footnotes {
margin-top: 2em;
padding-top: 0.8em;
border-top: 1px solid var(--report-border);
color: var(--report-muted);
font-size: 0.82em;
}
+25
View File
@@ -0,0 +1,25 @@
{
"manifestVersion": 1,
"id": "formal-report",
"name": "政企正式·工作报告",
"version": "0.5.1",
"description": "适用于工作总结、年度报告和专题汇报的稳重政务蓝灰主题。",
"author": "md-to-pdf contributors",
"license": "项目自有",
"entry": "theme.css",
"print": "print.css",
"domPreset": "generic",
"defaultFontSize": "18px",
"supportedFeatures": ["code", "table", "task-list", "footnote", "katex", "mermaid", "echarts"],
"category": "formal",
"compatibleProfiles": [],
"pageDefaults": {
"margins": {
"top": "37mm",
"right": "26mm",
"bottom": "35mm",
"left": "28mm"
}
},
"bundled": true
}
+49
View File
@@ -0,0 +1,49 @@
@media print {
#write .doc-masthead,
#write .doc-title,
#write .doc-signature,
#write .doc-edition {
break-inside: avoid;
}
.pagedjs_pages > .pagedjs_page:last-child .pagedjs_pagebox {
position: relative;
}
.pagedjs_pages > .pagedjs_page:last-child .pagedjs_pagebox::after {
position: absolute;
right: max(20mm, calc((100% - 170mm) / 2));
bottom: 20mm;
left: max(20mm, calc((100% - 170mm) / 2));
z-index: 3;
height: 1.2mm;
background:
linear-gradient(
to bottom,
var(--letter-red) 0 0.25mm,
transparent 0.25mm 0.55mm,
var(--letter-red) 0.55mm 1.2mm
);
content: "";
pointer-events: none;
}
#write[data-document-profile="official"]::after {
display: block;
width: min(170mm, 100%);
height: 1.2mm;
margin: 12mm auto 0;
background:
linear-gradient(
to bottom,
var(--letter-red) 0 0.25mm,
transparent 0.25mm 0.55mm,
var(--letter-red) 0.55mm 1.2mm
);
content: "";
}
.pagedjs_page #write[data-document-profile="official"]::after {
content: none;
}
}
+294
View File
@@ -0,0 +1,294 @@
@font-face {
font-family: "Mdpdf Fandol Song";
font-style: normal;
font-weight: 400;
font-display: block;
src: url("theme-shared:official-fonts/FandolSong-Regular.woff2") format("woff2");
}
@font-face {
font-family: "Mdpdf Fandol Song";
font-style: normal;
font-weight: 700;
font-display: block;
src: url("theme-shared:official-fonts/FandolSong-Bold.woff2") format("woff2");
}
@font-face {
font-family: "Mdpdf Fandol Fang";
font-style: normal;
font-weight: 400;
font-display: block;
src: url("theme-shared:official-fonts/FandolFang-Regular.woff2") format("woff2");
}
@font-face {
font-family: "Mdpdf Fandol Hei";
font-style: normal;
font-weight: 400;
font-display: block;
src: url("theme-shared:official-fonts/FandolHei-Regular.woff2") format("woff2");
}
@font-face {
font-family: "Mdpdf Fandol Kai";
font-style: normal;
font-weight: 400;
font-display: block;
src: url("theme-shared:official-fonts/FandolKai-Regular.woff2") format("woff2");
}
:root {
--letter-red: #d40000;
--letter-ink: #111;
}
#write {
color: var(--letter-ink);
font-family:
"Mdpdf Fandol Fang", "FangSong", "STFangsong", "Noto Serif CJK SC",
"Source Han Serif SC", "SimSun", serif;
font-size: 21.333px;
line-height: 1.75;
overflow-wrap: break-word;
text-align: justify;
text-justify: inter-ideograph;
}
#write .doc-masthead-official {
position: relative;
margin: 0 0 16mm;
padding: 0 0 5mm;
break-inside: avoid;
}
#write .doc-masthead-official::after {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 1.2mm;
background:
linear-gradient(
to bottom,
var(--letter-red) 0 0.65mm,
transparent 0.65mm 0.95mm,
var(--letter-red) 0.95mm 1.2mm
);
content: "";
}
#write .doc-classification {
display: flex;
min-height: 1.75em;
justify-content: space-between;
font-family:
"Mdpdf Fandol Hei", "SimHei", "Noto Sans CJK SC",
"Microsoft YaHei", sans-serif;
font-size: 0.75em;
font-weight: 400;
}
#write .doc-issuer {
margin: 4mm 0 7mm;
color: var(--letter-red);
font-family:
"Mdpdf Fandol Song", "STZhongsong", "Noto Serif CJK SC",
"Source Han Serif SC", serif;
font-size: 1.65em;
font-weight: 700;
letter-spacing: 0.06em;
line-height: 1.3;
text-align: center;
text-indent: 0;
}
#write .doc-issue-row {
display: flex;
align-items: end;
justify-content: space-between;
font-size: 0.75em;
line-height: 1.5;
}
#write .doc-title {
margin: 0 0 10mm;
font-family:
"Mdpdf Fandol Song", "STZhongsong", "Noto Serif CJK SC",
"Source Han Serif SC", serif;
font-size: 1.375em;
font-weight: 700;
line-height: 1.45;
text-align: center;
}
#write h1:not(.doc-title),
#write h2,
#write h3,
#write h4,
#write h5,
#write h6 {
margin: 1em 0 0.35em;
font-family:
"Mdpdf Fandol Hei", "SimHei", "Noto Sans CJK SC",
"Microsoft YaHei", sans-serif;
font-size: 1em;
font-weight: 400;
line-height: 1.75;
text-align: left;
text-align-last: left;
}
#write h3,
#write h4,
#write h5,
#write h6 {
font-family:
"Mdpdf Fandol Kai", "KaiTi", "STKaiti",
"Noto Serif CJK SC", serif;
font-weight: 400;
}
#write .doc-signatory {
font-family:
"Mdpdf Fandol Kai", "KaiTi", "STKaiti",
"Noto Serif CJK SC", serif;
}
#write .doc-signatory > span {
font-family:
"Mdpdf Fandol Fang", "FangSong", "STFangsong",
"Noto Serif CJK SC", serif;
}
#write p {
margin: 0;
text-indent: 2em;
text-align-last: left;
}
#write .doc-issuer,
#write .doc-title,
#write .doc-signature p {
text-align-last: auto;
}
#write li > p,
#write blockquote p,
#write td p,
#write th p,
#write .doc-signature p,
#write .doc-edition p {
text-indent: 0;
}
#write ul,
#write ol {
margin: 0.35em 0 0.7em;
padding-left: 2em;
}
#write blockquote {
margin: 0.8em 0;
padding: 0.45em 0.8em;
border-left: 0.8mm solid var(--letter-red);
background: #fff8f6;
}
#write a {
color: inherit;
text-decoration: underline;
}
#write table {
width: 100%;
margin: 0.8em 0;
border-collapse: collapse;
font-family:
"Mdpdf Fandol Song", "SimSun", "Noto Serif CJK SC",
"Source Han Serif SC", serif;
font-size: 0.75em;
line-height: 1.5;
}
#write th,
#write td {
padding: 0.35em 0.55em;
border: 0.25mm solid #222;
}
#write th {
background: #f5f5f5;
font-family:
"Mdpdf Fandol Hei", "SimHei", "Noto Sans CJK SC",
"Microsoft YaHei", sans-serif;
font-weight: 400;
}
#write code {
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 0.72em;
}
#write pre,
#write .md-fences {
margin: 0.8em 0;
padding: 0.75em 0.9em;
overflow: auto;
border: 0.25mm solid #aaa;
background: #f7f7f7;
line-height: 1.55;
text-align: left;
}
#write pre code {
padding: 0;
background: transparent;
}
#write .doc-signature {
width: 52%;
margin: 8mm 0 10mm auto;
break-inside: avoid;
text-align: center;
}
#write .doc-signature-issuer,
#write .doc-signature-date {
display: block;
margin: 0;
text-indent: 0;
}
#write .doc-edition {
margin-top: 8mm;
break-inside: avoid;
font-family:
"Mdpdf Fandol Fang", "FangSong", "STFangsong",
"Noto Serif CJK SC", serif;
font-size: 0.875em;
line-height: 1.7;
}
#write .doc-copy-to {
padding: 1mm 0;
}
#write .doc-printing-row {
display: flex;
padding: 1mm 0;
justify-content: space-between;
}
#write img,
#write svg {
max-width: 100%;
height: auto;
}
#write .footnotes {
margin-top: 1.5em;
padding-top: 0.6em;
border-top: 0.25mm solid #777;
font-size: 0.75em;
}
+65
View File
@@ -0,0 +1,65 @@
{
"manifestVersion": 1,
"id": "gov-red-letter",
"name": "政企红头·标准信函",
"version": "0.5.1",
"description": "参照公文信函格式设计,提供版头下方与末页底部的标准化红色双线。",
"author": "md-to-pdf contributors",
"license": "项目自有",
"entry": "theme.css",
"print": "print.css",
"domPreset": "generic",
"defaultFontSize": "21.333px",
"supportedFeatures": [
"code",
"table",
"task-list",
"footnote",
"katex",
"mermaid",
"echarts"
],
"category": "red-letter",
"compatibleProfiles": ["official"],
"pageDefaults": {
"margins": {
"top": "37mm",
"right": "26mm",
"bottom": "35mm",
"left": "28mm"
},
"header": {
"enabled": false,
"height": "8mm",
"showDivider": false,
"fontSize": "4.94mm",
"fontFamily": "\"Mdpdf Fandol Song\", SimSun, serif",
"color": "#000000",
"left": {
"enabled": false,
"content": ""
},
"center": {
"enabled": false,
"content": ""
},
"right": {
"enabled": false,
"content": ""
}
},
"footer": {
"enabled": true,
"height": "7mm",
"showDivider": false,
"fontSize": "4.94mm",
"fontFamily": "\"Mdpdf Fandol Song\", SimSun, serif",
"color": "#000000",
"alignment": "outer",
"format": "official-page",
"startFrom": 1,
"showOnFirstPage": false
}
},
"bundled": true
}
+12
View File
@@ -0,0 +1,12 @@
@media print {
#write .doc-masthead,
#write .doc-title,
#write .doc-signature,
#write .doc-edition {
break-inside: avoid;
}
#write .doc-edition {
break-before: auto;
}
}
+293
View File
@@ -0,0 +1,293 @@
@font-face {
font-family: "Mdpdf Fandol Song";
font-style: normal;
font-weight: 400;
font-display: block;
src: url("theme-shared:official-fonts/FandolSong-Regular.woff2") format("woff2");
}
@font-face {
font-family: "Mdpdf Fandol Song";
font-style: normal;
font-weight: 700;
font-display: block;
src: url("theme-shared:official-fonts/FandolSong-Bold.woff2") format("woff2");
}
@font-face {
font-family: "Mdpdf Fandol Fang";
font-style: normal;
font-weight: 400;
font-display: block;
src: url("theme-shared:official-fonts/FandolFang-Regular.woff2") format("woff2");
}
@font-face {
font-family: "Mdpdf Fandol Hei";
font-style: normal;
font-weight: 400;
font-display: block;
src: url("theme-shared:official-fonts/FandolHei-Regular.woff2") format("woff2");
}
@font-face {
font-family: "Mdpdf Fandol Kai";
font-style: normal;
font-weight: 400;
font-display: block;
src: url("theme-shared:official-fonts/FandolKai-Regular.woff2") format("woff2");
}
:root {
--official-red: #d40000;
--official-ink: #111;
--official-muted: #555;
--official-rule: #222;
}
#write {
color: var(--official-ink);
font-family:
"Mdpdf Fandol Fang", "FangSong", "STFangsong", "Noto Serif CJK SC",
"Source Han Serif SC", "SimSun", serif;
font-size: 21.333px;
line-height: 1.75;
overflow-wrap: break-word;
text-align: justify;
text-justify: inter-ideograph;
}
#write .doc-masthead-official {
position: relative;
margin: 0 0 18mm;
padding: 0 0 4mm;
border-bottom: 1mm solid var(--official-red);
break-inside: avoid;
color: var(--official-red);
}
#write .doc-classification {
display: flex;
min-height: 1.75em;
justify-content: space-between;
color: var(--official-ink);
font-family:
"Mdpdf Fandol Hei", "SimHei", "Noto Sans CJK SC",
"Microsoft YaHei", sans-serif;
font-size: 0.75em;
font-weight: 400;
}
#write .doc-issuer {
margin: 9mm 0 8mm;
color: var(--official-red);
font-family:
"Mdpdf Fandol Song", "STZhongsong", "Noto Serif CJK SC",
"Source Han Serif SC", serif;
font-size: 2em;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.25;
text-align: center;
text-indent: 0;
}
#write .doc-issue-row {
display: flex;
min-height: 1.75em;
align-items: end;
justify-content: space-between;
color: var(--official-ink);
font-size: 0.75em;
line-height: 1.5;
}
#write .doc-signatory > span {
font-family:
"Mdpdf Fandol Fang", "FangSong", "STFangsong",
"Noto Serif CJK SC", serif;
}
#write .doc-signatory {
font-family:
"Mdpdf Fandol Kai", "KaiTi", "STKaiti",
"Noto Serif CJK SC", serif;
}
#write .doc-title {
margin: 0 0 12mm;
color: var(--official-ink);
font-family:
"Mdpdf Fandol Song", "STZhongsong", "Noto Serif CJK SC",
"Source Han Serif SC", serif;
font-size: 1.375em;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1.45;
text-align: center;
}
#write h1:not(.doc-title),
#write h2,
#write h3,
#write h4,
#write h5,
#write h6 {
margin: 1em 0 0.35em;
color: var(--official-ink);
font-family:
"Mdpdf Fandol Hei", "SimHei", "Noto Sans CJK SC",
"Microsoft YaHei", sans-serif;
font-size: 1em;
font-weight: 400;
line-height: 1.75;
text-align: left;
text-align-last: left;
}
#write h3,
#write h4,
#write h5,
#write h6 {
font-family:
"Mdpdf Fandol Kai", "KaiTi", "STKaiti",
"Noto Serif CJK SC", serif;
font-weight: 400;
}
#write p {
margin: 0;
text-indent: 2em;
text-align-last: left;
}
#write .doc-issuer,
#write .doc-title,
#write .doc-signature p {
text-align-last: auto;
}
#write li > p,
#write blockquote p,
#write td p,
#write th p,
#write .doc-signature p,
#write .doc-edition p {
text-indent: 0;
}
#write ul,
#write ol {
margin: 0.35em 0 0.7em;
padding-left: 2em;
}
#write blockquote {
margin: 0.8em 0;
padding: 0.45em 0.8em;
border-left: 0.8mm solid var(--official-red);
background: #fff8f6;
}
#write a {
color: inherit;
text-decoration: underline;
text-underline-offset: 0.14em;
}
#write table {
width: 100%;
margin: 0.8em 0;
border-collapse: collapse;
font-family:
"Mdpdf Fandol Song", "SimSun", "Noto Serif CJK SC",
"Source Han Serif SC", serif;
font-size: 0.75em;
line-height: 1.5;
}
#write th,
#write td {
padding: 0.35em 0.55em;
border: 0.25mm solid #222;
}
#write th {
background: #f5f5f5;
font-family:
"Mdpdf Fandol Hei", "SimHei", "Noto Sans CJK SC",
"Microsoft YaHei", sans-serif;
font-weight: 400;
text-align: center;
}
#write code {
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 0.72em;
}
#write pre,
#write .md-fences {
margin: 0.8em 0;
padding: 0.75em 0.9em;
overflow: auto;
border: 0.25mm solid #aaa;
background: #f7f7f7;
line-height: 1.55;
text-align: left;
}
#write pre code {
padding: 0;
background: transparent;
}
#write .doc-signature {
width: 52%;
margin: 8mm 0 10mm auto;
break-inside: avoid;
text-align: center;
}
#write .doc-signature-issuer,
#write .doc-signature-date {
display: block;
margin: 0;
text-indent: 0;
}
#write .doc-edition {
margin-top: 10mm;
border-top: 0.7mm solid var(--official-rule);
border-bottom: 0.35mm solid var(--official-rule);
break-inside: avoid;
font-family:
"Mdpdf Fandol Fang", "FangSong", "STFangsong",
"Noto Serif CJK SC", serif;
font-size: 0.875em;
line-height: 1.7;
}
#write .doc-copy-to {
padding: 1mm 2em;
border-bottom: 0.25mm solid #777;
}
#write .doc-printing-row {
display: flex;
padding: 1mm 2em;
justify-content: space-between;
}
#write img,
#write svg {
max-width: 100%;
height: auto;
}
#write .footnotes {
margin-top: 1.5em;
padding-top: 0.6em;
border-top: 0.25mm solid #777;
font-size: 0.75em;
}
+65
View File
@@ -0,0 +1,65 @@
{
"manifestVersion": 1,
"id": "gov-red-standard",
"name": "政企红头·标准文件",
"version": "0.5.1",
"description": "参照党政机关公文版式设计的标准红头文件主题,包含发文机关标志、发文字号、落款和黑色版记。",
"author": "md-to-pdf contributors",
"license": "项目自有",
"entry": "theme.css",
"print": "print.css",
"domPreset": "generic",
"defaultFontSize": "21.333px",
"supportedFeatures": [
"code",
"table",
"task-list",
"footnote",
"katex",
"mermaid",
"echarts"
],
"category": "red-letter",
"compatibleProfiles": ["official"],
"pageDefaults": {
"margins": {
"top": "37mm",
"right": "26mm",
"bottom": "35mm",
"left": "28mm"
},
"header": {
"enabled": false,
"height": "8mm",
"showDivider": false,
"fontSize": "4.94mm",
"fontFamily": "\"Mdpdf Fandol Song\", SimSun, serif",
"color": "#000000",
"left": {
"enabled": false,
"content": ""
},
"center": {
"enabled": false,
"content": ""
},
"right": {
"enabled": false,
"content": ""
}
},
"footer": {
"enabled": true,
"height": "7mm",
"showDivider": false,
"fontSize": "4.94mm",
"fontFamily": "\"Mdpdf Fandol Song\", SimSun, serif",
"color": "#000000",
"alignment": "outer",
"format": "official-page",
"startFrom": 1,
"showOnFirstPage": true
}
},
"bundled": true
}
+7
View File
@@ -0,0 +1,7 @@
@media print {
#write .doc-masthead,
#write .doc-title,
#write .doc-briefing-contact {
break-inside: avoid;
}
}
+172
View File
@@ -0,0 +1,172 @@
:root {
--briefing-red: #c40009;
--briefing-ink: #1f1f1f;
--briefing-muted: #666;
--briefing-soft: #fff6f4;
}
#write {
color: var(--briefing-ink);
font-family:
"Noto Serif CJK SC", "Source Han Serif SC",
"Microsoft YaHei", "SimSun", serif;
font-size: 18px;
line-height: 1.8;
overflow-wrap: break-word;
}
#write .doc-masthead-briefing {
margin: 0 0 11mm;
break-inside: avoid;
}
#write .doc-briefing-masthead {
margin: 0;
padding: 3mm 0 4mm;
border-top: 1mm solid var(--briefing-red);
border-bottom: 1mm solid var(--briefing-red);
color: var(--briefing-red);
font-family:
"STZhongsong", "Noto Serif CJK SC", "Source Han Serif SC", serif;
font-size: 2.35em;
font-weight: 700;
letter-spacing: 0.18em;
line-height: 1.35;
text-align: center;
text-indent: 0;
}
#write .doc-briefing-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5mm 8mm;
padding: 3mm 1mm 2mm;
border-bottom: 0.35mm solid var(--briefing-red);
color: #444;
font-size: 0.78em;
line-height: 1.5;
}
#write .doc-briefing-publisher,
#write .doc-briefing-date {
text-align: right;
}
#write .doc-title {
margin: 0 0 8mm;
font-family:
"STZhongsong", "Noto Serif CJK SC", "Source Han Serif SC", serif;
font-size: 1.65em;
font-weight: 700;
line-height: 1.4;
text-align: center;
}
#write h1:not(.doc-title),
#write h2,
#write h3,
#write h4 {
margin: 1.35em 0 0.5em;
font-family:
"Noto Sans CJK SC", "Source Han Sans SC", "Microsoft YaHei", sans-serif;
font-weight: 700;
line-height: 1.5;
}
#write h1:not(.doc-title) {
color: var(--briefing-red);
font-size: 1.5em;
}
#write h2 {
padding-bottom: 0.25em;
border-bottom: 0.35mm solid #d8a2a2;
color: #8f1015;
font-size: 1.22em;
}
#write h3,
#write h4 {
font-size: 1.05em;
}
#write p {
margin: 0 0 0.75em;
text-align: justify;
text-indent: 2em;
}
#write li > p,
#write blockquote p,
#write td p,
#write th p {
text-indent: 0;
}
#write .doc-briefing-contact {
margin-top: 10mm;
padding: 2mm 0;
border-top: 0.6mm solid var(--briefing-red);
border-bottom: 0.25mm solid var(--briefing-red);
color: var(--briefing-muted);
font-size: 0.78em;
line-height: 1.6;
}
#write blockquote {
margin: 1em 0;
padding: 0.75em 1em;
border-left: 0.25em solid var(--briefing-red);
background: var(--briefing-soft);
}
#write table {
width: 100%;
margin: 1em 0;
border-collapse: collapse;
font-size: 0.88em;
}
#write th,
#write td {
padding: 0.5em 0.7em;
border: 1px solid #c7c7c7;
}
#write th {
color: #7d1116;
background: #fbe9e8;
}
#write a {
color: #9e1117;
}
#write code {
padding: 0.1em 0.3em;
border-radius: 3px;
background: #f5f5f5;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 0.82em;
}
#write pre,
#write .md-fences {
margin: 1em 0;
padding: 0.9em 1em;
overflow: auto;
border: 1px solid #ddd;
background: #f7f7f7;
line-height: 1.6;
}
#write pre code {
padding: 0;
background: transparent;
}
#write img,
#write svg {
max-width: 100%;
height: auto;
}

Some files were not shown because too many files have changed in this diff Show More