Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83e6559c2c |
@@ -3,6 +3,7 @@ dist/
|
|||||||
coverage/
|
coverage/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
.conda_env
|
||||||
.local/
|
.local/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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/`;
|
||||||
|
|||||||
@@ -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 首次运行可能显示“未知发布者”
|
||||||
提示。
|
提示。
|
||||||
|
|
||||||
|
|||||||
@@ -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,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 导出器桌面端",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+12
-1
@@ -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";
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("保存并恢复经过校验的配置", () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Generated
+3
-3
@@ -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
@@ -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
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'"
|
||||||
|
})[character] ?? character
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: 示例办发〔2026〕12号
|
||||||
|
secrecy: 内部
|
||||||
|
urgency: 加急
|
||||||
|
signatory: 张三
|
||||||
|
date: 2026年7月29日
|
||||||
|
copyTo: 市发展改革委, 市财政局
|
||||||
|
printingOffice: 示例市人民政府办公室
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工作要求
|
||||||
|
|
||||||
|
正文内容。
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(result.metadata.document).toEqual({
|
||||||
|
profile: "official",
|
||||||
|
issuer: "示例市人民政府办公室",
|
||||||
|
number: "示例办发〔2026〕12号",
|
||||||
|
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(
|
||||||
|
"<script>alert(1)</script>"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("服务端渲染公式并保留 Mermaid 安全占位", () => {
|
it("服务端渲染公式并保留 Mermaid 安全占位", () => {
|
||||||
const result = renderMarkdown(`
|
const result = renderMarkdown(`
|
||||||
内联公式 $E=mc^2$。
|
内联公式 $E=mc^2$。
|
||||||
|
|||||||
@@ -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 元数据中是否包含身份信息。
|
||||||
|
- 可研和标书不存在适用于所有项目的统一页边距;暗标的字体、字号、页边距、页码和装订要求尤其应以具体招标文件为准。正式导出前应关闭不允许出现的页眉、页脚和页码,并进行人工合规复核。
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: 关于发布项目交付管理办法的通知
|
||||||
|
author: 示例科技集团有限公司
|
||||||
|
subject: 项目交付管理办法
|
||||||
|
keywords: [企业发文, 管理制度, 项目交付]
|
||||||
|
document:
|
||||||
|
profile: official
|
||||||
|
issuer: 示例科技集团有限公司
|
||||||
|
number: 示例科发〔2026〕08号
|
||||||
|
signatory: 李四
|
||||||
|
date: 2026年7月29日
|
||||||
|
copyTo: 各事业部, 各区域公司
|
||||||
|
printingOffice: 集团综合管理部
|
||||||
|
---
|
||||||
|
|
||||||
|
集团各部门、各所属公司:
|
||||||
|
|
||||||
|
《项目交付管理办法》已经集团经营管理会议审议通过,现予发布,自2026年8月1日起施行。
|
||||||
|
|
||||||
|
## 一、适用范围
|
||||||
|
|
||||||
|
本办法适用于集团及所属公司承担的咨询、软件开发、系统集成和运维服务项目。
|
||||||
|
|
||||||
|
## 二、关键控制点
|
||||||
|
|
||||||
|
| 阶段 | 必备成果 | 责任人 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 启动 | 项目章程、实施计划 | 项目经理 |
|
||||||
|
| 建设 | 周报、风险清单、变更记录 | 项目团队 |
|
||||||
|
| 验收 | 验收报告、移交清单 | 交付负责人 |
|
||||||
|
|
||||||
|
## 三、执行要求
|
||||||
|
|
||||||
|
各单位应在项目管理平台维护完整记录。重大风险应在一个工作日内升级报告,未经审批不得擅自变更项目范围、预算和关键里程碑。
|
||||||
@@ -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万元,包括软件建设、基础设施、安全服务、实施服务和第三方测评费用。
|
||||||
|
|
||||||
|
# 第五章 结论
|
||||||
|
|
||||||
|
项目建设目标明确,技术路线可行,投资规模合理,具备实施条件。
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
title: 信息系统项目管理办法
|
||||||
|
author: 示例集团有限公司
|
||||||
|
subject: 信息系统项目管理制度
|
||||||
|
keywords: [规章制度, 项目管理, 信息系统]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 信息系统项目管理办法
|
||||||
|
|
||||||
|
## 第一章 总则
|
||||||
|
|
||||||
|
### 第一条
|
||||||
|
|
||||||
|
为规范集团信息系统项目全生命周期管理,控制项目风险,提高投资效益,根据国家有关规定和集团管理要求,制定本办法。
|
||||||
|
|
||||||
|
### 第二条
|
||||||
|
|
||||||
|
本办法适用于集团总部及所属单位新建、改建和升级的信息系统项目。
|
||||||
|
|
||||||
|
## 第二章 职责分工
|
||||||
|
|
||||||
|
### 第三条
|
||||||
|
|
||||||
|
数字化管理部门负责项目统筹、技术审查、过程监督和验收管理;需求单位负责业务需求确认、试运行和应用推广。
|
||||||
|
|
||||||
|
### 第四条
|
||||||
|
|
||||||
|
项目实行负责人制度。项目负责人对建设范围、质量、进度、投资和安全承担管理责任。
|
||||||
|
|
||||||
|
## 第三章 项目实施
|
||||||
|
|
||||||
|
### 第五条
|
||||||
|
|
||||||
|
项目实施应当依次完成立项、采购、建设、测试、试运行、验收和移交。
|
||||||
|
|
||||||
|
| 阶段 | 必备材料 | 审批责任 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 立项 | 需求说明、投资估算 | 需求单位 |
|
||||||
|
| 建设 | 实施计划、风险清单 | 项目负责人 |
|
||||||
|
| 验收 | 测试报告、移交清单 | 数字化管理部门 |
|
||||||
|
|
||||||
|
## 第四章 附则
|
||||||
|
|
||||||
|
### 第六条
|
||||||
|
|
||||||
|
本办法由集团数字化管理部门负责解释,自发布之日起施行。
|
||||||
@@ -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. 推进经营数据治理和人才专项培养。
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
title: 关于征求数字化建设方案意见的函
|
||||||
|
author: 示例市数据管理局
|
||||||
|
subject: 征求数字化建设方案意见
|
||||||
|
keywords: [公文, 函, 数字化]
|
||||||
|
document:
|
||||||
|
profile: official
|
||||||
|
issuer: 示例市数据管理局
|
||||||
|
number: 示例数函〔2026〕18号
|
||||||
|
date: 2026年7月29日
|
||||||
|
copyTo:
|
||||||
|
- 市发展改革委
|
||||||
|
- 市财政局
|
||||||
|
printingOffice: 示例市数据管理局办公室
|
||||||
|
---
|
||||||
|
|
||||||
|
市直各有关单位:
|
||||||
|
|
||||||
|
为统筹推进全市数字基础设施建设,我局组织编制了《示例市数字化建设三年行动方案(征求意见稿)》。现就方案内容征求你单位意见。
|
||||||
|
|
||||||
|
## 一、重点征求事项
|
||||||
|
|
||||||
|
请重点围绕建设目标、年度任务、项目清单、资金安排和数据安全责任等内容提出意见。涉及跨部门协同事项的,请明确牵头单位、配合单位和完成时限。
|
||||||
|
|
||||||
|
## 二、反馈方式
|
||||||
|
|
||||||
|
请于2026年8月12日前将加盖单位公章的书面意见及电子版反馈至我局。无意见也请书面反馈。
|
||||||
|
|
||||||
|
## 三、联系人
|
||||||
|
|
||||||
|
联系人:王某,联系电话:0000-12345678,电子邮箱:example@example.com。
|
||||||
|
|
||||||
|
感谢对我市数字化建设工作的支持。
|
||||||
|
|
||||||
|
为验证末页双线位置,本示例继续提供一段较长的补充说明。各单位在提出意见时,应同步评估现有系统、数据资源、网络环境和运维队伍情况,避免重复建设。对需要纳入年度财政预算的项目,应说明投资估算、资金来源和预期绩效;对涉及公共数据授权运营的事项,应明确数据分类分级、授权边界、安全审计和退出机制。
|
||||||
|
|
||||||
|
同时,请结合本单位职责梳理可共享的数据目录,说明更新频率、质量责任和使用限制。涉及个人信息、商业秘密或者重要数据的,应当提出相应的脱敏、访问控制和风险评估措施。意见应当具体、清晰、可执行,原则上不只提出方向性表述。
|
||||||
|
|
||||||
|
我局将对反馈意见逐条研究,形成采纳情况说明,并根据论证结果修改完善行动方案。后续需要进一步协调的事项,将另行组织专题会议。
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
title: 关于进一步规范项目管理工作的通知
|
||||||
|
author: 示例市人民政府办公室
|
||||||
|
subject: 项目管理工作通知
|
||||||
|
keywords: [公文, 通知, 项目管理]
|
||||||
|
document:
|
||||||
|
profile: official
|
||||||
|
issuer: 示例市人民政府办公室
|
||||||
|
number: 示例办发〔2026〕12号
|
||||||
|
urgency: 加急
|
||||||
|
signatory: 张三
|
||||||
|
date: 2026年7月29日
|
||||||
|
copyTo:
|
||||||
|
- 市发展改革委
|
||||||
|
- 市财政局
|
||||||
|
- 市审计局
|
||||||
|
printingOffice: 示例市人民政府办公室
|
||||||
|
---
|
||||||
|
|
||||||
|
各区人民政府,市政府各部门、各直属机构:
|
||||||
|
|
||||||
|
为进一步加强全市重点项目全过程管理,提升项目谋划、建设和验收质量,经市政府同意,现将有关事项通知如下。
|
||||||
|
|
||||||
|
## 一、完善项目分级管理机制
|
||||||
|
|
||||||
|
各单位应当按照投资规模、建设周期和社会影响,将项目划分为重点项目、一般项目和储备项目,分别建立动态管理台账。
|
||||||
|
|
||||||
|
## 二、规范项目建设过程
|
||||||
|
|
||||||
|
(一)严格执行立项、规划、用地、环境评价和招标采购等程序。
|
||||||
|
|
||||||
|
(二)建立月度调度机制,及时协调解决项目建设中的堵点问题。
|
||||||
|
|
||||||
|
(三)重大事项应当形成书面记录并按照权限履行审批程序。
|
||||||
|
|
||||||
|
## 三、强化监督检查
|
||||||
|
|
||||||
|
市有关部门将定期开展联合检查。对推进有力、成效明显的单位予以通报;对工作滞后、整改不力的单位按规定进行督办。
|
||||||
|
|
||||||
|
请各单位结合实际认真贯彻执行。
|
||||||
@@ -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. 完成年度项目储备库中期评估。
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
title: 技术文件
|
||||||
|
subject: 城市运行管理平台技术暗标
|
||||||
|
keywords: [技术暗标, 技术方案, 项目实施]
|
||||||
|
document:
|
||||||
|
profile: tender
|
||||||
|
copyMark: 技术暗标
|
||||||
|
projectName: 城市运行管理平台建设项目
|
||||||
|
projectNumber: JS-2026-009
|
||||||
|
volume: 技术文件
|
||||||
|
date: 2026年7月
|
||||||
|
---
|
||||||
|
|
||||||
|
# 第一章 项目理解
|
||||||
|
|
||||||
|
项目建设应形成城市运行监测、事件协同、指挥调度和综合评价能力,提升跨部门协同效率。
|
||||||
|
|
||||||
|
## 一、需求分析
|
||||||
|
|
||||||
|
现状系统存在数据标准不统一、事件闭环不完整和运行指标分散等问题,需要建立统一的技术与数据体系。
|
||||||
|
|
||||||
|
## 二、建设原则
|
||||||
|
|
||||||
|
1. 统一规划,分步实施。
|
||||||
|
2. 数据驱动,业务协同。
|
||||||
|
3. 安全可控,稳定可靠。
|
||||||
|
4. 标准开放,持续演进。
|
||||||
|
|
||||||
|
# 第二章 技术方案
|
||||||
|
|
||||||
|
## 一、总体架构
|
||||||
|
|
||||||
|
采用基础设施层、数据资源层、能力支撑层、业务应用层和展现层的五层架构。
|
||||||
|
|
||||||
|
| 层级 | 建设内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 数据资源层 | 目录、治理、共享、指标 |
|
||||||
|
| 能力支撑层 | 事件、规则、流程、消息 |
|
||||||
|
| 业务应用层 | 监测、协同、调度、评价 |
|
||||||
|
|
||||||
|
# 第三章 实施方案
|
||||||
|
|
||||||
|
项目按照准备、设计、建设、试运行和验收五个阶段推进,建立质量、进度、风险和安全管理机制。
|
||||||
@@ -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日历天。
|
||||||
|
|
||||||
|
# 服务体系
|
||||||
|
|
||||||
|
提供驻场支持、远程服务、定期巡检和管理员培训,形成持续运营机制。
|
||||||
@@ -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小时到场。
|
||||||
@@ -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 并发上限
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
评审人:产品、设计、研发与测试代表。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
title: 城市公共空间观察札记
|
||||||
|
author: 示例研究小组
|
||||||
|
subject: 街区步行环境观察
|
||||||
|
keywords: [调研, 观察, 城市空间]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 城市公共空间观察札记
|
||||||
|
|
||||||
|
## 清晨:街角与通勤
|
||||||
|
|
||||||
|
早高峰开始前,沿街商铺逐渐亮起灯光。步行者会自然选择遮阴连续、路口等待
|
||||||
|
时间较短的一侧街道。空间是否友好,往往体现在座椅、树荫和过街距离这些
|
||||||
|
不起眼的细节里。
|
||||||
|
|
||||||
|
> 好的公共空间并不要求人们停留,却始终为停留保留可能。
|
||||||
|
|
||||||
|
## 午后:共享空间的变化
|
||||||
|
|
||||||
|
我们在三个时段记录了广场中的主要活动:
|
||||||
|
|
||||||
|
| 时段 | 主要使用者 | 常见活动 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 08:00—09:00 | 通勤者、学生 | 快速通过、等候 |
|
||||||
|
| 12:00—14:00 | 周边职员 | 午餐、短暂休息 |
|
||||||
|
| 17:00—19:00 | 居民、儿童 | 散步、游戏、交流 |
|
||||||
|
|
||||||
|
## 傍晚:灯光与安全感
|
||||||
|
|
||||||
|
照明亮度并不是唯一指标。连续的临街界面、清晰的步行方向和适当的人群密度,
|
||||||
|
共同决定了夜间安全感。后续设计应优先修复照明断点,并避免绿化遮挡视线。
|
||||||
|
|
||||||
|
## 小结
|
||||||
|
|
||||||
|
1. 为主要步行路径提供连续遮阴。
|
||||||
|
2. 缩短路口等待时间,增加安全岛。
|
||||||
|
3. 在不阻塞通行的位置增加可移动座椅。
|
||||||
|
4. 保留适合临时活动的小尺度开放区域。
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
title: 项目周会备忘
|
||||||
|
author: 示例项目组
|
||||||
|
subject: 本周进展与下周安排
|
||||||
|
keywords: [周会, 备忘, 项目管理]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 项目周会备忘
|
||||||
|
|
||||||
|
## 本周完成
|
||||||
|
|
||||||
|
- 完成主题清单与分类展示。
|
||||||
|
- 打通网页预览和桌面 PDF 的统一渲染链路。
|
||||||
|
- 修复长代码块和媒体元素的分页边界问题。
|
||||||
|
|
||||||
|
## 关键数据
|
||||||
|
|
||||||
|
| 指标 | 本周结果 |
|
||||||
|
| --- | ---: |
|
||||||
|
| 自动化测试 | 全部通过 |
|
||||||
|
| 阻塞问题 | 0 |
|
||||||
|
| 待复核项目 | 3 |
|
||||||
|
|
||||||
|
## 下周安排
|
||||||
|
|
||||||
|
1. 完成正式文档主题的字体复核。
|
||||||
|
2. 增加主题示例和使用教程。
|
||||||
|
3. 执行安装包与免安装包验收。
|
||||||
|
|
||||||
|
## 风险提示
|
||||||
|
|
||||||
|
> 暗标、政府公文和企业模板可能存在项目级差异,最终格式应以对应制度或招标
|
||||||
|
> 文件为准。
|
||||||
|
|
||||||
|
负责人:示例项目经理<br>
|
||||||
|
记录日期:2026年7月29日
|
||||||
@@ -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: 示例办发〔2026〕12号
|
||||||
|
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: 示例办发〔2026〕12号
|
||||||
|
secrecy: 秘密
|
||||||
|
urgency: 加急
|
||||||
|
signatory: 张三
|
||||||
|
date: 2026年7月29日
|
||||||
|
copyTo:
|
||||||
|
- 市发展改革委
|
||||||
|
- 市财政局
|
||||||
|
- 市审计局
|
||||||
|
printingOffice: 示例市人民政府办公室
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
### `official` 字段表
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 功能 | 示例 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `profile` | 固定字符串 | 是 | 启用红头公文结构,必须为 `official` | `profile: official` |
|
||||||
|
| `issuer` | 字符串 | 否 | 红头发文机关名称,并用于文末落款机关 | `issuer: 示例市人民政府办公室` |
|
||||||
|
| `number` | 字符串 | 否 | 发文字号 | `number: 示例办发〔2026〕12号` |
|
||||||
|
| `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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
@media print {
|
||||||
|
#write .doc-masthead,
|
||||||
|
#write .doc-title,
|
||||||
|
#write .doc-signature,
|
||||||
|
#write .doc-edition {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@media print {
|
||||||
|
#write .doc-cover-project-report {
|
||||||
|
break-after: page;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@media print {
|
||||||
|
#write table,
|
||||||
|
#write blockquote {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@media print {
|
||||||
|
#write > h1:first-child {
|
||||||
|
break-after: avoid;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@media print {
|
||||||
|
#write .doc-masthead,
|
||||||
|
#write .doc-title,
|
||||||
|
#write .doc-briefing-contact {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user