dsh-thesis 2.0.2 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/docs/config.md +12 -0
- package/docs/design.md +95 -2
- package/docs/release.md +94 -85
- package/docs/tools.md +8 -5
- package/docs/troubleshooting.md +18 -0
- package/docs/usage.md +4 -1
- package/lib/config.d.ts +13 -0
- package/lib/config.js +8 -1
- package/lib/figure/actions.d.ts +83 -0
- package/lib/figure/actions.js +439 -0
- package/lib/figure/index.d.ts +24 -0
- package/lib/figure/index.js +40 -0
- package/lib/figure/lib/check.d.ts +83 -0
- package/lib/figure/lib/check.js +214 -0
- package/lib/figure/lib/drawio.d.ts +123 -0
- package/lib/figure/lib/drawio.js +156 -0
- package/lib/figure/lib/export.d.ts +120 -0
- package/lib/figure/lib/export.js +308 -0
- package/lib/figure/lib/layout.d.ts +78 -0
- package/lib/figure/lib/layout.js +496 -0
- package/lib/figure/lib/spec.d.ts +114 -0
- package/lib/figure/lib/spec.js +290 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +5 -1
- package/lib/paper/lib/check.d.ts +5 -0
- package/lib/paper/lib/check.js +21 -0
- package/lib/paper/lib/docx.d.ts +26 -0
- package/lib/paper/lib/docx.js +190 -27
- package/lib/paper/lib/image.d.ts +19 -0
- package/lib/paper/lib/image.js +82 -0
- package/lib/paper/lib/layout.js +26 -1
- package/lib/paper/lib/lit-refs.d.ts +30 -0
- package/lib/paper/lib/lit-refs.js +394 -0
- package/lib/paper/lib/lit-verify.d.ts +79 -0
- package/lib/paper/lib/lit-verify.js +556 -0
- package/lib/paper/register.d.ts +1 -1
- package/lib/paper/register.js +37 -1
- package/lib/paper/tools/build.d.ts +4 -0
- package/lib/paper/tools/build.js +79 -4
- package/lib/paper/tools/lit-verify.d.ts +35 -0
- package/lib/paper/tools/lit-verify.js +193 -0
- package/lib/ppt/check.js +5 -0
- package/lib/ppt/design.d.ts +86 -0
- package/lib/ppt/design.js +218 -0
- package/package.json +4 -1
- package/skills/thesis-defense/SKILL.md +25 -1
- package/skills/thesis-eng-design/SKILL.md +40 -20
- package/skills/thesis-literature/SKILL.md +28 -5
- package/skills/thesis-opener/SKILL.md +1 -1
- package/skills/thesis-pipeline/SKILL.md +4 -3
- package/skills/thesis-writing/SKILL.md +4 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图表体检:源文件、导出件、正文引用、图号四者对不上时,在这里被抓出来。
|
|
3
|
+
*
|
|
4
|
+
* 论文里最容易出错、又最丢分的一类细节是「图」:
|
|
5
|
+
*
|
|
6
|
+
* - 图改了忘导出,论文里还是旧图;
|
|
7
|
+
* - 图在 assets 里躺着,正文从没引用(或者引用了但文件路径断了);
|
|
8
|
+
* - 图题写成「系统架构图」而不是「图 4-1 系统总体架构图」,编号直接断档;
|
|
9
|
+
* - 第 4 章的图编号成 图 3-1。
|
|
10
|
+
*
|
|
11
|
+
* 这些都是**可机器判定**的,所以不靠人肉检查:{@link checkFigures} 只吃已经准备好的
|
|
12
|
+
* 清单(不碰文件系统,方便单测),返回问题列表;{@link renderFigureReport} 渲染成
|
|
13
|
+
* `06-论文/assets/图/图表清单.md`。
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-thesis/figure/check
|
|
16
|
+
*/
|
|
17
|
+
/** 问题等级:error 会让论文明图不成;warn 是「能过但会被老师问」。 */
|
|
18
|
+
export type FigureSeverity = 'error' | 'warn';
|
|
19
|
+
/** 一条问题。 */
|
|
20
|
+
export interface FigureIssue {
|
|
21
|
+
readonly severity: FigureSeverity;
|
|
22
|
+
readonly code: string;
|
|
23
|
+
readonly message: string;
|
|
24
|
+
}
|
|
25
|
+
/** 图文件(源文件与导出件共用)。 */
|
|
26
|
+
export interface FigureFileEntry {
|
|
27
|
+
/** 相对论文工作区根的路径(正斜杠)。 */
|
|
28
|
+
readonly path: string;
|
|
29
|
+
readonly mtimeMs?: number;
|
|
30
|
+
/** 导出件的像素宽(拿得到时用来判断印刷是否够清晰)。 */
|
|
31
|
+
readonly widthPx?: number;
|
|
32
|
+
}
|
|
33
|
+
/** 正文里的一处图片引用。 */
|
|
34
|
+
export interface FigureReference {
|
|
35
|
+
readonly chapter: string;
|
|
36
|
+
readonly chapterNo: number;
|
|
37
|
+
/** 在章节文件里的行号(1 起)。 */
|
|
38
|
+
readonly line: number;
|
|
39
|
+
/** 形如 `图 4-1 系统总体架构图`;缺图题时为 alt 原文。 */
|
|
40
|
+
readonly alt: string;
|
|
41
|
+
/** 正文里写的路径(原样)。 */
|
|
42
|
+
readonly src: string;
|
|
43
|
+
/** 解析到的绝对/规范路径;解析不到时为 undefined(文件不存在)。 */
|
|
44
|
+
readonly resolvedPath?: string;
|
|
45
|
+
}
|
|
46
|
+
/** 体检输入。 */
|
|
47
|
+
export interface FigureCheckInput {
|
|
48
|
+
readonly sources: readonly FigureFileEntry[];
|
|
49
|
+
readonly images: readonly FigureFileEntry[];
|
|
50
|
+
readonly references: readonly FigureReference[];
|
|
51
|
+
/** 章节正文(用于判断「本章第一张图是 4-3 时,4-2 到底存不存在」)。 */
|
|
52
|
+
readonly chapterTexts?: readonly {
|
|
53
|
+
readonly chapter: string;
|
|
54
|
+
readonly text: string;
|
|
55
|
+
}[];
|
|
56
|
+
}
|
|
57
|
+
/** 汇总里的一张图。 */
|
|
58
|
+
export interface FigureEntry {
|
|
59
|
+
readonly stem: string;
|
|
60
|
+
readonly source?: string;
|
|
61
|
+
readonly image?: string;
|
|
62
|
+
readonly references: readonly string[];
|
|
63
|
+
}
|
|
64
|
+
/** 体检结果。 */
|
|
65
|
+
export interface FigureCheckResult {
|
|
66
|
+
readonly entries: readonly FigureEntry[];
|
|
67
|
+
readonly issues: readonly FigureIssue[];
|
|
68
|
+
readonly errors: number;
|
|
69
|
+
readonly warnings: number;
|
|
70
|
+
}
|
|
71
|
+
/** 规范图题:`图 4-1 系统总体架构图`(章号-序号 + 空格 + 标题)。 */
|
|
72
|
+
export declare const CAPTION_PATTERN: RegExp;
|
|
73
|
+
/** 从图题里取出章号与序号;不是规范图题时返回 undefined。 */
|
|
74
|
+
export declare function parseCaption(text: string): {
|
|
75
|
+
readonly chapter: number;
|
|
76
|
+
readonly index: number;
|
|
77
|
+
} | undefined;
|
|
78
|
+
/** 去掉扩展名,只留文件名主干(用于把 .drawio 与 .png 配对)。 */
|
|
79
|
+
export declare function stemOf(path: string): string;
|
|
80
|
+
/** 体检:纯函数,输入齐全时可以完全离线断言。 */
|
|
81
|
+
export declare function checkFigures(input: FigureCheckInput): FigureCheckResult;
|
|
82
|
+
/** 渲染成 `图表清单.md`(同时给模型看,也随论文提交)。 */
|
|
83
|
+
export declare function renderFigureReport(result: FigureCheckResult, now?: Date): string;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图表体检:源文件、导出件、正文引用、图号四者对不上时,在这里被抓出来。
|
|
3
|
+
*
|
|
4
|
+
* 论文里最容易出错、又最丢分的一类细节是「图」:
|
|
5
|
+
*
|
|
6
|
+
* - 图改了忘导出,论文里还是旧图;
|
|
7
|
+
* - 图在 assets 里躺着,正文从没引用(或者引用了但文件路径断了);
|
|
8
|
+
* - 图题写成「系统架构图」而不是「图 4-1 系统总体架构图」,编号直接断档;
|
|
9
|
+
* - 第 4 章的图编号成 图 3-1。
|
|
10
|
+
*
|
|
11
|
+
* 这些都是**可机器判定**的,所以不靠人肉检查:{@link checkFigures} 只吃已经准备好的
|
|
12
|
+
* 清单(不碰文件系统,方便单测),返回问题列表;{@link renderFigureReport} 渲染成
|
|
13
|
+
* `06-论文/assets/图/图表清单.md`。
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-thesis/figure/check
|
|
16
|
+
*/
|
|
17
|
+
/** 规范图题:`图 4-1 系统总体架构图`(章号-序号 + 空格 + 标题)。 */
|
|
18
|
+
export const CAPTION_PATTERN = /^图\s*(\d+)\s*[--—–]\s*(\d+)\s+\S/;
|
|
19
|
+
/** 从图题里取出章号与序号;不是规范图题时返回 undefined。 */
|
|
20
|
+
export function parseCaption(text) {
|
|
21
|
+
const match = CAPTION_PATTERN.exec(text.trim());
|
|
22
|
+
if (match === null)
|
|
23
|
+
return undefined;
|
|
24
|
+
return { chapter: Number(match[1]), index: Number(match[2]) };
|
|
25
|
+
}
|
|
26
|
+
/** 去掉扩展名,只留文件名主干(用于把 .drawio 与 .png 配对)。 */
|
|
27
|
+
export function stemOf(path) {
|
|
28
|
+
const base = path.replace(/\\/g, '/').split('/').pop() ?? path;
|
|
29
|
+
const dot = base.lastIndexOf('.');
|
|
30
|
+
return dot <= 0 ? base : base.slice(0, dot);
|
|
31
|
+
}
|
|
32
|
+
function dirOf(path) {
|
|
33
|
+
const normalized = path.replace(/\\/g, '/');
|
|
34
|
+
const slash = normalized.lastIndexOf('/');
|
|
35
|
+
return slash < 0 ? '' : normalized.slice(0, slash);
|
|
36
|
+
}
|
|
37
|
+
/** 体检:纯函数,输入齐全时可以完全离线断言。 */
|
|
38
|
+
export function checkFigures(input) {
|
|
39
|
+
const issues = [];
|
|
40
|
+
const push = (severity, code, message) => {
|
|
41
|
+
issues.push({ severity, code, message });
|
|
42
|
+
};
|
|
43
|
+
const imagesByPath = new Map(input.images.map(image => [image.path.replace(/\\/g, '/'), image]));
|
|
44
|
+
const sourcesByStem = new Map();
|
|
45
|
+
for (const source of input.sources) {
|
|
46
|
+
const key = `${dirOf(source.path)}/${stemOf(source.path)}`;
|
|
47
|
+
const bucket = sourcesByStem.get(key) ?? [];
|
|
48
|
+
bucket.push(source);
|
|
49
|
+
sourcesByStem.set(key, bucket);
|
|
50
|
+
}
|
|
51
|
+
// 1. 每个 .drawio 都要有同名的 png/svg 导出件。
|
|
52
|
+
for (const source of input.sources) {
|
|
53
|
+
const dir = dirOf(source.path);
|
|
54
|
+
const stem = stemOf(source.path);
|
|
55
|
+
const candidates = [`${dir}/${stem}.png`, `${dir}/${stem}.svg`].map(p => p.replace(/^\/+/, ''));
|
|
56
|
+
const exported = candidates.map(path => imagesByPath.get(path)).find(image => image !== undefined);
|
|
57
|
+
if (exported === undefined) {
|
|
58
|
+
push('error', 'missing-export', `源文件 ${source.path} 没有导出件(同目录缺 ${stem}.png 或 ${stem}.svg)。运行 thesis_figure action=export 导出后再写正文引用。`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (source.mtimeMs !== undefined && exported.mtimeMs !== undefined && exported.mtimeMs < source.mtimeMs) {
|
|
62
|
+
push('warn', 'stale-export', `${exported.path} 比源文件 ${source.path} 旧:图改过但没重新导出,论文里会显示旧图。重跑 thesis_figure action=export。`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// 1.5 导出件分辨率:按论文 15cm 栏宽折算 dpi,低于 200 dpi 印刷会发虚。
|
|
66
|
+
for (const image of input.images) {
|
|
67
|
+
if (image.widthPx === undefined || image.widthPx >= 1200)
|
|
68
|
+
continue;
|
|
69
|
+
const dpi = Math.round(image.widthPx / 5.9);
|
|
70
|
+
push('warn', 'low-resolution', `${image.path} 只有 ${image.widthPx}px 宽:按 15cm 栏宽排进论文约 ${dpi} dpi,印刷会发虚。用 thesis_figure action=export(3×)重新导出,或换更高分辨率截图。`);
|
|
71
|
+
}
|
|
72
|
+
// 2. 正文引用必须能落到真实文件;alt 必须是规范图题。
|
|
73
|
+
const referenced = new Map();
|
|
74
|
+
const byChapter = new Map();
|
|
75
|
+
for (const reference of input.references) {
|
|
76
|
+
const key = reference.resolvedPath === undefined ? undefined : reference.resolvedPath.replace(/\\/g, '/');
|
|
77
|
+
if (reference.resolvedPath === undefined || !imagesByPath.has(key ?? '')) {
|
|
78
|
+
push('error', 'missing-image', `${reference.chapter}:${reference.line} 引用的图片不存在:${reference.src}。路径相对当前章节文件解析;论文仓库里的图通常写成 ../assets/图/<名字>.png。`);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
const bucket = referenced.get(key) ?? [];
|
|
82
|
+
bucket.push(reference.chapter);
|
|
83
|
+
referenced.set(key, bucket);
|
|
84
|
+
}
|
|
85
|
+
const caption = parseCaption(reference.alt);
|
|
86
|
+
if (caption === undefined) {
|
|
87
|
+
push('error', 'missing-caption', `${reference.chapter}:${reference.line} 的图片缺少规范图题:现在是「${reference.alt === '' ? '(空)' : reference.alt}」,应写成「图 ${reference.chapterNo}-<序号> <图名>」——图题在图下方、编号带章号。`);
|
|
88
|
+
}
|
|
89
|
+
else if (caption.chapter !== reference.chapterNo) {
|
|
90
|
+
push('error', 'caption-chapter', `${reference.chapter}:${reference.line} 的图题章号是 ${caption.chapter},但它在第 ${reference.chapterNo} 章:应为「图 ${reference.chapterNo}-${caption.index} …」。`);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const bucket = byChapter.get(reference.chapter) ?? [];
|
|
94
|
+
bucket.push({ line: reference.line, caption: reference.alt, chapterNo: reference.chapterNo });
|
|
95
|
+
byChapter.set(reference.chapter, bucket);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// 3. 每章图片的图号必须唯一、按出现顺序递增、且相邻(跳号说明中间那张图丢了)。
|
|
99
|
+
// 若本章第一张图不是 4-1,只有正文里确实提到过 4-(n-1) 才放行——
|
|
100
|
+
// 那说明前面还有一张图(例如截图单独放在别处),不算断档。
|
|
101
|
+
const chapterText = new Map((input.chapterTexts ?? []).map(item => [item.chapter, item.text]));
|
|
102
|
+
for (const [chapter, list] of byChapter) {
|
|
103
|
+
const sorted = [...list].sort((a, b) => a.line - b.line);
|
|
104
|
+
const parsed = sorted
|
|
105
|
+
.map(item => ({ item, caption: parseCaption(item.caption) }))
|
|
106
|
+
.filter(entry => entry.caption !== undefined);
|
|
107
|
+
const seen = new Map();
|
|
108
|
+
for (const { item, caption } of parsed) {
|
|
109
|
+
const previous = seen.get(caption.index);
|
|
110
|
+
if (previous !== undefined) {
|
|
111
|
+
push('error', 'caption-duplicate', `${chapter}:${item.line} 与第 ${previous} 行都是「图 ${caption.chapter}-${caption.index}」:同一章图号不能重复。`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
seen.set(caption.index, item.line);
|
|
115
|
+
}
|
|
116
|
+
for (let i = 1; i < parsed.length; i += 1) {
|
|
117
|
+
const previous = parsed[i - 1].caption;
|
|
118
|
+
const current = parsed[i].caption;
|
|
119
|
+
if (current.index === previous.index)
|
|
120
|
+
continue; // 重复已在上面报过,不重复计一次跳号
|
|
121
|
+
if (current.index !== previous.index + 1) {
|
|
122
|
+
push('error', 'caption-sequence', `${chapter}:${parsed[i].item.line} 的图号是 ${current.chapter}-${current.index},上一张是 ${previous.chapter}-${previous.index}:每章图号应连续递增(中间缺 ${current.chapter}-${previous.index + 1})。`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const first = parsed[0]?.caption;
|
|
126
|
+
if (first !== undefined && first.index > 1) {
|
|
127
|
+
const text = chapterText.get(chapter) ?? '';
|
|
128
|
+
const previousIndex = first.index - 1;
|
|
129
|
+
const mentioned = new RegExp(`图\\s*${first.chapter}\\s*[--—–]\\s*${previousIndex}`).test(text);
|
|
130
|
+
if (!mentioned) {
|
|
131
|
+
push('error', 'caption-start', `${chapter} 的图片从 图 ${first.chapter}-${first.index} 开始,缺 图 ${first.chapter}-${previousIndex}:本章第一张图应从 -1 开始,或把那张图补回来。`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// 4. assets 里的图有没有人用;纯截图(无 .drawio 源)只提示不报错。
|
|
136
|
+
for (const image of input.images) {
|
|
137
|
+
const key = image.path.replace(/\\/g, '/');
|
|
138
|
+
if ([...referenced.keys()].some(used => used === key))
|
|
139
|
+
continue;
|
|
140
|
+
const stemKey = `${dirOf(image.path)}/${stemOf(image.path)}`;
|
|
141
|
+
if (sourcesByStem.has(stemKey)) {
|
|
142
|
+
push('warn', 'unused-figure', `${image.path} 没有被任何章节引用:要么在正文补一句「如图 X-Y 所示」并放图,要么删掉这张图。`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// 5. 汇总条目(按源文件优先,其次图片)。
|
|
146
|
+
const entries = [];
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
const addEntry = (stem, source, image) => {
|
|
149
|
+
const key = `${stem}|${source ?? ''}|${image ?? ''}`;
|
|
150
|
+
if (seen.has(key))
|
|
151
|
+
return;
|
|
152
|
+
seen.add(key);
|
|
153
|
+
const refs = [...(image === undefined ? [] : referenced.get(image.replace(/\\/g, '/')) ?? [])];
|
|
154
|
+
entries.push({ stem, ...(source !== undefined ? { source } : {}), ...(image !== undefined ? { image } : {}), references: refs });
|
|
155
|
+
};
|
|
156
|
+
for (const source of input.sources) {
|
|
157
|
+
const dir = dirOf(source.path);
|
|
158
|
+
const stem = stemOf(source.path);
|
|
159
|
+
const candidates = [`${dir}/${stem}.png`, `${dir}/${stem}.svg`].map(p => p.replace(/^\/+/, ''));
|
|
160
|
+
const image = candidates.map(path => imagesByPath.get(path)).find(item => item !== undefined);
|
|
161
|
+
addEntry(stem, source.path, image?.path);
|
|
162
|
+
}
|
|
163
|
+
for (const image of input.images) {
|
|
164
|
+
const stems = [...sourcesByStem.keys()].filter(key => key === `${dirOf(image.path)}/${stemOf(image.path)}`);
|
|
165
|
+
if (stems.length > 0)
|
|
166
|
+
continue;
|
|
167
|
+
addEntry(stemOf(image.path), undefined, image.path);
|
|
168
|
+
}
|
|
169
|
+
entries.sort((a, b) => a.stem.localeCompare(b.stem, 'zh-Hans-CN'));
|
|
170
|
+
return {
|
|
171
|
+
entries,
|
|
172
|
+
issues,
|
|
173
|
+
errors: issues.filter(issue => issue.severity === 'error').length,
|
|
174
|
+
warnings: issues.filter(issue => issue.severity === 'warn').length,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/** 渲染成 `图表清单.md`(同时给模型看,也随论文提交)。 */
|
|
178
|
+
export function renderFigureReport(result, now = new Date()) {
|
|
179
|
+
const lines = [];
|
|
180
|
+
lines.push('# 图表清单');
|
|
181
|
+
lines.push('');
|
|
182
|
+
lines.push(`> 由 thesis_figure action=check 生成;时间:${now.toISOString()}`);
|
|
183
|
+
lines.push('> 源文件(.drawio)是可编辑真相,导出件(.png/.svg)供论文与幻灯使用。');
|
|
184
|
+
lines.push('');
|
|
185
|
+
lines.push('## 一、图的清单');
|
|
186
|
+
lines.push('');
|
|
187
|
+
if (result.entries.length === 0) {
|
|
188
|
+
lines.push('(还没有图。用 thesis_figure action=create 生成第一张。)');
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
lines.push('| 图 | 源文件 | 导出件 | 引用的章节 |');
|
|
192
|
+
lines.push('|---|---|---|---|');
|
|
193
|
+
for (const entry of result.entries) {
|
|
194
|
+
lines.push(`| ${entry.stem} | ${entry.source ?? '(无,截图/外部图)'} | ${entry.image ?? '(未导出)'} | ${entry.references.length === 0 ? '—' : entry.references.join('、')} |`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
lines.push('');
|
|
198
|
+
lines.push('## 二、检查结论');
|
|
199
|
+
lines.push('');
|
|
200
|
+
if (result.issues.length === 0) {
|
|
201
|
+
lines.push('✓ 源文件、导出件、正文引用、图号四者一致。');
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
for (const issue of result.issues) {
|
|
205
|
+
lines.push(`- ${issue.severity === 'error' ? '✗' : '⚠'} ${issue.message}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
lines.push('');
|
|
209
|
+
lines.push(`合计:${result.errors} 个必须修的问题、${result.warnings} 个提示。`);
|
|
210
|
+
lines.push('');
|
|
211
|
+
lines.push('修完重跑 `thesis_figure action=check`;导出用 `thesis_figure action=export`。');
|
|
212
|
+
lines.push('');
|
|
213
|
+
return lines.join('\n');
|
|
214
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* draw.io(`.drawio` / mxfile)渲染:把版面模型写成 draw.io 能打开、学生能继续手工编辑的 XML。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不用 Mermaid:Mermaid 是「文本进、自动布局出」,论文里的架构图、ER 图往往被
|
|
5
|
+
* 排成一张挤在一起的网,字号、配色、线型也由渲染器决定。draw.io 的源文件是**显式坐标**
|
|
6
|
+
* 的矢量图,可以精确控制版面、统一风格、导出高分辨率 PNG,并且改动仍可在 draw.io 里完成。
|
|
7
|
+
* 本模块只做两件事:给出一套论文用的风格常量,把 {@link FigureCell} 渲染成 mxfile。
|
|
8
|
+
*
|
|
9
|
+
* 约束(与仓库其它模块一致):
|
|
10
|
+
* - 零依赖、确定性输出(同样的输入 + 同样的时间,字节完全一致);
|
|
11
|
+
* - 正文里出现的一切用户文本都要转义,标签里的换行以 `<br>` 表达(draw.io 的 html=1);
|
|
12
|
+
* - `custom` 图接受模型手写的 mxfile,但必须过 {@link validateMxfile},不允许脚本与 DOCTYPE。
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-thesis/figure/drawio
|
|
15
|
+
*/
|
|
16
|
+
/** 平面坐标(draw.io 单位:1 单位 ≈ 0.01 英寸,A4 纵向页宽 827)。 */
|
|
17
|
+
export interface Point {
|
|
18
|
+
readonly x: number;
|
|
19
|
+
readonly y: number;
|
|
20
|
+
}
|
|
21
|
+
/** 矩形几何。 */
|
|
22
|
+
export interface Rect {
|
|
23
|
+
readonly x: number;
|
|
24
|
+
readonly y: number;
|
|
25
|
+
readonly width: number;
|
|
26
|
+
readonly height: number;
|
|
27
|
+
}
|
|
28
|
+
/** 一条要落进 mxGraphModel 的图元。 */
|
|
29
|
+
export type FigureCell = {
|
|
30
|
+
readonly kind: 'vertex';
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly value: string;
|
|
33
|
+
readonly style: string;
|
|
34
|
+
readonly rect: Rect;
|
|
35
|
+
} | {
|
|
36
|
+
readonly kind: 'edge';
|
|
37
|
+
readonly id: string;
|
|
38
|
+
readonly value: string;
|
|
39
|
+
readonly style: string;
|
|
40
|
+
readonly source: string;
|
|
41
|
+
readonly target: string;
|
|
42
|
+
/** 手工折点(缺省交给 draw.io 的正交路由)。 */
|
|
43
|
+
readonly points?: readonly Point[];
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* 统一的插图风格。
|
|
47
|
+
*
|
|
48
|
+
* 取色原则:低饱和的蓝灰主色 + 大面积留白,黑白打印仍然分得清层次;
|
|
49
|
+
* 中文用宋体、层名用黑体,与论文正文的字体习惯一致(字体缺失时 draw.io 会回退,
|
|
50
|
+
* 不会影响图的可读性)。
|
|
51
|
+
*/
|
|
52
|
+
export declare const STYLE: {
|
|
53
|
+
/** 分层容器的底色(虚线框,不抢内容的注意力)。 */
|
|
54
|
+
readonly layerBand: "rounded=0;whiteSpace=wrap;html=1;fillColor=#F7F9FC;strokeColor=#C3CEDC;dashed=1;dashPattern=6 4;";
|
|
55
|
+
/** 分层名(左侧竖条)。 */
|
|
56
|
+
readonly layerLabel: "rounded=0;whiteSpace=wrap;html=1;fillColor=#E9EFF7;strokeColor=#C3CEDC;fontFamily=SimHei;fontSize=12;fontStyle=1;";
|
|
57
|
+
/** 普通模块。 */
|
|
58
|
+
readonly module: "rounded=1;arcSize=10;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#6E8FB5;fontFamily=SimSun;fontSize=12;";
|
|
59
|
+
/** 强调模块(系统的核心 / 答辩要讲的那一块)。 */
|
|
60
|
+
readonly moduleAccent: "rounded=1;arcSize=10;whiteSpace=wrap;html=1;fillColor=#DCE8F6;strokeColor=#3F6D9E;fontFamily=SimSun;fontSize=12;fontStyle=1;";
|
|
61
|
+
/** 流程起点。 */
|
|
62
|
+
readonly start: "rounded=1;arcSize=50;whiteSpace=wrap;html=1;fillColor=#E4F1DC;strokeColor=#6B9E5A;fontFamily=SimSun;fontSize=12;fontStyle=1;";
|
|
63
|
+
/** 流程终点。 */
|
|
64
|
+
readonly end: "rounded=1;arcSize=50;whiteSpace=wrap;html=1;fillColor=#F6E4E4;strokeColor=#B06A6A;fontFamily=SimSun;fontSize=12;fontStyle=1;";
|
|
65
|
+
/** 判断节点(菱形)。 */
|
|
66
|
+
readonly decision: "rhombus;whiteSpace=wrap;html=1;fillColor=#FFF6E0;strokeColor=#C9A24A;fontFamily=SimSun;fontSize=12;";
|
|
67
|
+
/** ER 实体标题栏。 */
|
|
68
|
+
readonly entityTitle: "rounded=0;whiteSpace=wrap;html=1;fillColor=#DCE8F6;strokeColor=#3F6D9E;fontFamily=SimHei;fontSize=12;fontStyle=1;";
|
|
69
|
+
/** ER 实体字段行。 */
|
|
70
|
+
readonly entityRow: "rounded=0;whiteSpace=wrap;html=1;align=left;spacingLeft=8;fillColor=#FFFFFF;strokeColor=#9FB3C8;fontFamily=SimSun;fontSize=11;";
|
|
71
|
+
/** 时序图参与者(draw.io 自带生命线形状)。 */
|
|
72
|
+
readonly lifeline: "shape=umlLifeline;perimeter=lifelinePerimeter;whiteSpace=wrap;html=1;container=0;collapsible=0;recursiveResize=0;outlineConnect=0;fontFamily=SimSun;fontSize=12;";
|
|
73
|
+
/** 实线箭头(调用 / 依赖)。标签加白底,压在线上也读得清。 */
|
|
74
|
+
readonly edge: "edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;endArrow=block;endFill=1;strokeColor=#5F7F9F;labelBackgroundColor=#FFFFFF;fontFamily=SimSun;fontSize=11;";
|
|
75
|
+
/** 虚线箭头(返回 / 弱依赖)。 */
|
|
76
|
+
readonly edgeDashed: "edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;endArrow=open;endFill=0;dashed=1;strokeColor=#5F7F9F;labelBackgroundColor=#FFFFFF;fontFamily=SimSun;fontSize=11;";
|
|
77
|
+
/** 时序图消息(水平箭头,无正交折线)。 */
|
|
78
|
+
readonly message: "html=1;verticalAlign=bottom;endArrow=block;endFill=1;rounded=0;strokeColor=#3F6D9E;labelBackgroundColor=#FFFFFF;fontFamily=SimSun;fontSize=11;";
|
|
79
|
+
/** 时序图返回消息。 */
|
|
80
|
+
readonly messageReturn: "html=1;verticalAlign=bottom;endArrow=open;endFill=0;dashed=1;rounded=0;strokeColor=#5F7F9F;labelBackgroundColor=#FFFFFF;fontFamily=SimSun;fontSize=11;";
|
|
81
|
+
};
|
|
82
|
+
/** XML 属性值转义(顺序有关:`&` 必须先转)。 */
|
|
83
|
+
export declare function escapeXmlAttr(text: string): string;
|
|
84
|
+
/** draw.io 标签:逐行转义后用 `<br>` 连接(html=1 时渲染成换行)。 */
|
|
85
|
+
export declare function labelValue(text: string): string;
|
|
86
|
+
/** mxfile 渲染参数。 */
|
|
87
|
+
export interface MxFileOptions {
|
|
88
|
+
/** 图名(写进 `<diagram name>`,也是 draw.io 里的页签标题)。 */
|
|
89
|
+
readonly name: string;
|
|
90
|
+
readonly cells: readonly FigureCell[];
|
|
91
|
+
/** 页面宽(缺省 A4 纵向 827)。导出时 `--border` 会在内容外留白,页码本身不参与裁剪。 */
|
|
92
|
+
readonly pageWidth?: number;
|
|
93
|
+
/** 页面高(缺省按内容推算,最小 A4 纵向 1169)。 */
|
|
94
|
+
readonly pageHeight?: number;
|
|
95
|
+
/** 渲染时间(测试注入固定值;缺省当前时间)。 */
|
|
96
|
+
readonly now?: Date;
|
|
97
|
+
}
|
|
98
|
+
/** A4 纵向页宽(draw.io 单位)。 */
|
|
99
|
+
export declare const DEFAULT_PAGE_WIDTH = 827;
|
|
100
|
+
/** A4 纵向页高。 */
|
|
101
|
+
export declare const DEFAULT_PAGE_HEIGHT = 1169;
|
|
102
|
+
/**
|
|
103
|
+
* 渲染完整的 `.drawio` 文本。
|
|
104
|
+
*
|
|
105
|
+
* 顶层版本号写死为 `24.0.0`:draw.io 对高版本文件会走兼容分支,低版本号最稳;
|
|
106
|
+
* `host/agent` 标成 dsh-thesis,便于学生一眼看出这张图是谁生成的。
|
|
107
|
+
*/
|
|
108
|
+
export declare function renderMxfile(options: MxFileOptions): string;
|
|
109
|
+
/** 自定义 mxfile 的校验结论。 */
|
|
110
|
+
export type MxfileCheck = {
|
|
111
|
+
readonly ok: true;
|
|
112
|
+
} | {
|
|
113
|
+
readonly ok: false;
|
|
114
|
+
readonly reason: string;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* 校验 `custom` 图的手写 XML。
|
|
118
|
+
*
|
|
119
|
+
* 只做结构性检查(足够挡住「贴错内容」「贴成 SVG」这类事故),不做完整 XML 解析:
|
|
120
|
+
* 真解析需要额外依赖,而 draw.io 自己会在打开时报语法错。拒绝 `<!DOCTYPE` 与 `<script`
|
|
121
|
+
* 是安全底线:mxfile 可以内嵌 HTML 标签,脚本一旦被执行就是宿主风险。
|
|
122
|
+
*/
|
|
123
|
+
export declare function validateMxfile(xml: string): MxfileCheck;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* draw.io(`.drawio` / mxfile)渲染:把版面模型写成 draw.io 能打开、学生能继续手工编辑的 XML。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不用 Mermaid:Mermaid 是「文本进、自动布局出」,论文里的架构图、ER 图往往被
|
|
5
|
+
* 排成一张挤在一起的网,字号、配色、线型也由渲染器决定。draw.io 的源文件是**显式坐标**
|
|
6
|
+
* 的矢量图,可以精确控制版面、统一风格、导出高分辨率 PNG,并且改动仍可在 draw.io 里完成。
|
|
7
|
+
* 本模块只做两件事:给出一套论文用的风格常量,把 {@link FigureCell} 渲染成 mxfile。
|
|
8
|
+
*
|
|
9
|
+
* 约束(与仓库其它模块一致):
|
|
10
|
+
* - 零依赖、确定性输出(同样的输入 + 同样的时间,字节完全一致);
|
|
11
|
+
* - 正文里出现的一切用户文本都要转义,标签里的换行以 `<br>` 表达(draw.io 的 html=1);
|
|
12
|
+
* - `custom` 图接受模型手写的 mxfile,但必须过 {@link validateMxfile},不允许脚本与 DOCTYPE。
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-thesis/figure/drawio
|
|
15
|
+
*/
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// 风格:论文插图用的统一预设
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
/**
|
|
20
|
+
* 统一的插图风格。
|
|
21
|
+
*
|
|
22
|
+
* 取色原则:低饱和的蓝灰主色 + 大面积留白,黑白打印仍然分得清层次;
|
|
23
|
+
* 中文用宋体、层名用黑体,与论文正文的字体习惯一致(字体缺失时 draw.io 会回退,
|
|
24
|
+
* 不会影响图的可读性)。
|
|
25
|
+
*/
|
|
26
|
+
export const STYLE = {
|
|
27
|
+
/** 分层容器的底色(虚线框,不抢内容的注意力)。 */
|
|
28
|
+
layerBand: 'rounded=0;whiteSpace=wrap;html=1;fillColor=#F7F9FC;strokeColor=#C3CEDC;dashed=1;dashPattern=6 4;',
|
|
29
|
+
/** 分层名(左侧竖条)。 */
|
|
30
|
+
layerLabel: 'rounded=0;whiteSpace=wrap;html=1;fillColor=#E9EFF7;strokeColor=#C3CEDC;fontFamily=SimHei;fontSize=12;fontStyle=1;',
|
|
31
|
+
/** 普通模块。 */
|
|
32
|
+
module: 'rounded=1;arcSize=10;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#6E8FB5;fontFamily=SimSun;fontSize=12;',
|
|
33
|
+
/** 强调模块(系统的核心 / 答辩要讲的那一块)。 */
|
|
34
|
+
moduleAccent: 'rounded=1;arcSize=10;whiteSpace=wrap;html=1;fillColor=#DCE8F6;strokeColor=#3F6D9E;fontFamily=SimSun;fontSize=12;fontStyle=1;',
|
|
35
|
+
/** 流程起点。 */
|
|
36
|
+
start: 'rounded=1;arcSize=50;whiteSpace=wrap;html=1;fillColor=#E4F1DC;strokeColor=#6B9E5A;fontFamily=SimSun;fontSize=12;fontStyle=1;',
|
|
37
|
+
/** 流程终点。 */
|
|
38
|
+
end: 'rounded=1;arcSize=50;whiteSpace=wrap;html=1;fillColor=#F6E4E4;strokeColor=#B06A6A;fontFamily=SimSun;fontSize=12;fontStyle=1;',
|
|
39
|
+
/** 判断节点(菱形)。 */
|
|
40
|
+
decision: 'rhombus;whiteSpace=wrap;html=1;fillColor=#FFF6E0;strokeColor=#C9A24A;fontFamily=SimSun;fontSize=12;',
|
|
41
|
+
/** ER 实体标题栏。 */
|
|
42
|
+
entityTitle: 'rounded=0;whiteSpace=wrap;html=1;fillColor=#DCE8F6;strokeColor=#3F6D9E;fontFamily=SimHei;fontSize=12;fontStyle=1;',
|
|
43
|
+
/** ER 实体字段行。 */
|
|
44
|
+
entityRow: 'rounded=0;whiteSpace=wrap;html=1;align=left;spacingLeft=8;fillColor=#FFFFFF;strokeColor=#9FB3C8;fontFamily=SimSun;fontSize=11;',
|
|
45
|
+
/** 时序图参与者(draw.io 自带生命线形状)。 */
|
|
46
|
+
lifeline: 'shape=umlLifeline;perimeter=lifelinePerimeter;whiteSpace=wrap;html=1;container=0;collapsible=0;recursiveResize=0;outlineConnect=0;fontFamily=SimSun;fontSize=12;',
|
|
47
|
+
/** 实线箭头(调用 / 依赖)。标签加白底,压在线上也读得清。 */
|
|
48
|
+
edge: 'edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;endArrow=block;endFill=1;strokeColor=#5F7F9F;labelBackgroundColor=#FFFFFF;fontFamily=SimSun;fontSize=11;',
|
|
49
|
+
/** 虚线箭头(返回 / 弱依赖)。 */
|
|
50
|
+
edgeDashed: 'edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;endArrow=open;endFill=0;dashed=1;strokeColor=#5F7F9F;labelBackgroundColor=#FFFFFF;fontFamily=SimSun;fontSize=11;',
|
|
51
|
+
/** 时序图消息(水平箭头,无正交折线)。 */
|
|
52
|
+
message: 'html=1;verticalAlign=bottom;endArrow=block;endFill=1;rounded=0;strokeColor=#3F6D9E;labelBackgroundColor=#FFFFFF;fontFamily=SimSun;fontSize=11;',
|
|
53
|
+
/** 时序图返回消息。 */
|
|
54
|
+
messageReturn: 'html=1;verticalAlign=bottom;endArrow=open;endFill=0;dashed=1;rounded=0;strokeColor=#5F7F9F;labelBackgroundColor=#FFFFFF;fontFamily=SimSun;fontSize=11;',
|
|
55
|
+
};
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// 转义与渲染
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
/** XML 属性值转义(顺序有关:`&` 必须先转)。 */
|
|
60
|
+
export function escapeXmlAttr(text) {
|
|
61
|
+
return text
|
|
62
|
+
.replaceAll('&', '&')
|
|
63
|
+
.replaceAll('<', '<')
|
|
64
|
+
.replaceAll('>', '>')
|
|
65
|
+
.replaceAll('"', '"')
|
|
66
|
+
.replaceAll("'", ''');
|
|
67
|
+
}
|
|
68
|
+
/** draw.io 标签:逐行转义后用 `<br>` 连接(html=1 时渲染成换行)。 */
|
|
69
|
+
export function labelValue(text) {
|
|
70
|
+
return text
|
|
71
|
+
.replace(/\r\n/g, '\n')
|
|
72
|
+
.split('\n')
|
|
73
|
+
.map(line => escapeXmlAttr(line.trimEnd()))
|
|
74
|
+
.join('<br>');
|
|
75
|
+
}
|
|
76
|
+
/** 数字统一保留两位小数,避免浮点尾巴让 diff 噪音变大。 */
|
|
77
|
+
function num(value) {
|
|
78
|
+
const rounded = Math.round(value * 100) / 100;
|
|
79
|
+
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(2);
|
|
80
|
+
}
|
|
81
|
+
function renderGeometry(cell) {
|
|
82
|
+
const { x, y, width, height } = cell.rect;
|
|
83
|
+
return `<mxGeometry x="${num(x)}" y="${num(y)}" width="${num(width)}" height="${num(height)}" as="geometry" />`;
|
|
84
|
+
}
|
|
85
|
+
function renderEdgeGeometry(cell) {
|
|
86
|
+
const points = cell.points ?? [];
|
|
87
|
+
if (points.length === 0)
|
|
88
|
+
return '<mxGeometry relative="1" as="geometry" />';
|
|
89
|
+
const rendered = points.map(p => `<mxPoint x="${num(p.x)}" y="${num(p.y)}" />`).join('');
|
|
90
|
+
return `<mxGeometry relative="1" as="geometry"><Array as="points">${rendered}</Array></mxGeometry>`;
|
|
91
|
+
}
|
|
92
|
+
function renderCell(cell) {
|
|
93
|
+
if (cell.kind === 'vertex') {
|
|
94
|
+
// 所有图元都挂在根层、用**绝对坐标**:mxGraph 里子图元的 geometry 是相对父图元的,
|
|
95
|
+
// 把模块嵌进层带(parent=band)会让坐标整体再偏移一次,图直接错位。
|
|
96
|
+
// 层带与模块的包含关系靠视觉位置表达,不靠 cell 嵌套。
|
|
97
|
+
// 标签走 labelValue:换行转成 `<br>`(html=1 时 draw.io 才认多行)。
|
|
98
|
+
return `<mxCell id="${escapeXmlAttr(cell.id)}" value="${labelValue(cell.value)}" style="${escapeXmlAttr(cell.style)}" vertex="1" parent="1">${renderGeometry(cell)}</mxCell>`;
|
|
99
|
+
}
|
|
100
|
+
return `<mxCell id="${escapeXmlAttr(cell.id)}" value="${labelValue(cell.value)}" style="${escapeXmlAttr(cell.style)}" edge="1" parent="1" source="${escapeXmlAttr(cell.source)}" target="${escapeXmlAttr(cell.target)}">${renderEdgeGeometry(cell)}</mxCell>`;
|
|
101
|
+
}
|
|
102
|
+
/** A4 纵向页宽(draw.io 单位)。 */
|
|
103
|
+
export const DEFAULT_PAGE_WIDTH = 827;
|
|
104
|
+
/** A4 纵向页高。 */
|
|
105
|
+
export const DEFAULT_PAGE_HEIGHT = 1169;
|
|
106
|
+
/**
|
|
107
|
+
* 渲染完整的 `.drawio` 文本。
|
|
108
|
+
*
|
|
109
|
+
* 顶层版本号写死为 `24.0.0`:draw.io 对高版本文件会走兼容分支,低版本号最稳;
|
|
110
|
+
* `host/agent` 标成 dsh-thesis,便于学生一眼看出这张图是谁生成的。
|
|
111
|
+
*/
|
|
112
|
+
export function renderMxfile(options) {
|
|
113
|
+
const now = options.now ?? new Date();
|
|
114
|
+
const pageWidth = options.pageWidth ?? DEFAULT_PAGE_WIDTH;
|
|
115
|
+
const pageHeight = options.pageHeight ?? DEFAULT_PAGE_HEIGHT;
|
|
116
|
+
const cells = options.cells.map(renderCell).join('');
|
|
117
|
+
return [
|
|
118
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
119
|
+
`<mxfile host="dsh-thesis" modified="${now.toISOString()}" agent="dsh-thesis" version="24.0.0" type="device">`,
|
|
120
|
+
` <diagram id="dsh-thesis-figure" name="${escapeXmlAttr(options.name)}">`,
|
|
121
|
+
` <mxGraphModel dx="${pageWidth}" dy="${pageHeight}" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="${pageWidth}" pageHeight="${pageHeight}" math="0" shadow="0">`,
|
|
122
|
+
' <root>',
|
|
123
|
+
' <mxCell id="0" />',
|
|
124
|
+
' <mxCell id="1" parent="0" />',
|
|
125
|
+
` ${cells}`,
|
|
126
|
+
' </root>',
|
|
127
|
+
' </mxGraphModel>',
|
|
128
|
+
' </diagram>',
|
|
129
|
+
'</mxfile>',
|
|
130
|
+
'',
|
|
131
|
+
].join('\n');
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* 校验 `custom` 图的手写 XML。
|
|
135
|
+
*
|
|
136
|
+
* 只做结构性检查(足够挡住「贴错内容」「贴成 SVG」这类事故),不做完整 XML 解析:
|
|
137
|
+
* 真解析需要额外依赖,而 draw.io 自己会在打开时报语法错。拒绝 `<!DOCTYPE` 与 `<script`
|
|
138
|
+
* 是安全底线:mxfile 可以内嵌 HTML 标签,脚本一旦被执行就是宿主风险。
|
|
139
|
+
*/
|
|
140
|
+
export function validateMxfile(xml) {
|
|
141
|
+
const text = xml.trim();
|
|
142
|
+
if (text === '')
|
|
143
|
+
return { ok: false, reason: 'custom 图的 xml 为空' };
|
|
144
|
+
if (/<!DOCTYPE/i.test(text))
|
|
145
|
+
return { ok: false, reason: 'custom 图的 xml 不允许 <!DOCTYPE(draw.io 也不需要)' };
|
|
146
|
+
if (/<script[\s>]/i.test(text))
|
|
147
|
+
return { ok: false, reason: 'custom 图的 xml 不允许 <script>' };
|
|
148
|
+
if (!/^<\?xml[^>]*\?>\s*<mxfile[\s>]/i.test(text) && !/^<mxfile[\s>]/i.test(text)) {
|
|
149
|
+
return { ok: false, reason: 'custom 图必须以 <mxfile> 开头(draw.io 的「文件 → 另存为 .drawio」就是这种格式)' };
|
|
150
|
+
}
|
|
151
|
+
if (!/<mxGraphModel[\s>]/i.test(text))
|
|
152
|
+
return { ok: false, reason: 'custom 图缺少 <mxGraphModel>(不是有效的 draw.io 图)' };
|
|
153
|
+
if (!/<root[\s>]/i.test(text))
|
|
154
|
+
return { ok: false, reason: 'custom 图缺少 <root>(mxGraphModel 里必须有一个根层)' };
|
|
155
|
+
return { ok: true };
|
|
156
|
+
}
|