Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83e6559c2c |
@@ -3,6 +3,7 @@ dist/
|
||||
coverage/
|
||||
.env
|
||||
.env.local
|
||||
.conda_env
|
||||
.local/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
@@ -4,27 +4,24 @@
|
||||
导出工具。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题 CSS 和
|
||||
分页运行时,适合内网部署,也可以作为不依赖本地服务的桌面应用使用。
|
||||
|
||||
当前版本:`v0.5.0`。
|
||||
当前版本:`v0.5.1`。
|
||||
|
||||
## v0.5.0 主要能力
|
||||
## v0.5.1 主要能力
|
||||
|
||||
- 新增“连续”预览模式:不分页、低测量开销,适合编辑时快速刷新;快速
|
||||
预览继续提供分页近似,精确预览和导出 PDF 保持权威分页;
|
||||
- 分页引擎从 Web 应用抽取为 `@md-to-pdf/preview-engine`,Web、
|
||||
Playwright PDF 与 Electron PDF 直接复用同一运行时;
|
||||
- 快速预览支持按修改位置复用稳定前缀,只重新分页受影响的后缀;
|
||||
- Web 链接只处理当前文档锚点和 HTTP/HTTPS 外链;外链在新浏览器窗口
|
||||
打开,本地路径及不支持协议不执行导航;
|
||||
- Desktop 支持 HTTP/HTTPS、系统协议、本地相对或绝对路径和 `file:`
|
||||
URI;Markdown 链接可选择在当前窗口或新窗口打开;
|
||||
- Desktop 支持多窗口和同一文件单例。窗口聚焦时检查磁盘文件变化,并
|
||||
提示是否重新加载;
|
||||
- 四个内置主题统一命名为 Typora Github、Typora Pixyll、
|
||||
Typora whitey 和 Typora Clean,默认使用 Typora Github;
|
||||
- 围栏代码块输出 Typora 兼容的 `.md-fences` DOM,并避免内部
|
||||
`<code>` 重复套用行内代码样式;
|
||||
- 桌面发行链会在每次打包前完整重建内嵌 Web 依赖,避免安装包携带陈旧
|
||||
前端资源。
|
||||
- 新增政企标准红头、函件红头、企业简版红头、简报红头等 4 套红头主题,
|
||||
以及 3 套正式文档主题和 3 套标书主题;
|
||||
- 正式主题可声明推荐页边距、页眉页脚、页码和页面装饰;未声明时继续使用
|
||||
16mm 默认页边距,选择正式主题时自动采用其规范参数;
|
||||
- 新增公文、项目报告和标书结构化 Front Matter,支持发文机关、文号、
|
||||
签发人、密级、主送、抄送、印发信息、封面及落款等文档结构;
|
||||
- 内置方正书版兼容的 Fandol 中文字体资源,统一正文、红头标题与页眉页脚
|
||||
字体,并修复红头标题居中;代码块支持按正文宽度自动换行、跨页分片,
|
||||
同时保留 `8px` 左侧内容留白;
|
||||
- 内置 ECharts 与公文主题教程,并为 14 套主题提供示例 Markdown;
|
||||
Desktop 发行包包含全部教程和示例,打开示例时自动切换相应主题;
|
||||
- “更多”菜单按文件、教程与示例、主题管理分区;Desktop 新增
|
||||
`Ctrl+N/W/S/Shift+S` 工作流、未保存确认及跟随新路径的“另存为”,
|
||||
Web 端隐藏不适用的“另存为”。
|
||||
|
||||
## Web 与桌面端
|
||||
|
||||
@@ -32,7 +29,7 @@
|
||||
| --- | --- | --- |
|
||||
| 新建 Markdown | 支持 | 支持 |
|
||||
| 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 |
|
||||
| 保存 Markdown | 浏览器下载 | 原生另存为 |
|
||||
| 保存文件 | 浏览器下载 | 原生保存与另存为 |
|
||||
| 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 |
|
||||
| 文档内锚点 | 当前预览定位 | 当前预览定位 |
|
||||
| HTTP/HTTPS 链接 | 浏览器新窗口 | 系统默认浏览器 |
|
||||
@@ -129,8 +126,8 @@ npm run make -w @md-to-pdf/desktop
|
||||
```
|
||||
|
||||
桌面 `package` 和 `make` 会先完整重建内嵌 Web 及全部共享依赖,不能
|
||||
复用陈旧的 `apps/web/dist`。产物位于 `apps/desktop/out/v0.5.0/`。
|
||||
正式安装包命名为 `md-to-pdf-0.5.0-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
|
||||
复用陈旧的 `apps/web/dist`。产物位于 `apps/desktop/out/v0.5.1/`。
|
||||
正式安装包命名为 `md-to-pdf-0.5.1-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
|
||||
未配置代码签名。
|
||||
|
||||
## 文档与媒体
|
||||
@@ -149,8 +146,8 @@ ECharts 围栏语法、支持系列和安全边界详见
|
||||
|
||||
## 主题
|
||||
|
||||
仓库内置 Typora Github、Typora Pixyll、Typora whitey 和
|
||||
Typora Clean 四套主题,默认使用 Typora Github。主题由版本化
|
||||
仓库内置 4 套 Typora 风格主题、4 套红头主题、3 套正式文档主题和
|
||||
3 套标书主题,默认使用 Typora Github。主题由版本化
|
||||
`theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。
|
||||
|
||||
- 本地开发与 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。
|
||||
|
||||
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
|
||||
`apps/desktop/out/v0.5.0/`。该目录被 Git 忽略。公司内部分发以
|
||||
`md-to-pdf-0.5.0-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
||||
`apps/desktop/out/v0.5.1/`。该目录被 Git 忽略。公司内部分发以
|
||||
`md-to-pdf-0.5.1-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
||||
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
|
||||
提示。
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ module.exports = {
|
||||
to: "themes",
|
||||
filter: ["**/*"]
|
||||
},
|
||||
{
|
||||
from: "../../samples",
|
||||
to: "samples",
|
||||
filter: ["**/*"]
|
||||
},
|
||||
{
|
||||
from: windowsIcon,
|
||||
to: "app.ico"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@md-to-pdf/desktop",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"productName": "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 {
|
||||
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
||||
DESKTOP_CREATE_NEW_WINDOW,
|
||||
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
||||
DESKTOP_GET_THEME_CSS,
|
||||
DESKTOP_GENERATE_PDF,
|
||||
@@ -13,9 +14,12 @@ import {
|
||||
DESKTOP_OPEN_THEME_DIRECTORY,
|
||||
DESKTOP_REFRESH_THEMES,
|
||||
DESKTOP_RENDER_MARKDOWN,
|
||||
DESKTOP_RESOLVE_WINDOW_CLOSE,
|
||||
DESKTOP_SAVE_MARKDOWN,
|
||||
DESKTOP_SAVE_MARKDOWN_AS,
|
||||
DESKTOP_SET_DOCUMENT_DIRTY,
|
||||
DESKTOP_START_NEW_MARKDOWN,
|
||||
DESKTOP_WINDOW_CLOSE_REQUESTED,
|
||||
DESKTOP_SAVE_PDF
|
||||
} from "./channels.js";
|
||||
|
||||
@@ -38,12 +42,30 @@ contextBridge.exposeInMainWorld("mdToPdfDesktop", {
|
||||
},
|
||||
startNewMarkdown: () =>
|
||||
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) =>
|
||||
ipcRenderer.invoke(DESKTOP_SET_DOCUMENT_DIRTY, dirty),
|
||||
openDocumentLink: (href: string) =>
|
||||
ipcRenderer.invoke(DESKTOP_OPEN_DOCUMENT_LINK, href),
|
||||
saveMarkdown: (fileName: string, markdown: string) =>
|
||||
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN, fileName, markdown),
|
||||
saveMarkdownAs: (fileName: string, markdown: string) =>
|
||||
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN_AS, fileName, markdown),
|
||||
openThemeDirectory: () =>
|
||||
ipcRenderer.invoke(DESKTOP_OPEN_THEME_DIRECTORY),
|
||||
refreshThemes: () => ipcRenderer.invoke(DESKTOP_REFRESH_THEMES),
|
||||
|
||||
@@ -14,8 +14,16 @@ export const DESKTOP_OPEN_DOCUMENT_LINK =
|
||||
"md-to-pdf:desktop:open-document-link";
|
||||
export const 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 =
|
||||
"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 =
|
||||
"md-to-pdf:desktop:open-theme-directory";
|
||||
export const DESKTOP_REFRESH_THEMES =
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "@md-to-pdf/application";
|
||||
import {
|
||||
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
||||
DESKTOP_CREATE_NEW_WINDOW,
|
||||
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
||||
DESKTOP_GENERATE_PDF,
|
||||
DESKTOP_GET_THEME_CSS,
|
||||
@@ -31,10 +32,13 @@ import {
|
||||
DESKTOP_OPEN_THEME_DIRECTORY,
|
||||
DESKTOP_REFRESH_THEMES,
|
||||
DESKTOP_RENDER_MARKDOWN,
|
||||
DESKTOP_RESOLVE_WINDOW_CLOSE,
|
||||
DESKTOP_SAVE_MARKDOWN,
|
||||
DESKTOP_SAVE_MARKDOWN_AS,
|
||||
DESKTOP_SAVE_PDF,
|
||||
DESKTOP_SET_DOCUMENT_DIRTY,
|
||||
DESKTOP_START_NEW_MARKDOWN
|
||||
DESKTOP_START_NEW_MARKDOWN,
|
||||
DESKTOP_WINDOW_CLOSE_REQUESTED
|
||||
} from "./channels.js";
|
||||
import {
|
||||
parseMarkdownRenderRequest,
|
||||
@@ -55,6 +59,7 @@ import {
|
||||
saveWindowState,
|
||||
type WindowState
|
||||
} from "./window-state.js";
|
||||
import { getWindowCloseDecision } from "./window-close-policy.js";
|
||||
|
||||
const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 };
|
||||
const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 };
|
||||
@@ -72,6 +77,7 @@ interface WindowSession {
|
||||
window: BrowserWindow;
|
||||
dirty: boolean;
|
||||
allowClose: boolean;
|
||||
closeRequestPending: boolean;
|
||||
currentFilePath: string | undefined;
|
||||
documentKey: string | undefined;
|
||||
documentRoot: string | undefined;
|
||||
@@ -159,7 +165,10 @@ export class DesktopApplicationController {
|
||||
return this.#createWindow(pendingSnapshot);
|
||||
}
|
||||
|
||||
async #createWindow(pendingSnapshot?: MarkdownFileSnapshot) {
|
||||
async #createWindow(
|
||||
pendingSnapshot?: MarkdownFileSnapshot,
|
||||
startBlank = false
|
||||
) {
|
||||
const isPrimary = !this.#primarySession;
|
||||
const restoredWindowState = isPrimary
|
||||
? this.#latestWindowState ?? this.#savedWindowState
|
||||
@@ -190,6 +199,7 @@ export class DesktopApplicationController {
|
||||
window,
|
||||
dirty: false,
|
||||
allowClose: false,
|
||||
closeRequestPending: false,
|
||||
currentFilePath: undefined,
|
||||
documentKey: undefined,
|
||||
documentRoot: undefined,
|
||||
@@ -219,7 +229,11 @@ export class DesktopApplicationController {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -325,11 +339,17 @@ export class DesktopApplicationController {
|
||||
void this.#checkForExternalChange(session);
|
||||
});
|
||||
window.on("close", (event) => {
|
||||
if (!session.dirty || session.allowClose) {
|
||||
const decision = getWindowCloseDecision(session);
|
||||
if (decision === "allow") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void this.#confirmDirtyWindowClose(session);
|
||||
if (decision === "request") {
|
||||
session.closeRequestPending = true;
|
||||
session.window.webContents.send(
|
||||
DESKTOP_WINDOW_CLOSE_REQUESTED
|
||||
);
|
||||
}
|
||||
});
|
||||
window.on("closed", () => {
|
||||
const wasPrimary = this.#primarySession === session;
|
||||
@@ -358,28 +378,6 @@ export class DesktopApplicationController {
|
||||
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) {
|
||||
if (
|
||||
session.focusCheckRunning ||
|
||||
@@ -630,6 +628,28 @@ export class DesktopApplicationController {
|
||||
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(
|
||||
DESKTOP_SET_DOCUMENT_DIRTY,
|
||||
async (event, unsafeDirty: unknown) => {
|
||||
@@ -640,13 +660,12 @@ export class DesktopApplicationController {
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
DESKTOP_SAVE_MARKDOWN,
|
||||
async (
|
||||
event,
|
||||
unsafeFileName: unknown,
|
||||
unsafeMarkdown: unknown
|
||||
) => {
|
||||
const saveMarkdown = async (
|
||||
event: IpcMainInvokeEvent,
|
||||
unsafeFileName: unknown,
|
||||
unsafeMarkdown: unknown,
|
||||
forceSaveAs: boolean
|
||||
) => {
|
||||
const session = this.#getSession(event);
|
||||
if (
|
||||
typeof unsafeFileName !== "string" ||
|
||||
@@ -659,18 +678,20 @@ export class DesktopApplicationController {
|
||||
}
|
||||
|
||||
let targetPath = session.currentFilePath;
|
||||
if (!targetPath) {
|
||||
if (forceSaveAs || !targetPath) {
|
||||
const suggestedName = /\.(?:md|markdown)$/iu.test(
|
||||
path.basename(unsafeFileName)
|
||||
)
|
||||
? path.basename(unsafeFileName)
|
||||
: `${path.basename(unsafeFileName)}.md`;
|
||||
const selection = await dialog.showSaveDialog(session.window, {
|
||||
title: "保存 Markdown",
|
||||
defaultPath: path.join(
|
||||
app.getPath("documents"),
|
||||
suggestedName
|
||||
),
|
||||
title: forceSaveAs
|
||||
? "另存为 Markdown"
|
||||
: "保存 Markdown",
|
||||
defaultPath:
|
||||
forceSaveAs && session.currentFilePath
|
||||
? session.currentFilePath
|
||||
: path.join(app.getPath("documents"), suggestedName),
|
||||
filters: [
|
||||
{
|
||||
name: "Markdown 文件",
|
||||
@@ -724,7 +745,27 @@ export class DesktopApplicationController {
|
||||
}
|
||||
this.#commitSnapshot(session, snapshot);
|
||||
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) => {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
+485
-37
@@ -59,6 +59,18 @@ import {
|
||||
useThemeResources
|
||||
} from "./use-theme-resources";
|
||||
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 module = await import("./PrecisePdfPreview");
|
||||
@@ -67,18 +79,48 @@ const PrecisePdfPreview = lazy(async () => {
|
||||
|
||||
type PreviewMode = "quick" | "precise" | "continuous";
|
||||
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 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() {
|
||||
const [markdown, setMarkdown] = useState(sampleMarkdown);
|
||||
const [fileName, setFileName] = useState("示例文档.md");
|
||||
const [markdown, setMarkdown] = useState(initialMarkdown);
|
||||
const [fileName, setFileName] = useState(initialFileName);
|
||||
const [documentKind, setDocumentKind] =
|
||||
useState<DocumentKind>("sample");
|
||||
const [savedMarkdown, setSavedMarkdown] = useState(sampleMarkdown);
|
||||
useState<DocumentKind>(initialDocumentKind);
|
||||
const [savedMarkdown, setSavedMarkdown] = useState(initialMarkdown);
|
||||
const [exportConfig, setExportConfig] =
|
||||
useState<ExportConfig>(loadExportConfig);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [documentDialog, setDocumentDialog] =
|
||||
useState<DocumentDialogState>();
|
||||
const [documentActionBusy, setDocumentActionBusy] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [moreSubmenuOpen, setMoreSubmenuOpen] = useState<
|
||||
"tutorials" | "samples" | undefined
|
||||
>();
|
||||
const [themeRefreshKey, setThemeRefreshKey] = useState(0);
|
||||
const [sourcePanelCollapsed, setSourcePanelCollapsed] = useState(false);
|
||||
const [status, setStatus] = useState("正在准备预览…");
|
||||
@@ -186,21 +228,60 @@ export function App() {
|
||||
);
|
||||
}, [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(() => {
|
||||
if (!moreMenuOpen) {
|
||||
return;
|
||||
}
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (
|
||||
event.target instanceof Node &&
|
||||
!moreMenuRef.current?.contains(event.target)
|
||||
) {
|
||||
if (!(event.target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
if (!moreMenuRef.current?.contains(event.target)) {
|
||||
setMoreMenuOpen(false);
|
||||
setMoreSubmenuOpen(undefined);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setMoreMenuOpen(false);
|
||||
setMoreSubmenuOpen(undefined);
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointerdown", handlePointerDown);
|
||||
@@ -214,19 +295,18 @@ export function App() {
|
||||
const handleThemesLoaded = useCallback(
|
||||
(availableThemes: ThemeSummary[], loadedRefreshKey: number) => {
|
||||
setExportConfig((currentConfig) => {
|
||||
if (
|
||||
availableThemes.some(
|
||||
(theme) => theme.id === currentConfig.themeId
|
||||
)
|
||||
) {
|
||||
return currentConfig;
|
||||
const currentTheme = availableThemes.find(
|
||||
(theme) => theme.id === currentConfig.themeId
|
||||
);
|
||||
if (currentTheme) {
|
||||
return applyThemeDefaults(currentConfig, currentTheme);
|
||||
}
|
||||
const preferredTheme =
|
||||
availableThemes.find((theme) => theme.id === "typora-github") ??
|
||||
availableThemes.find((theme) => theme.id === "typora-like") ??
|
||||
availableThemes[0];
|
||||
return preferredTheme
|
||||
? { ...currentConfig, themeId: preferredTheme.id }
|
||||
? selectTheme(currentConfig, preferredTheme)
|
||||
: currentConfig;
|
||||
});
|
||||
if (themeRefreshTargetRef.current === loadedRefreshKey) {
|
||||
@@ -268,6 +348,17 @@ export function App() {
|
||||
pdfError;
|
||||
const themeId = exportConfig.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 =
|
||||
documentKind === "new" || !fileName
|
||||
? createMarkdownFileName(markdown)
|
||||
@@ -806,11 +897,29 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewMarkdown() {
|
||||
function requestNewMarkdown() {
|
||||
setMoreMenuOpen(false);
|
||||
if (!confirmDiscardChanges()) {
|
||||
setMoreSubmenuOpen(undefined);
|
||||
if (window.mdToPdfDesktop) {
|
||||
setDocumentDialog({ type: "new-location" });
|
||||
return;
|
||||
}
|
||||
requestCurrentWindowNew();
|
||||
}
|
||||
|
||||
function requestCurrentWindowNew() {
|
||||
if (documentDirty) {
|
||||
setDocumentDialog({
|
||||
type: "unsaved",
|
||||
action: { type: "new-current" }
|
||||
});
|
||||
return;
|
||||
}
|
||||
setDocumentDialog(undefined);
|
||||
void createMarkdownInCurrentWindow();
|
||||
}
|
||||
|
||||
async function createMarkdownInCurrentWindow() {
|
||||
try {
|
||||
setAppError("");
|
||||
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);
|
||||
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);
|
||||
try {
|
||||
setAppError("");
|
||||
const bridge = window.mdToPdfDesktop;
|
||||
let savedFileName = suggestedName;
|
||||
if (bridge) {
|
||||
const saved = await bridge.saveMarkdown(suggestedName, markdown);
|
||||
const saved = forceSaveAs
|
||||
? await bridge.saveMarkdownAs(suggestedName, markdown)
|
||||
: await bridge.saveMarkdown(suggestedName, markdown);
|
||||
if (!saved) {
|
||||
setStatus("已取消 Markdown 保存");
|
||||
return;
|
||||
setStatus(
|
||||
forceSaveAs
|
||||
? "已取消 Markdown 另存为"
|
||||
: "已取消 Markdown 保存"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
savedFileName = saved.fileName;
|
||||
} else {
|
||||
@@ -847,16 +1131,39 @@ export function App() {
|
||||
setFileName(savedFileName);
|
||||
setDocumentKind("file");
|
||||
setSavedMarkdown(markdown);
|
||||
setStatus(`Markdown 已保存:${savedFileName}`);
|
||||
setStatus(
|
||||
forceSaveAs
|
||||
? `Markdown 已另存为:${savedFileName}`
|
||||
: `Markdown 已保存:${savedFileName}`
|
||||
);
|
||||
return true;
|
||||
} catch (reason) {
|
||||
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() {
|
||||
setMoreMenuOpen(false);
|
||||
setMoreSubmenuOpen(undefined);
|
||||
try {
|
||||
setAppError("");
|
||||
const themeDirectory =
|
||||
@@ -875,6 +1182,7 @@ export function App() {
|
||||
|
||||
async function handleRefreshThemes() {
|
||||
setMoreMenuOpen(false);
|
||||
setMoreSubmenuOpen(undefined);
|
||||
try {
|
||||
setAppError("");
|
||||
await window.mdToPdfDesktop?.refreshThemes();
|
||||
@@ -1011,7 +1319,10 @@ export function App() {
|
||||
aria-controls="document-actions-menu"
|
||||
aria-expanded={moreMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setMoreMenuOpen((open) => !open)}
|
||||
onClick={() => {
|
||||
setMoreMenuOpen((open) => !open);
|
||||
setMoreSubmenuOpen(undefined);
|
||||
}}
|
||||
>
|
||||
更多 ▾
|
||||
</button>
|
||||
@@ -1023,20 +1334,129 @@ export function App() {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="menu-command"
|
||||
role="menuitem"
|
||||
onClick={() => void handleNewMarkdown()}
|
||||
onClick={requestNewMarkdown}
|
||||
>
|
||||
新建 Markdown
|
||||
<span>新建文件</span>
|
||||
{window.mdToPdfDesktop ? <kbd>Ctrl+N</kbd> : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="menu-command"
|
||||
role="menuitem"
|
||||
onClick={() => void handleSaveMarkdown()}
|
||||
>
|
||||
保存 Markdown
|
||||
<span>保存文件</span>
|
||||
<kbd>Ctrl+S</kbd>
|
||||
</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 ? (
|
||||
<>
|
||||
<div className="menu-separator" role="separator" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -1175,20 +1595,31 @@ export function App() {
|
||||
<select
|
||||
aria-label="预览主题"
|
||||
value={themeId}
|
||||
onChange={(event) =>
|
||||
setExportConfig((currentConfig) => ({
|
||||
...currentConfig,
|
||||
themeId: event.target.value
|
||||
}))
|
||||
}
|
||||
onChange={(event) => {
|
||||
const nextTheme = themes.find(
|
||||
(theme) => theme.id === event.target.value
|
||||
);
|
||||
if (nextTheme) {
|
||||
setExportConfig((currentConfig) =>
|
||||
selectTheme(currentConfig, nextTheme)
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{themes.length === 0 ? (
|
||||
<option value={themeId}>正在加载主题…</option>
|
||||
) : (
|
||||
themes.map((theme) => (
|
||||
<option key={theme.id} value={theme.id}>
|
||||
{theme.name}
|
||||
</option>
|
||||
themeGroups.map((group) => (
|
||||
<optgroup
|
||||
key={group.category}
|
||||
label={group.label}
|
||||
>
|
||||
{group.themes.map((theme) => (
|
||||
<option key={theme.id} value={theme.id}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
@@ -1280,11 +1711,28 @@ export function App() {
|
||||
{settingsOpen ? (
|
||||
<ExportSettingsDrawer
|
||||
config={exportConfig}
|
||||
theme={selectedTheme}
|
||||
onChange={setExportConfig}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onReset={() => setExportConfig(resetExportConfig())}
|
||||
/>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
defaultExportConfig,
|
||||
getPaperDimensionsMm,
|
||||
lengthToMillimeters,
|
||||
paperFormatLabels,
|
||||
@@ -12,9 +13,11 @@ import {
|
||||
type MermaidExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ThemeSummary } from "./application-backend";
|
||||
|
||||
interface ExportSettingsDrawerProps {
|
||||
config: ExportConfig;
|
||||
theme: ThemeSummary | undefined;
|
||||
onChange: (config: ExportConfig) => void;
|
||||
onClose: () => void;
|
||||
onReset: () => void;
|
||||
@@ -24,6 +27,9 @@ interface LengthInputProps {
|
||||
label: string;
|
||||
value: string;
|
||||
maximum: number;
|
||||
disabled?: boolean;
|
||||
precision?: number;
|
||||
step?: number;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
@@ -32,6 +38,7 @@ const pageNumberFormatLabels: Record<FooterConfig["format"], string> = {
|
||||
"page-total": "1 / 10",
|
||||
"chinese-page-total": "第 1 页 / 共 10 页",
|
||||
"dash-page": "- 1 -",
|
||||
"official-page": "— 1 —(标准公文)",
|
||||
custom: "自定义模板"
|
||||
};
|
||||
|
||||
@@ -98,6 +105,9 @@ function LengthInput({
|
||||
label,
|
||||
value,
|
||||
maximum,
|
||||
disabled = false,
|
||||
precision = 1,
|
||||
step = 0.1,
|
||||
onChange
|
||||
}: LengthInputProps) {
|
||||
const [draft, setDraft] = useState(
|
||||
@@ -115,7 +125,8 @@ function LengthInput({
|
||||
return;
|
||||
}
|
||||
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));
|
||||
onChange(`${rounded}mm`);
|
||||
}
|
||||
@@ -128,8 +139,9 @@ function LengthInput({
|
||||
type="number"
|
||||
min="0"
|
||||
max={maximum}
|
||||
step="0.1"
|
||||
step={step}
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
onBlur={commit}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
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({
|
||||
value,
|
||||
onChange
|
||||
@@ -208,6 +271,7 @@ function updateHeaderSlot(
|
||||
|
||||
export function ExportSettingsDrawer({
|
||||
config,
|
||||
theme,
|
||||
onChange,
|
||||
onClose,
|
||||
onReset
|
||||
@@ -259,6 +323,7 @@ export function ExportSettingsDrawer({
|
||||
value: string
|
||||
) {
|
||||
updatePaper({
|
||||
marginMode: "custom",
|
||||
margins: {
|
||||
...margins,
|
||||
[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>) {
|
||||
onChange({
|
||||
...config,
|
||||
pageDecorationsMode: "custom",
|
||||
header: {
|
||||
...config.header,
|
||||
...header
|
||||
@@ -279,6 +357,7 @@ export function ExportSettingsDrawer({
|
||||
function updateFooter(footer: Partial<FooterConfig>) {
|
||||
onChange({
|
||||
...config,
|
||||
pageDecorationsMode: "custom",
|
||||
footer: {
|
||||
...config.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>) {
|
||||
onChange({
|
||||
...config,
|
||||
@@ -374,6 +466,34 @@ export function ExportSettingsDrawer({
|
||||
|
||||
<section className="settings-section">
|
||||
<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">
|
||||
<LengthInput
|
||||
label="上"
|
||||
@@ -416,6 +536,15 @@ export function ExportSettingsDrawer({
|
||||
onChange={(value) => updateMargin("left", value)}
|
||||
/>
|
||||
</div>
|
||||
{config.paper.marginMode === "custom" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action margin-reset-action"
|
||||
onClick={followThemeMargins}
|
||||
>
|
||||
恢复主题推荐值
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
@@ -485,6 +614,53 @@ export function ExportSettingsDrawer({
|
||||
</p>
|
||||
</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">
|
||||
<div className="setting-section-title">
|
||||
<h3>页眉</h3>
|
||||
@@ -502,6 +678,21 @@ export function ExportSettingsDrawer({
|
||||
<p className="setting-hint">
|
||||
支持 {"${title}"}、{"${author}"} 和 {"${filename}"}。
|
||||
</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) => {
|
||||
const labels = { left: "左侧", center: "中间", right: "右侧" };
|
||||
return (
|
||||
@@ -567,6 +758,21 @@ export function ExportSettingsDrawer({
|
||||
<span>{config.footer.enabled ? "开启" : "关闭"}</span>
|
||||
</label>
|
||||
</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">
|
||||
<span>样式</span>
|
||||
<select
|
||||
@@ -616,6 +822,7 @@ export function ExportSettingsDrawer({
|
||||
<option value="left">左侧</option>
|
||||
<option value="center">居中</option>
|
||||
<option value="right">右侧</option>
|
||||
<option value="outer">外侧(奇数页右、偶数页左)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="setting-field">
|
||||
@@ -636,6 +843,19 @@ export function ExportSettingsDrawer({
|
||||
}
|
||||
/>
|
||||
</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">
|
||||
<input
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
category: ThemeCategory;
|
||||
compatibleProfiles: DocumentProfileName[];
|
||||
pageDefaults?: {
|
||||
margins: PageMargins;
|
||||
header?: HeaderConfig;
|
||||
footer?: FooterConfig;
|
||||
};
|
||||
bundled: boolean;
|
||||
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
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v3";
|
||||
export const LEGACY_EXPORT_CONFIG_STORAGE_KEY =
|
||||
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v4";
|
||||
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";
|
||||
|
||||
type SettingsStorage = Pick<Storage, "getItem" | "setItem">;
|
||||
@@ -14,6 +16,119 @@ function cloneDefaultExportConfig() {
|
||||
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 {
|
||||
if (!rawValue) {
|
||||
return cloneDefaultExportConfig();
|
||||
@@ -21,17 +136,7 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
|
||||
|
||||
try {
|
||||
const value: unknown = JSON.parse(rawValue);
|
||||
const migratedValue =
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"version" in value &&
|
||||
value.version === 2
|
||||
? {
|
||||
...value,
|
||||
version: defaultExportConfig.version,
|
||||
mermaid: structuredClone(defaultExportConfig.mermaid)
|
||||
}
|
||||
: value;
|
||||
const migratedValue = migrateStoredExportConfig(value);
|
||||
const parsed = exportConfigSchema.safeParse(migratedValue);
|
||||
return parsed.success ? parsed.data : cloneDefaultExportConfig();
|
||||
} catch {
|
||||
@@ -47,7 +152,9 @@ export function loadExportConfig(
|
||||
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);
|
||||
if (legacyValue) {
|
||||
storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config));
|
||||
|
||||
@@ -188,6 +188,72 @@ h1 {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
|
||||
@@ -749,6 +815,59 @@ textarea:focus {
|
||||
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 {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
@@ -927,6 +1046,19 @@ textarea:focus {
|
||||
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 {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
@@ -1133,4 +1265,31 @@ textarea:focus {
|
||||
.settings-drawer {
|
||||
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 {
|
||||
PagedDocumentPayload,
|
||||
PagedDocumentRenderResult,
|
||||
RenderedMarkdownDocument
|
||||
RenderedMarkdownDocument,
|
||||
ThemeCategory,
|
||||
DocumentProfileName
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
declare global {
|
||||
@@ -36,12 +38,19 @@ declare global {
|
||||
listener: (reason: "open" | "reload" | "replace") => void
|
||||
): () => void;
|
||||
startNewMarkdown(): Promise<void>;
|
||||
createNewWindow(): Promise<void>;
|
||||
resolveWindowClose(close: boolean): Promise<void>;
|
||||
onWindowCloseRequested(listener: () => void): () => void;
|
||||
setDocumentDirty(dirty: boolean): Promise<void>;
|
||||
openDocumentLink(href: string): Promise<void>;
|
||||
saveMarkdown(
|
||||
fileName: string,
|
||||
markdown: string
|
||||
): Promise<{ fileName: string } | undefined>;
|
||||
saveMarkdownAs(
|
||||
fileName: string,
|
||||
markdown: string
|
||||
): Promise<{ fileName: string } | undefined>;
|
||||
openThemeDirectory(): Promise<string>;
|
||||
refreshThemes(): Promise<void>;
|
||||
renderMarkdown(input: {
|
||||
@@ -58,6 +67,8 @@ declare global {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
category: ThemeCategory;
|
||||
compatibleProfiles: DocumentProfileName[];
|
||||
bundled: boolean;
|
||||
source: "bundled" | "local";
|
||||
}>;
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
APP_VERSION,
|
||||
APP_VERSION_LABEL
|
||||
} from "../src/app-version";
|
||||
|
||||
const webRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
|
||||
describe("应用版本", () => {
|
||||
it("从统一构建版本生成标题徽标", () => {
|
||||
expect(APP_VERSION).toBe("0.5.0");
|
||||
expect(APP_VERSION_LABEL).toBe("v0.5.0");
|
||||
expect(APP_VERSION).toBe("0.5.1");
|
||||
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 {
|
||||
EXPORT_CONFIG_STORAGE_KEY,
|
||||
LEGACY_EXPORT_CONFIG_STORAGE_KEY,
|
||||
LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY,
|
||||
LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY,
|
||||
loadExportConfig,
|
||||
parseStoredExportConfig,
|
||||
saveExportConfig
|
||||
@@ -10,14 +11,15 @@ import {
|
||||
|
||||
function createMemoryStorage(
|
||||
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>();
|
||||
if (initialValue) {
|
||||
values.set(EXPORT_CONFIG_STORAGE_KEY, initialValue);
|
||||
}
|
||||
if (legacyValue) {
|
||||
values.set(LEGACY_EXPORT_CONFIG_STORAGE_KEY, legacyValue);
|
||||
values.set(legacyKey, legacyValue);
|
||||
}
|
||||
return {
|
||||
getItem(key: string) {
|
||||
@@ -52,26 +54,106 @@ describe("导出设置缓存", () => {
|
||||
).toEqual(defaultExportConfig);
|
||||
});
|
||||
|
||||
it("将 v2 缓存迁移为 v3 并保留已有设置", () => {
|
||||
it("将 v2 缓存迁移为 v4 并让默认页边距跟随主题", () => {
|
||||
const legacyConfig = {
|
||||
...defaultExportConfig,
|
||||
version: 2,
|
||||
mermaid: undefined,
|
||||
paper: {
|
||||
...defaultExportConfig.paper,
|
||||
marginMode: undefined,
|
||||
format: "A5"
|
||||
}
|
||||
};
|
||||
const storage = createMemoryStorage(
|
||||
null,
|
||||
JSON.stringify(legacyConfig)
|
||||
JSON.stringify(legacyConfig),
|
||||
LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY
|
||||
);
|
||||
|
||||
const migrated = loadExportConfig(storage);
|
||||
expect(migrated.version).toBe(3);
|
||||
expect(migrated.version).toBe(4);
|
||||
expect(migrated.paper.format).toBe("A5");
|
||||
expect(migrated.paper.marginMode).toBe("theme");
|
||||
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("保存并恢复经过校验的配置", () => {
|
||||
|
||||
@@ -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 packages ./packages
|
||||
COPY logos ./logos
|
||||
COPY samples ./samples
|
||||
RUN npm run build -w @md-to-pdf/markdown-echarts \
|
||||
&& npm run build -w @md-to-pdf/core \
|
||||
&& npm run build -w @md-to-pdf/renderer \
|
||||
@@ -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/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/samples ./samples
|
||||
COPY --chown=pwuser:pwuser themes ./themes
|
||||
COPY deploy/nginx.conf /etc/nginx/nginx.conf
|
||||
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
|
||||
$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
|
||||
```
|
||||
|
||||
@@ -47,13 +47,13 @@ docker compose -f deploy\compose.yaml build
|
||||
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
|
||||
|
||||
```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_REUSE_PLAYWRIGHT_RUNTIME = '1'
|
||||
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`。主题规范详见
|
||||
[`docs/THEMES.md`](../docs/THEMES.md)。
|
||||
|
||||
当前内置 Typora Github、Typora Pixyll、Typora whitey 和
|
||||
Typora Clean 四套主题;挂载目录只用于额外的本机扩展主题,无需重复
|
||||
当前内置 4 套 Typora 风格主题、4 套红头主题、3 套正式文档主题和
|
||||
3 套标书主题;挂载目录只用于额外的本机扩展主题,无需重复
|
||||
放置内置主题。内置主题与挂载主题 ID 重复时以内置版本为准。
|
||||
|
||||
## 运行参数
|
||||
|
||||
+75
-1
@@ -1,6 +1,6 @@
|
||||
# Markdown PDF 导出器进度
|
||||
|
||||
最后更新:2026-07-28
|
||||
最后更新:2026-07-29
|
||||
|
||||
## 1. 当前概况
|
||||
|
||||
@@ -55,6 +55,12 @@ Web/Compose 发布仍保持 `v0.4.1`。
|
||||
聚焦时外部文件变化提示。四套主题统一命名,并修复围栏代码块的 Typora
|
||||
DOM 兼容和桌面发行包复用陈旧 Web 产物的问题。
|
||||
|
||||
`v0.5.1` 正式版新增 10 套政企与标书主题、规范页边距和页面装饰、
|
||||
结构化公文 Front Matter、内置中文字体、教程及 14 份主题示例;同时
|
||||
重组“更多”菜单,补齐桌面文件快捷键、未保存确认和跟随新路径的另存为,
|
||||
并修复红头标题居中、代码块自动换行、左侧内容留白和跨页分页。Windows
|
||||
安装包、Docker Compose、本机安装与发布验证均已完成。
|
||||
|
||||
## 2. 已完成
|
||||
|
||||
### 2.1 项目骨架
|
||||
@@ -507,8 +513,76 @@ DOM 兼容和桌面发行包复用陈旧 Web 产物的问题。
|
||||
`package` 和 `make` 强制先调用该脚本,防止安装包复制陈旧
|
||||
`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. 已执行验证
|
||||
|
||||
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 在当前完整工作区成功执行:
|
||||
|
||||
```text
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "md-to-pdf",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "md-to-pdf",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "@md-to-pdf/desktop",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"devDependencies": {
|
||||
"@md-to-pdf/application": "0.4.1",
|
||||
"@types/node": "^24.10.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "md-to-pdf",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"description": "可配置、可主题化的 Markdown PDF 导出工具",
|
||||
"workspaces": [
|
||||
|
||||
@@ -104,6 +104,9 @@ export function createApplicationService(
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
category: manifest.category,
|
||||
compatibleProfiles: manifest.compatibleProfiles,
|
||||
pageDefaults: manifest.pageDefaults,
|
||||
bundled: manifest.bundled,
|
||||
source
|
||||
}))
|
||||
|
||||
@@ -46,6 +46,8 @@ const assetContentTypes: Record<string, string> = {
|
||||
const cssImportPattern =
|
||||
/@import\s+(?:url\(\s*(?:\"([^\"]+)\"|'([^']+)'|([^'\"\s)]+))\s*\)|\"([^\"]+)\"|'([^']+)')\s*;/gi;
|
||||
const maximumCssImportDepth = 8;
|
||||
const sharedThemeAssetId = "_shared";
|
||||
const sharedThemeAssetScheme = "theme-shared:";
|
||||
|
||||
function isMissingFileError(error: unknown) {
|
||||
return (
|
||||
@@ -110,7 +112,7 @@ async function readThemeRoot(
|
||||
|
||||
const records: ThemeRecord[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith("_")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -165,7 +167,8 @@ function prepareCssSegment(
|
||||
css: string,
|
||||
themeId: string,
|
||||
cssDirectory: string,
|
||||
createAssetUrl: (themeId: string, assetPath: string) => string
|
||||
createAssetUrl: (themeId: string, assetPath: string) => string,
|
||||
allowSharedAssets: boolean
|
||||
) {
|
||||
return css.replace(
|
||||
/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi,
|
||||
@@ -174,6 +177,18 @@ function prepareCssSegment(
|
||||
if (value.startsWith("data:") || value.startsWith("#")) {
|
||||
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 (
|
||||
value.startsWith("/") ||
|
||||
value.startsWith("//") ||
|
||||
@@ -250,7 +265,8 @@ async function loadThemeCssFile(
|
||||
css.slice(previousEnd, matchStart),
|
||||
theme.manifest.id,
|
||||
cssDirectory,
|
||||
createAssetUrl
|
||||
createAssetUrl,
|
||||
theme.source === "bundled"
|
||||
);
|
||||
|
||||
const importedPath = readImportedPath(match);
|
||||
@@ -273,7 +289,8 @@ async function loadThemeCssFile(
|
||||
remainder,
|
||||
theme.manifest.id,
|
||||
cssDirectory,
|
||||
createAssetUrl
|
||||
createAssetUrl,
|
||||
theme.source === "bundled"
|
||||
);
|
||||
if (/@import\b/i.test(result)) {
|
||||
throw new Error("主题包含不支持的 CSS @import 语法");
|
||||
@@ -378,6 +395,20 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
|
||||
}
|
||||
|
||||
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);
|
||||
if (!theme) {
|
||||
return undefined;
|
||||
|
||||
@@ -37,7 +37,13 @@ async function createThemeFixture() {
|
||||
const bundledRoot = join(temporaryDirectory, "bundled");
|
||||
const localRoot = join(temporaryDirectory, "local");
|
||||
const themeRoot = join(bundledRoot, "test-theme");
|
||||
const sharedFontRoot = join(
|
||||
bundledRoot,
|
||||
"_shared",
|
||||
"official-fonts"
|
||||
);
|
||||
await mkdir(join(themeRoot, "fonts"), { recursive: true });
|
||||
await mkdir(sharedFontRoot, { recursive: true });
|
||||
await mkdir(localRoot, { recursive: true });
|
||||
await writeFile(
|
||||
join(themeRoot, "theme.json"),
|
||||
@@ -53,6 +59,14 @@ async function createThemeFixture() {
|
||||
domPreset: "typora",
|
||||
defaultFontSize: "16px",
|
||||
supportedFeatures: ["code", "table"],
|
||||
pageDefaults: {
|
||||
margins: {
|
||||
top: "37mm",
|
||||
right: "26mm",
|
||||
bottom: "35mm",
|
||||
left: "28mm"
|
||||
}
|
||||
},
|
||||
bundled: true
|
||||
}),
|
||||
"utf8"
|
||||
@@ -64,6 +78,10 @@ async function createThemeFixture() {
|
||||
" font-family: Test;",
|
||||
" 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; }"
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
@@ -72,6 +90,10 @@ async function createThemeFixture() {
|
||||
join(themeRoot, "fonts", "test.woff2"),
|
||||
Buffer.from([0, 1, 2, 3])
|
||||
);
|
||||
await writeFile(
|
||||
join(sharedFontRoot, "shared.woff2"),
|
||||
Buffer.from([4, 5, 6, 7])
|
||||
);
|
||||
return { bundledRoot, localRoot };
|
||||
}
|
||||
|
||||
@@ -147,6 +169,16 @@ describe("共享应用服务", () => {
|
||||
themes: [
|
||||
expect.objectContaining({
|
||||
id: "test-theme",
|
||||
category: "general",
|
||||
compatibleProfiles: [],
|
||||
pageDefaults: {
|
||||
margins: {
|
||||
top: "37mm",
|
||||
right: "26mm",
|
||||
bottom: "35mm",
|
||||
left: "28mm"
|
||||
}
|
||||
},
|
||||
bundled: true,
|
||||
source: "bundled"
|
||||
})
|
||||
@@ -157,6 +189,11 @@ describe("共享应用服务", () => {
|
||||
).resolves.toContain(
|
||||
"mdpdf://theme/test-theme/fonts/test.woff2"
|
||||
);
|
||||
await expect(
|
||||
service.getThemeCss("test-theme")
|
||||
).resolves.toContain(
|
||||
"mdpdf://theme/_shared/official-fonts/shared.woff2"
|
||||
);
|
||||
});
|
||||
|
||||
it("安全读取主题二进制资源", async () => {
|
||||
@@ -172,6 +209,18 @@ describe("共享应用服务", () => {
|
||||
await expect(
|
||||
service.getThemeAsset("test-theme", "../test.woff2")
|
||||
).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 () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
@@ -6,10 +6,65 @@ const bundledThemeNames = {
|
||||
"typora-github": "Typora Github",
|
||||
"typora-pixyll": "Typora Pixyll",
|
||||
"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;
|
||||
|
||||
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 () => {
|
||||
const themesRoot = fileURLToPath(
|
||||
new URL("../../../themes/", import.meta.url)
|
||||
@@ -27,4 +82,175 @@ describe("内置主题清单", () => {
|
||||
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 { DocumentProfile } from "./document-profile.js";
|
||||
import type { ThemeFeature } from "./theme.js";
|
||||
|
||||
export interface MarkdownDocumentMetadata {
|
||||
@@ -7,6 +8,7 @@ export interface MarkdownDocumentMetadata {
|
||||
subject: string;
|
||||
keywords: string[];
|
||||
language: string;
|
||||
document?: DocumentProfile;
|
||||
}
|
||||
|
||||
export interface RenderedMarkdownDocument {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 PDF_POINTS_PER_INCH = 72;
|
||||
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;
|
||||
}
|
||||
|
||||
const marginSchema = z.object({
|
||||
export const pageMarginsSchema = z.object({
|
||||
top: lengthSchema,
|
||||
right: lengthSchema,
|
||||
bottom: 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(),
|
||||
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(),
|
||||
height: lengthSchema,
|
||||
showDivider: z.boolean(),
|
||||
fontSize: lengthSchema,
|
||||
fontFamily: pageDecorationFontFamilySchema.default(
|
||||
'"Segoe UI", "Microsoft YaHei", sans-serif'
|
||||
),
|
||||
color: z.string(),
|
||||
left: headerSlotSchema,
|
||||
center: headerSlotSchema,
|
||||
@@ -114,26 +130,39 @@ export const pageNumberFormatSchema = z.enum([
|
||||
"page-total",
|
||||
"chinese-page-total",
|
||||
"dash-page",
|
||||
"official-page",
|
||||
"custom"
|
||||
]);
|
||||
|
||||
const footerSchema = z.object({
|
||||
export const footerAlignmentSchema = z.enum([
|
||||
"left",
|
||||
"center",
|
||||
"right",
|
||||
"outer"
|
||||
]);
|
||||
|
||||
export const footerSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
height: lengthSchema,
|
||||
showDivider: z.boolean(),
|
||||
fontSize: lengthSchema,
|
||||
fontFamily: pageDecorationFontFamilySchema.default(
|
||||
'"Segoe UI", "Microsoft YaHei", sans-serif'
|
||||
),
|
||||
color: z.string(),
|
||||
alignment: z.enum(["left", "center", "right"]),
|
||||
alignment: footerAlignmentSchema,
|
||||
format: pageNumberFormatSchema,
|
||||
template: z.string().max(500).optional(),
|
||||
startFrom: z.number().int().positive()
|
||||
startFrom: z.number().int().positive(),
|
||||
showOnFirstPage: z.boolean().default(true)
|
||||
});
|
||||
|
||||
const paperSchema = z
|
||||
.object({
|
||||
format: z.enum(supportedPaperFormats),
|
||||
orientation: z.enum(["portrait", "landscape"]),
|
||||
margins: marginSchema
|
||||
marginMode: marginModeSchema,
|
||||
margins: pageMarginsSchema
|
||||
})
|
||||
.superRefine((paper, context) => {
|
||||
const sourceDimensions = paperDimensionsMm[paper.format];
|
||||
@@ -178,6 +207,7 @@ export const exportConfigSchema = z.object({
|
||||
version: z.literal(EXPORT_CONFIG_VERSION),
|
||||
name: z.string().min(1).max(100),
|
||||
themeId: z.string().min(1),
|
||||
pageDecorationsMode: pageDecorationsModeSchema.default("theme"),
|
||||
mermaid: mermaidConfigSchema,
|
||||
paper: paperSchema,
|
||||
header: headerSchema,
|
||||
@@ -200,9 +230,30 @@ export type ExportConfig = z.infer<typeof exportConfigSchema>;
|
||||
export type MermaidExportConfig = ExportConfig["mermaid"];
|
||||
export type PaperFormat = ExportConfig["paper"]["format"];
|
||||
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 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(
|
||||
format: PaperFormat,
|
||||
orientation: PaperOrientation
|
||||
@@ -222,6 +273,7 @@ export const defaultExportConfig: ExportConfig = {
|
||||
version: EXPORT_CONFIG_VERSION,
|
||||
name: "默认 A4 文档",
|
||||
themeId: "typora-github",
|
||||
pageDecorationsMode: "theme",
|
||||
mermaid: {
|
||||
layout: "dagre",
|
||||
theme: "default",
|
||||
@@ -231,18 +283,15 @@ export const defaultExportConfig: ExportConfig = {
|
||||
paper: {
|
||||
format: "A4",
|
||||
orientation: "portrait",
|
||||
margins: {
|
||||
top: "16mm",
|
||||
right: "16mm",
|
||||
bottom: "16mm",
|
||||
left: "16mm"
|
||||
}
|
||||
marginMode: "theme",
|
||||
margins: { ...defaultPageMargins }
|
||||
},
|
||||
header: {
|
||||
enabled: false,
|
||||
height: "8mm",
|
||||
showDivider: false,
|
||||
fontSize: "3mm",
|
||||
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
|
||||
color: "#6b7280",
|
||||
left: disabledHeaderSlot,
|
||||
center: disabledHeaderSlot,
|
||||
@@ -253,10 +302,12 @@ export const defaultExportConfig: ExportConfig = {
|
||||
height: "8mm",
|
||||
showDivider: false,
|
||||
fontSize: "3mm",
|
||||
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
|
||||
color: "#6b7280",
|
||||
alignment: "center",
|
||||
format: "page-total",
|
||||
startFrom: 1
|
||||
startFrom: 1,
|
||||
showOnFirstPage: true
|
||||
},
|
||||
metadata: {
|
||||
title: "",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./document.js";
|
||||
export * from "./document-profile.js";
|
||||
export * from "./document-link.js";
|
||||
export * from "./export-config.js";
|
||||
export * from "./theme.js";
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { documentProfileNameSchema } from "./document-profile.js";
|
||||
import {
|
||||
footerSchema,
|
||||
headerSchema,
|
||||
pageMarginsSchema
|
||||
} from "./export-config.js";
|
||||
|
||||
export const THEME_MANIFEST_VERSION = 1;
|
||||
|
||||
@@ -12,6 +18,13 @@ export const themeFeatureSchema = z.enum([
|
||||
"echarts"
|
||||
]);
|
||||
|
||||
export const themeCategorySchema = z.enum([
|
||||
"general",
|
||||
"red-letter",
|
||||
"formal",
|
||||
"tender"
|
||||
]);
|
||||
|
||||
export const themeManifestSchema = z.object({
|
||||
manifestVersion: z.literal(THEME_MANIFEST_VERSION),
|
||||
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"]),
|
||||
defaultFontSize: z.string(),
|
||||
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()
|
||||
});
|
||||
|
||||
export type ThemeManifest = z.infer<typeof themeManifestSchema>;
|
||||
export type ThemeFeature = z.infer<typeof themeFeatureSchema>;
|
||||
export type ThemeCategory = z.infer<typeof themeCategorySchema>;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
millimetersToCssPixels,
|
||||
millimetersToPdfPoints,
|
||||
paperDimensionsMm,
|
||||
resolvePageMargins,
|
||||
supportedMermaidLayouts,
|
||||
supportedMermaidLooks,
|
||||
supportedMermaidThemes,
|
||||
@@ -33,9 +34,11 @@ describe("导出配置", () => {
|
||||
|
||||
it("使用 A4 和 16mm 作为默认纸张配置", () => {
|
||||
expect(defaultExportConfig.themeId).toBe("typora-github");
|
||||
expect(defaultExportConfig.pageDecorationsMode).toBe("theme");
|
||||
expect(defaultExportConfig.paper).toEqual({
|
||||
format: "A4",
|
||||
orientation: "portrait",
|
||||
marginMode: "theme",
|
||||
margins: {
|
||||
top: "16mm",
|
||||
right: "16mm",
|
||||
@@ -46,6 +49,103 @@ describe("导出配置", () => {
|
||||
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 官方布局、主题、外观和字体默认值", () => {
|
||||
expect(supportedMermaidLayouts).toEqual(["dagre", "elk"]);
|
||||
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,
|
||||
mountContinuousRenderStage
|
||||
} from "./continuous-preview.js";
|
||||
import { prepareCodeBlockPagination } from "./code-block-pagination.js";
|
||||
import { fitOversizedEChartsToPage } from "./echarts-page-fit.js";
|
||||
import {
|
||||
fitDocumentImagesToPage,
|
||||
@@ -44,6 +45,8 @@ import {
|
||||
documentGeometryCss,
|
||||
documentInteractionCss,
|
||||
formatPageNumber,
|
||||
resolvePageNumberAlignment,
|
||||
shouldRenderPageNumber,
|
||||
type PagedPreviewPayload
|
||||
} from "./paged-preview.js";
|
||||
import type { PagedRenderTarget } from "./paged-render-target.js";
|
||||
@@ -156,12 +159,23 @@ function applyPageNumbers(
|
||||
return;
|
||||
}
|
||||
|
||||
const alignment = payload.exportConfig.footer.alignment;
|
||||
const pages = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(".pagedjs_page")
|
||||
);
|
||||
|
||||
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>(
|
||||
`.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content`
|
||||
);
|
||||
@@ -640,6 +654,7 @@ export class PagedDocumentRuntime {
|
||||
this.resetPagedState();
|
||||
this.root.replaceChildren();
|
||||
}
|
||||
prepareCodeBlockPagination(nextArticle);
|
||||
const setupMs = performance.now() - setupStartedAt;
|
||||
|
||||
const mermaidStartedAt = performance.now();
|
||||
|
||||
@@ -75,10 +75,22 @@ svg {
|
||||
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;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
padding-left: 8px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
@@ -87,6 +99,9 @@ svg {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
white-space: inherit;
|
||||
overflow-wrap: inherit;
|
||||
word-break: inherit;
|
||||
}
|
||||
|
||||
.md-document-image-block {
|
||||
@@ -250,6 +265,9 @@ export function formatPageNumber(
|
||||
if (config.format === "dash-page") {
|
||||
return `- ${page} -`;
|
||||
}
|
||||
if (config.format === "official-page") {
|
||||
return `— ${page} —`;
|
||||
}
|
||||
|
||||
return (config.template || "${page} / ${pages}")
|
||||
.replace(/\$\{page\}/g, String(page))
|
||||
@@ -262,6 +280,7 @@ function marginBox(
|
||||
content: string,
|
||||
options: {
|
||||
color: string;
|
||||
fontFamily: string;
|
||||
fontSize: string;
|
||||
height: string;
|
||||
showDivider: boolean;
|
||||
@@ -280,7 +299,7 @@ function marginBox(
|
||||
content: ${content};
|
||||
height: ${options.height};
|
||||
color: ${options.color};
|
||||
font-family: inherit;
|
||||
font-family: ${options.fontFamily};
|
||||
font-size: ${options.fontSize};
|
||||
font-weight: 400;
|
||||
line-height: 1.25;
|
||||
@@ -300,6 +319,7 @@ function buildHeaderCss(
|
||||
|
||||
const options = {
|
||||
color: config.header.color,
|
||||
fontFamily: config.header.fontFamily,
|
||||
fontSize: config.header.fontSize,
|
||||
height: config.header.height,
|
||||
showDivider: config.header.showDivider
|
||||
@@ -328,6 +348,7 @@ function buildFooterCss(config: ExportConfig) {
|
||||
|
||||
const options = {
|
||||
color: config.footer.color,
|
||||
fontFamily: config.footer.fontFamily,
|
||||
fontSize: config.footer.fontSize,
|
||||
height: config.footer.height,
|
||||
showDivider: config.footer.showDivider
|
||||
@@ -347,6 +368,23 @@ function buildFooterCss(config: ExportConfig) {
|
||||
.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(
|
||||
config: ExportConfig,
|
||||
payload: Pick<PagedPreviewPayload, "fileName" | "metadata">
|
||||
@@ -398,6 +436,47 @@ ${buildFooterCss(config)}
|
||||
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"] {
|
||||
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,
|
||||
isPagedPreviewRenderRequest,
|
||||
PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
resolvePageNumberAlignment,
|
||||
shouldRenderPageNumber,
|
||||
type PagedPreviewPayload
|
||||
} from "../src/paged-preview.js";
|
||||
|
||||
@@ -36,6 +38,9 @@ describe("分页预览协议", () => {
|
||||
expect(css).toContain("size: 210mm 297mm");
|
||||
expect(css).toContain("margin: 16mm 16mm");
|
||||
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).toContain("#write thead");
|
||||
expect(css).toContain("display: table-header-group");
|
||||
@@ -149,6 +154,36 @@ describe("分页预览协议", () => {
|
||||
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 之后强制分页画布保持透明", () => {
|
||||
@@ -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("以低优先级兜底样式保留链接识别和焦点反馈", () => {
|
||||
expect(documentInteractionCss).toContain(":where(#write a[href])");
|
||||
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 { tasklist } from "@mdit/plugin-tasklist";
|
||||
import type {
|
||||
DocumentProfile,
|
||||
MarkdownDocumentMetadata,
|
||||
RenderedMarkdownDocument,
|
||||
ThemeFeature
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
classifyDocumentLink,
|
||||
documentProfileSchema,
|
||||
isSafeDocumentLink
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
@@ -22,6 +24,7 @@ import type { Options as MarkdownItOptions } from "markdown-it";
|
||||
import type Renderer from "markdown-it/lib/renderer.mjs";
|
||||
import type Token from "markdown-it/lib/token.mjs";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import { renderDocumentStructure } from "./render-document-structure.js";
|
||||
|
||||
export const RENDERER_VERSION = 1;
|
||||
|
||||
@@ -350,7 +353,11 @@ export function renderMarkdown(
|
||||
options.language ?? "zh-CN",
|
||||
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 {
|
||||
rendererVersion: RENDERER_VERSION,
|
||||
@@ -405,10 +412,30 @@ function normalizeMetadata(
|
||||
author: readString(data.author),
|
||||
subject: readString(data.subject) || readString(data.description),
|
||||
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 {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
|
||||
@@ -106,6 +106,122 @@ title: [未闭合
|
||||
).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 安全占位", () => {
|
||||
const result = renderMarkdown(`
|
||||
内联公式 $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