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
|
+
* `thesis_figure` 的业务实现:建图(create)、导出(export)、体检(check)、指引(guide)。
|
|
3
|
+
*
|
|
4
|
+
* 三条产品原则:
|
|
5
|
+
*
|
|
6
|
+
* 1. **源文件优先**:`.drawio` 是唯一真源,落盘在 `06-论文/assets/图/`,随论文 git 版本化;
|
|
7
|
+
* PNG 是导出件,随时可以重出。学生答辩前改一张图,不需要重画。
|
|
8
|
+
* 2. **图题与编号自动算**:给 `chapter` 就按该章已有图号递增出「图 4-2 …」,
|
|
9
|
+
* 避免手写编号断档——这正是 `thesis_check` 会判错的地方。
|
|
10
|
+
* 3. **没有 draw.io 也不假装成功**:导出失败/未安装都如实说,并给出三条可执行的替代路径
|
|
11
|
+
* (在线版 / VS Code 扩展 / 装桌面版)。建图本身不因导出失败而失败。
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-thesis/figure
|
|
14
|
+
*/
|
|
15
|
+
import type { FileSystem } from '@deepseek-ai/dsh-fs';
|
|
16
|
+
import type { FigureOptions } from '../config.ts';
|
|
17
|
+
import { type DrawioDetection, type DrawioEngine, type ExportOutcome } from './lib/export.ts';
|
|
18
|
+
import { type FigureKind } from './lib/spec.ts';
|
|
19
|
+
/** 插件配置里与出图有关的字段(定义在 config.ts,默认值同源)。 */
|
|
20
|
+
export type { FigureOptions };
|
|
21
|
+
/** 图的源文件与导出件目录(相对论文工作区根)。 */
|
|
22
|
+
export declare const FIGURE_DIR = "06-\u8BBA\u6587/assets/\u56FE";
|
|
23
|
+
/** 最近一次导出的现场记录。 */
|
|
24
|
+
export declare const FIGURE_NOTES = "06-\u8BBA\u6587/assets/\u56FE/\u5BFC\u51FA\u8BF4\u660E.md";
|
|
25
|
+
/** 图表清单(check 的产物)。 */
|
|
26
|
+
export declare const FIGURE_INVENTORY = "06-\u8BBA\u6587/assets/\u56FE/\u56FE\u8868\u6E05\u5355.md";
|
|
27
|
+
/** 章节正文目录。 */
|
|
28
|
+
export declare const CHAPTER_DIR = "06-\u8BBA\u6587/\u7AE0\u8282";
|
|
29
|
+
/** 工具参数。 */
|
|
30
|
+
export interface FigureArgs {
|
|
31
|
+
readonly action: 'create' | 'export' | 'check' | 'guide';
|
|
32
|
+
readonly kind?: FigureKind;
|
|
33
|
+
readonly name?: string;
|
|
34
|
+
readonly title?: string;
|
|
35
|
+
readonly chapter?: string;
|
|
36
|
+
readonly caption?: string;
|
|
37
|
+
readonly spec?: unknown;
|
|
38
|
+
readonly export?: boolean;
|
|
39
|
+
readonly force?: boolean;
|
|
40
|
+
readonly format?: 'png' | 'svg';
|
|
41
|
+
readonly scale?: number;
|
|
42
|
+
}
|
|
43
|
+
/** 可注入的外部依赖(测试用;生产走默认实现)。 */
|
|
44
|
+
export interface FigureDeps {
|
|
45
|
+
readonly now?: () => Date;
|
|
46
|
+
readonly platform?: string;
|
|
47
|
+
readonly env?: Record<string, string | undefined>;
|
|
48
|
+
readonly detect?: (options: {
|
|
49
|
+
configured?: string;
|
|
50
|
+
platform?: string;
|
|
51
|
+
env?: Record<string, string | undefined>;
|
|
52
|
+
}) => DrawioDetection;
|
|
53
|
+
readonly runExport?: (engine: DrawioEngine, drawioPath: string, outPath: string, options: {
|
|
54
|
+
scale: number;
|
|
55
|
+
format: 'png' | 'svg';
|
|
56
|
+
timeoutMs: number;
|
|
57
|
+
}) => ExportOutcome;
|
|
58
|
+
/** 文件修改时间(用于「导出件比源文件旧」检查);拿不到返回 undefined。 */
|
|
59
|
+
readonly mtimeMs?: (absPath: string) => number | undefined;
|
|
60
|
+
}
|
|
61
|
+
/** 文件名:保留中文,替换掉路径分隔符与 Windows 非法字符。 */
|
|
62
|
+
export declare function slugify(input: string): string;
|
|
63
|
+
/** 把章节参数(文件名 / 序号 / 章标题)解析成章节元数据。 */
|
|
64
|
+
export declare function resolveChapter(chapter: string): {
|
|
65
|
+
readonly file: string;
|
|
66
|
+
readonly chapterNo: number;
|
|
67
|
+
} | undefined;
|
|
68
|
+
/** 该章已有的最大图号 + 1(按「图 4-N」统计,正文引用与图题都算)。 */
|
|
69
|
+
export declare function nextFigureIndex(chapterText: string, chapterNo: number): number;
|
|
70
|
+
/** 章节里的图片引用(含行号与图题)。 */
|
|
71
|
+
export declare function extractReferences(text: string): {
|
|
72
|
+
readonly line: number;
|
|
73
|
+
readonly alt: string;
|
|
74
|
+
readonly src: string;
|
|
75
|
+
}[];
|
|
76
|
+
/**
|
|
77
|
+
* 工具入口:按 action 分派。
|
|
78
|
+
*
|
|
79
|
+
* 找不到论文工作区时给出与其它 `thesis_*` 工具一致的错误,而不是静默返回空结果。
|
|
80
|
+
*/
|
|
81
|
+
export declare function runFigureAction(fs: FileSystem, cwd: string | undefined, args: FigureArgs, options: FigureOptions, signal?: AbortSignal, deps?: FigureDeps): Promise<string>;
|
|
82
|
+
/** 工具描述(与论文写作技能的口径一致)。 */
|
|
83
|
+
export declare const FIGURE_TOOL_DESCRIPTION: string;
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `thesis_figure` 的业务实现:建图(create)、导出(export)、体检(check)、指引(guide)。
|
|
3
|
+
*
|
|
4
|
+
* 三条产品原则:
|
|
5
|
+
*
|
|
6
|
+
* 1. **源文件优先**:`.drawio` 是唯一真源,落盘在 `06-论文/assets/图/`,随论文 git 版本化;
|
|
7
|
+
* PNG 是导出件,随时可以重出。学生答辩前改一张图,不需要重画。
|
|
8
|
+
* 2. **图题与编号自动算**:给 `chapter` 就按该章已有图号递增出「图 4-2 …」,
|
|
9
|
+
* 避免手写编号断档——这正是 `thesis_check` 会判错的地方。
|
|
10
|
+
* 3. **没有 draw.io 也不假装成功**:导出失败/未安装都如实说,并给出三条可执行的替代路径
|
|
11
|
+
* (在线版 / VS Code 扩展 / 装桌面版)。建图本身不因导出失败而失败。
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-thesis/figure
|
|
14
|
+
*/
|
|
15
|
+
import * as nodePath from 'node:path';
|
|
16
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
17
|
+
import { CHAPTER_META, chapterMetaFor } from "../paper/lib/layout.js";
|
|
18
|
+
import { findThesisRoot, workspaceNotFoundError } from "../paper/lib/project.js";
|
|
19
|
+
import { imageFormatOf, readImageInfo } from "../paper/lib/image.js";
|
|
20
|
+
import { isMissingError } from "../shared/fs-errors.js";
|
|
21
|
+
import { checkFigures, renderFigureReport, stemOf, } from "./lib/check.js";
|
|
22
|
+
import { DEFAULT_PAGE_HEIGHT, renderMxfile, validateMxfile } from "./lib/drawio.js";
|
|
23
|
+
import { detectDrawio, drawioGuide, exportDiagram, renderExportNotes, } from "./lib/export.js";
|
|
24
|
+
import { buildDrawing } from "./lib/layout.js";
|
|
25
|
+
import { describeModel, parseFigureModel } from "./lib/spec.js";
|
|
26
|
+
/** 图的源文件与导出件目录(相对论文工作区根)。 */
|
|
27
|
+
export const FIGURE_DIR = '06-论文/assets/图';
|
|
28
|
+
/** 最近一次导出的现场记录。 */
|
|
29
|
+
export const FIGURE_NOTES = `${FIGURE_DIR}/导出说明.md`;
|
|
30
|
+
/** 图表清单(check 的产物)。 */
|
|
31
|
+
export const FIGURE_INVENTORY = `${FIGURE_DIR}/图表清单.md`;
|
|
32
|
+
/** 章节正文目录。 */
|
|
33
|
+
export const CHAPTER_DIR = '06-论文/章节';
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// 小工具
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
function readMtime(absPath) {
|
|
38
|
+
try {
|
|
39
|
+
return statSync(absPath).mtimeMs;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function readOptional(fs, absPath, signal) {
|
|
46
|
+
try {
|
|
47
|
+
return await fs.readText(await fs.resolve(absPath, { signal }), signal);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (isMissingError(error))
|
|
51
|
+
return undefined;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function listOptional(fs, absDir, signal) {
|
|
56
|
+
try {
|
|
57
|
+
return (await fs.listDir(await fs.resolve(absDir, { signal }), signal)).map(entry => entry.name);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (isMissingError(error))
|
|
61
|
+
return [];
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** 文件名:保留中文,替换掉路径分隔符与 Windows 非法字符。 */
|
|
66
|
+
export function slugify(input) {
|
|
67
|
+
const slug = input
|
|
68
|
+
.trim()
|
|
69
|
+
.replace(/[\\/:*?"<>|\s]+/g, '-')
|
|
70
|
+
.replace(/-{2,}/g, '-')
|
|
71
|
+
.replace(/^[-.]+|[-.]+$/g, '')
|
|
72
|
+
.slice(0, 60);
|
|
73
|
+
return slug;
|
|
74
|
+
}
|
|
75
|
+
/** 把章节参数(文件名 / 序号 / 章标题)解析成章节元数据。 */
|
|
76
|
+
export function resolveChapter(chapter) {
|
|
77
|
+
const raw = chapter.trim().replace(/\.md$/i, '');
|
|
78
|
+
const direct = chapterMetaFor(raw);
|
|
79
|
+
if (direct !== undefined)
|
|
80
|
+
return { file: direct.meta.file, chapterNo: direct.chapterNo };
|
|
81
|
+
if (/^\d+$/.test(raw)) {
|
|
82
|
+
const meta = CHAPTER_META[Number(raw) - 1];
|
|
83
|
+
if (meta !== undefined)
|
|
84
|
+
return { file: meta.file, chapterNo: Number(raw) };
|
|
85
|
+
}
|
|
86
|
+
const index = CHAPTER_META.findIndex(meta => meta.title === raw || meta.file.includes(raw));
|
|
87
|
+
if (index === -1)
|
|
88
|
+
return undefined;
|
|
89
|
+
const meta = CHAPTER_META[index];
|
|
90
|
+
return { file: meta.file, chapterNo: index + 1 };
|
|
91
|
+
}
|
|
92
|
+
/** 该章已有的最大图号 + 1(按「图 4-N」统计,正文引用与图题都算)。 */
|
|
93
|
+
export function nextFigureIndex(chapterText, chapterNo) {
|
|
94
|
+
const pattern = new RegExp(`图\\s*${chapterNo}\\s*[--—–]\\s*(\\d+)`, 'g');
|
|
95
|
+
let max = 0;
|
|
96
|
+
for (const match of chapterText.matchAll(pattern))
|
|
97
|
+
max = Math.max(max, Number(match[1]));
|
|
98
|
+
return max + 1;
|
|
99
|
+
}
|
|
100
|
+
/** 默认图名(学生没写 title 时的兜底,不猜业务内容)。 */
|
|
101
|
+
const DEFAULT_TITLES = {
|
|
102
|
+
architecture: '系统总体架构图',
|
|
103
|
+
flow: '业务流程图',
|
|
104
|
+
er: '数据库 E-R 图',
|
|
105
|
+
sequence: '接口时序图',
|
|
106
|
+
custom: '示意图',
|
|
107
|
+
};
|
|
108
|
+
/** 章节里的图片引用(含行号与图题)。 */
|
|
109
|
+
export function extractReferences(text) {
|
|
110
|
+
const references = [];
|
|
111
|
+
text.split(/\r?\n/).forEach((line, index) => {
|
|
112
|
+
const pattern = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
|
113
|
+
for (const match of line.matchAll(pattern)) {
|
|
114
|
+
references.push({ line: index + 1, alt: match[1] ?? '', src: match[2] ?? '' });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
return references;
|
|
118
|
+
}
|
|
119
|
+
/** 解析图片路径:先按章节文件所在目录,再按论文根。 */
|
|
120
|
+
async function resolveImage(fs, root, chapterAbs, src, signal) {
|
|
121
|
+
if (nodePath.isAbsolute(src))
|
|
122
|
+
return src;
|
|
123
|
+
const candidates = [
|
|
124
|
+
nodePath.resolve(nodePath.dirname(chapterAbs), src),
|
|
125
|
+
nodePath.resolve(root, src),
|
|
126
|
+
];
|
|
127
|
+
for (const candidate of candidates) {
|
|
128
|
+
try {
|
|
129
|
+
const info = await fs.stat(await fs.resolve(candidate, { signal }), signal);
|
|
130
|
+
if (info !== undefined && !info.isDirectory)
|
|
131
|
+
return candidate;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// 探测失败按「不存在」处理:这里只用于生成检查清单,不写任何东西。
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
function toRel(root, absPath) {
|
|
140
|
+
return nodePath.relative(root, absPath).replace(/\\/g, '/');
|
|
141
|
+
}
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// create
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
async function runCreate(fs, root, args, options, signal, deps) {
|
|
146
|
+
if (args.kind === undefined) {
|
|
147
|
+
throw new Error('缺少 kind:可选 architecture(分层架构图)/ flow(流程图)/ er(数据库 E-R 图)/ sequence(时序图)/ custom(直接贴 draw.io 的 XML)。');
|
|
148
|
+
}
|
|
149
|
+
const model = parseFigureModel(args.kind, args.spec);
|
|
150
|
+
const title = (args.title ?? '').trim() === '' ? DEFAULT_TITLES[args.kind] : args.title.trim();
|
|
151
|
+
const drawing = buildDrawing(model);
|
|
152
|
+
if (model.kind === 'custom') {
|
|
153
|
+
const check = validateMxfile(model.model.xml);
|
|
154
|
+
if (!check.ok)
|
|
155
|
+
throw new Error(`custom 图的 XML 不合法:${check.reason}`);
|
|
156
|
+
}
|
|
157
|
+
const base = slugify(args.name ?? title);
|
|
158
|
+
if (base === '')
|
|
159
|
+
throw new Error('name(或 title)去掉非法字符后是空的,请给一个能当文件名的名字,例如 arch-overview。');
|
|
160
|
+
const targetRel = `${FIGURE_DIR}/${base}.drawio`;
|
|
161
|
+
const targetAbs = nodePath.join(root, targetRel);
|
|
162
|
+
const existing = await readOptional(fs, targetAbs, signal);
|
|
163
|
+
let backupRel;
|
|
164
|
+
if (existing !== undefined) {
|
|
165
|
+
if (args.force !== true) {
|
|
166
|
+
throw new Error(`图源文件已存在:${targetRel}。要覆盖请传 force: true(覆盖前会另存旧文件),或用 name 换一个名字。`);
|
|
167
|
+
}
|
|
168
|
+
const stamp = (deps.now?.() ?? new Date()).toISOString().replace(/[:.]/g, '-');
|
|
169
|
+
const backupName = `${base}-${stamp}.drawio`;
|
|
170
|
+
// 备份放进 历史/ 子目录:export 与 check 只扫当前目录,旧版本不会被当成"待导出的图"。
|
|
171
|
+
await fs.writeText(await fs.resolve(nodePath.join(root, FIGURE_DIR, '历史', backupName), { signal }), existing, undefined, signal);
|
|
172
|
+
backupRel = `${FIGURE_DIR}/历史/${backupName}`;
|
|
173
|
+
}
|
|
174
|
+
const xml = model.kind === 'custom'
|
|
175
|
+
? model.model.xml
|
|
176
|
+
: renderMxfile({
|
|
177
|
+
name: title,
|
|
178
|
+
cells: drawing.cells,
|
|
179
|
+
pageHeight: Math.max(DEFAULT_PAGE_HEIGHT, Math.ceil(drawing.height) + 40),
|
|
180
|
+
now: deps.now?.() ?? new Date(),
|
|
181
|
+
});
|
|
182
|
+
await fs.writeText(await fs.resolve(targetAbs, { signal }), xml, undefined, signal);
|
|
183
|
+
// 图题与编号:给了 chapter 就按该章已有图号递增。
|
|
184
|
+
let caption = args.caption?.trim();
|
|
185
|
+
let chapterNote = '';
|
|
186
|
+
if (caption === undefined || caption === '') {
|
|
187
|
+
if (args.chapter !== undefined && args.chapter.trim() !== '') {
|
|
188
|
+
const chapter = resolveChapter(args.chapter);
|
|
189
|
+
if (chapter === undefined) {
|
|
190
|
+
throw new Error(`无法识别章节「${args.chapter}」:可用 ${CHAPTER_META.map(meta => meta.file).join(' / ')}(也可写章号 1-7)。`);
|
|
191
|
+
}
|
|
192
|
+
const chapterText = (await readOptional(fs, nodePath.join(root, CHAPTER_DIR, `${chapter.file}.md`), signal)) ?? '';
|
|
193
|
+
const index = nextFigureIndex(chapterText, chapter.chapterNo);
|
|
194
|
+
caption = `图 ${chapter.chapterNo}-${index} ${title}`;
|
|
195
|
+
chapterNote = `(第 ${chapter.chapterNo} 章已有 ${index - 1} 张图,自动编号为 ${chapter.chapterNo}-${index})`;
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
caption = title;
|
|
199
|
+
chapterNote = '(未给 chapter,图题里没有编号;写正文时请改成「图 4-1 …」并保持连续)';
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// 导出(失败不阻塞建图,但必须如实报告)。
|
|
203
|
+
const detection = (deps.detect ?? detectDrawio)({
|
|
204
|
+
...(options.drawioPath !== undefined ? { configured: options.drawioPath } : {}),
|
|
205
|
+
...(deps.platform !== undefined ? { platform: deps.platform } : {}),
|
|
206
|
+
...(deps.env !== undefined ? { env: deps.env } : {}),
|
|
207
|
+
});
|
|
208
|
+
const format = args.format ?? 'png';
|
|
209
|
+
const scale = args.scale ?? options.scale;
|
|
210
|
+
let exported;
|
|
211
|
+
let exportError;
|
|
212
|
+
const shouldExport = args.export !== false;
|
|
213
|
+
if (shouldExport && detection.engine !== undefined) {
|
|
214
|
+
try {
|
|
215
|
+
exported = (deps.runExport ?? exportDiagram)(detection.engine, targetAbs, nodePath.join(root, FIGURE_DIR, `${base}.${format}`), {
|
|
216
|
+
scale,
|
|
217
|
+
format,
|
|
218
|
+
timeoutMs: options.timeoutMs,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
exportError = error instanceof Error ? error.message : String(error);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const notes = renderExportNotes({
|
|
226
|
+
items: [{
|
|
227
|
+
figureRel: targetRel,
|
|
228
|
+
...(exported !== undefined ? { imageRel: toRel(root, exported.outPath) } : {}),
|
|
229
|
+
...(exported !== undefined ? { outcome: exported } : {}),
|
|
230
|
+
...(exportError !== undefined ? { error: exportError } : {}),
|
|
231
|
+
}],
|
|
232
|
+
detection,
|
|
233
|
+
format,
|
|
234
|
+
scale,
|
|
235
|
+
now: deps.now?.() ?? new Date(),
|
|
236
|
+
});
|
|
237
|
+
await fs.writeText(await fs.resolve(nodePath.join(root, FIGURE_NOTES), { signal }), notes, undefined, signal);
|
|
238
|
+
const imageRel = exported !== undefined ? toRel(root, exported.outPath) : `${FIGURE_DIR}/${base}.${format}`;
|
|
239
|
+
const snippet = ``;
|
|
240
|
+
const lines = [];
|
|
241
|
+
lines.push(`已生成图源文件:${targetRel}`);
|
|
242
|
+
lines.push(`- 种类:${model.kind}(${describeModel(model)})`);
|
|
243
|
+
if (backupRel !== undefined)
|
|
244
|
+
lines.push(`- 旧文件已备份:${backupRel}`);
|
|
245
|
+
lines.push(`- 图题:${caption}${chapterNote}`);
|
|
246
|
+
for (const note of drawing.notes)
|
|
247
|
+
lines.push(`- 版面提示:${note}`);
|
|
248
|
+
if (exported !== undefined) {
|
|
249
|
+
lines.push(`- 导出:${imageRel}(draw.io ${scale}×)`);
|
|
250
|
+
}
|
|
251
|
+
else if (!shouldExport) {
|
|
252
|
+
lines.push('- 导出:已跳过(export=false)。需要图片时运行 thesis_figure action=export。');
|
|
253
|
+
}
|
|
254
|
+
else if (detection.engine === undefined) {
|
|
255
|
+
lines.push('- 导出:未执行 —— 本机没有找到 draw.io 桌面版。');
|
|
256
|
+
lines.push(' 按 thesis_figure action=guide 的三条路径之一导出 PNG,再运行 action=check 核对。');
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
lines.push(`- 导出:失败 —— ${(exportError ?? '未知原因').split('\n')[0]}`);
|
|
260
|
+
lines.push(` 现场记录见 ${FIGURE_NOTES};修好后运行 thesis_figure action=export 重试。`);
|
|
261
|
+
}
|
|
262
|
+
lines.push('');
|
|
263
|
+
lines.push('写进章节(正文先引用,图题在图下方):');
|
|
264
|
+
lines.push('');
|
|
265
|
+
const citeNumber = /^图\s*(\d+[-–—]\d+)/.exec(caption)?.[1];
|
|
266
|
+
lines.push('```markdown');
|
|
267
|
+
lines.push(citeNumber === undefined
|
|
268
|
+
? '(图题里没有编号:正文引用时先补成「图 4-1 …」再写「如图 4-1 所示」)'
|
|
269
|
+
: `如图 ${citeNumber} 所示,……`);
|
|
270
|
+
lines.push('');
|
|
271
|
+
lines.push(snippet);
|
|
272
|
+
lines.push('```');
|
|
273
|
+
lines.push('');
|
|
274
|
+
lines.push('写法:图片单独成行、alt 就是图题(构建时图在上、图题排在图下方);路径相对章节文件(06-论文/章节/)。');
|
|
275
|
+
return lines.join('\n');
|
|
276
|
+
}
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// export
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
async function runExport(fs, root, args, options, signal, deps) {
|
|
281
|
+
const entries = (await listOptional(fs, nodePath.join(root, FIGURE_DIR), signal)).filter(name => /\.drawio$/i.test(name));
|
|
282
|
+
if (entries.length === 0) {
|
|
283
|
+
throw new Error(`还没有可导出的图源文件(${FIGURE_DIR}/*.drawio)。先运行 thesis_figure action=create。`);
|
|
284
|
+
}
|
|
285
|
+
const filter = args.name?.trim();
|
|
286
|
+
const selected = (filter === undefined || filter === ''
|
|
287
|
+
? entries
|
|
288
|
+
: entries.filter(name => stemOf(name) === stemOf(filter) || name === filter));
|
|
289
|
+
if (selected.length === 0) {
|
|
290
|
+
throw new Error(`在 ${FIGURE_DIR} 里没有找到名为「${filter}」的 .drawio;现有:${entries.join('、')}`);
|
|
291
|
+
}
|
|
292
|
+
const detection = (deps.detect ?? detectDrawio)({
|
|
293
|
+
...(options.drawioPath !== undefined ? { configured: options.drawioPath } : {}),
|
|
294
|
+
...(deps.platform !== undefined ? { platform: deps.platform } : {}),
|
|
295
|
+
...(deps.env !== undefined ? { env: deps.env } : {}),
|
|
296
|
+
});
|
|
297
|
+
const format = args.format ?? 'png';
|
|
298
|
+
const scale = args.scale ?? options.scale;
|
|
299
|
+
const now = deps.now?.() ?? new Date();
|
|
300
|
+
if (detection.engine === undefined) {
|
|
301
|
+
const notes = renderExportNotes({
|
|
302
|
+
items: selected.map(name => ({ figureRel: `${FIGURE_DIR}/${name}` })),
|
|
303
|
+
detection,
|
|
304
|
+
format,
|
|
305
|
+
scale,
|
|
306
|
+
now,
|
|
307
|
+
});
|
|
308
|
+
await fs.writeText(await fs.resolve(nodePath.join(root, FIGURE_NOTES), { signal }), notes, undefined, signal);
|
|
309
|
+
throw new Error(`未发现 draw.io 桌面版,无法自动导出(源文件已保留,不会丢)。\n${detection.problems.join('\n')}\n`
|
|
310
|
+
+ `三条替代路径见 thesis_figure action=guide;导出记录已写入 ${FIGURE_NOTES}。`);
|
|
311
|
+
}
|
|
312
|
+
const items = [];
|
|
313
|
+
const failures = [];
|
|
314
|
+
for (const name of selected) {
|
|
315
|
+
const drawioAbs = nodePath.join(root, FIGURE_DIR, name);
|
|
316
|
+
const outAbs = nodePath.join(root, FIGURE_DIR, `${stemOf(name)}.${format}`);
|
|
317
|
+
const figureRel = toRel(root, drawioAbs);
|
|
318
|
+
try {
|
|
319
|
+
const outcome = (deps.runExport ?? exportDiagram)(detection.engine, drawioAbs, outAbs, { scale, format, timeoutMs: options.timeoutMs });
|
|
320
|
+
items.push({ figureRel, imageRel: toRel(root, outAbs), outcome });
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
324
|
+
items.push({ figureRel, error: message });
|
|
325
|
+
failures.push(`- ${figureRel}:${message.split('\n')[0]}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const notes = renderExportNotes({ items, detection, format, scale, now });
|
|
329
|
+
await fs.writeText(await fs.resolve(nodePath.join(root, FIGURE_NOTES), { signal }), notes, undefined, signal);
|
|
330
|
+
const lines = [];
|
|
331
|
+
lines.push(`导出完成:${items.length - failures.length}/${items.length} 张(${format},${scale}×)。`);
|
|
332
|
+
for (const item of items) {
|
|
333
|
+
lines.push(item.error === undefined
|
|
334
|
+
? `- ✓ ${item.figureRel} → ${item.imageRel}`
|
|
335
|
+
: `- ✗ ${item.figureRel}:${item.error.split('\n')[0]}`);
|
|
336
|
+
}
|
|
337
|
+
lines.push('');
|
|
338
|
+
lines.push(`现场记录:${FIGURE_NOTES}`);
|
|
339
|
+
if (failures.length > 0) {
|
|
340
|
+
throw new Error(`${lines.join('\n')}\n\n${failures.length} 张图导出失败。`);
|
|
341
|
+
}
|
|
342
|
+
lines.push('下一步:把图片写进对应章节(图题在图下方),然后运行 thesis_figure action=check 核对。');
|
|
343
|
+
return lines.join('\n');
|
|
344
|
+
}
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// check
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
async function runCheck(fs, root, signal, deps) {
|
|
349
|
+
const mtime = deps.mtimeMs ?? readMtime;
|
|
350
|
+
const figureDirAbs = nodePath.join(root, FIGURE_DIR);
|
|
351
|
+
const figureFiles = await listOptional(fs, figureDirAbs, signal);
|
|
352
|
+
const sources = [];
|
|
353
|
+
const images = [];
|
|
354
|
+
for (const name of figureFiles) {
|
|
355
|
+
const abs = nodePath.join(figureDirAbs, name);
|
|
356
|
+
const rel = toRel(root, abs);
|
|
357
|
+
const modified = mtime(abs);
|
|
358
|
+
if (/\.drawio$/i.test(name)) {
|
|
359
|
+
sources.push({ path: rel, ...(modified !== undefined ? { mtimeMs: modified } : {}) });
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (!/\.(png|jpe?g|svg)$/i.test(name))
|
|
363
|
+
continue;
|
|
364
|
+
let widthPx;
|
|
365
|
+
if (imageFormatOf(name) !== undefined) {
|
|
366
|
+
try {
|
|
367
|
+
widthPx = readImageInfo(new Uint8Array(readFileSync(abs)))?.width;
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
widthPx = undefined;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
images.push({ path: rel, ...(modified !== undefined ? { mtimeMs: modified } : {}), ...(widthPx !== undefined ? { widthPx } : {}) });
|
|
374
|
+
}
|
|
375
|
+
const references = [];
|
|
376
|
+
const chapterTexts = [];
|
|
377
|
+
for (const [index, meta] of CHAPTER_META.entries()) {
|
|
378
|
+
const chapterAbs = nodePath.join(root, CHAPTER_DIR, `${meta.file}.md`);
|
|
379
|
+
const text = await readOptional(fs, chapterAbs, signal);
|
|
380
|
+
if (text === undefined)
|
|
381
|
+
continue;
|
|
382
|
+
chapterTexts.push({ chapter: meta.file, text });
|
|
383
|
+
for (const reference of extractReferences(text)) {
|
|
384
|
+
const resolved = await resolveImage(fs, root, chapterAbs, reference.src, signal);
|
|
385
|
+
if (resolved === undefined) {
|
|
386
|
+
references.push({ chapter: meta.file, chapterNo: index + 1, line: reference.line, alt: reference.alt, src: reference.src });
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const rel = toRel(root, resolved);
|
|
390
|
+
references.push({ chapter: meta.file, chapterNo: index + 1, line: reference.line, alt: reference.alt, src: reference.src, resolvedPath: rel });
|
|
391
|
+
if (!images.some(image => image.path === rel) && /\.(png|jpe?g|svg)$/i.test(rel)) {
|
|
392
|
+
let widthPx;
|
|
393
|
+
if (imageFormatOf(rel) !== undefined) {
|
|
394
|
+
try {
|
|
395
|
+
widthPx = readImageInfo(new Uint8Array(readFileSync(resolved)))?.width;
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
widthPx = undefined;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const modified = mtime(resolved);
|
|
402
|
+
images.push({ path: rel, ...(modified !== undefined ? { mtimeMs: modified } : {}), ...(widthPx !== undefined ? { widthPx } : {}) });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const result = checkFigures({ sources, images, references, chapterTexts });
|
|
407
|
+
const report = renderFigureReport(result, deps.now?.() ?? new Date());
|
|
408
|
+
await fs.writeText(await fs.resolve(nodePath.join(root, FIGURE_INVENTORY), { signal }), report, undefined, signal);
|
|
409
|
+
const head = `图表体检:${result.entries.length} 张图,${result.errors} 个必须修的问题、${result.warnings} 个提示。`;
|
|
410
|
+
const detail = result.issues.length === 0
|
|
411
|
+
? '✓ 源文件、导出件、正文引用、图号四者一致。'
|
|
412
|
+
: result.issues.map(issue => `${issue.severity === 'error' ? '✗' : '⚠'} ${issue.message}`).join('\n');
|
|
413
|
+
return `${head}\n${detail}\n\n完整清单:${FIGURE_INVENTORY}(导出用 thesis_figure action=export)`;
|
|
414
|
+
}
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
/**
|
|
417
|
+
* 工具入口:按 action 分派。
|
|
418
|
+
*
|
|
419
|
+
* 找不到论文工作区时给出与其它 `thesis_*` 工具一致的错误,而不是静默返回空结果。
|
|
420
|
+
*/
|
|
421
|
+
export async function runFigureAction(fs, cwd, args, options, signal, deps = {}) {
|
|
422
|
+
// guide 是「本机怎么装/怎么导」的说明,不需要论文工作区。
|
|
423
|
+
if (args.action === 'guide')
|
|
424
|
+
return drawioGuide();
|
|
425
|
+
const root = await findThesisRoot(fs, cwd, signal);
|
|
426
|
+
if (root === null)
|
|
427
|
+
throw workspaceNotFoundError(cwd);
|
|
428
|
+
switch (args.action) {
|
|
429
|
+
case 'create': return await runCreate(fs, root, args, options, signal, deps);
|
|
430
|
+
case 'export': return await runExport(fs, root, args, options, signal, deps);
|
|
431
|
+
case 'check': return await runCheck(fs, root, signal, deps);
|
|
432
|
+
default:
|
|
433
|
+
throw new Error(`未知 action「${String(args.action)}」:可用 create / export / check / guide。`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/** 工具描述(与论文写作技能的口径一致)。 */
|
|
437
|
+
export const FIGURE_TOOL_DESCRIPTION = '论文插图(draw.io 为准):create 按结构化规格生成可编辑的 06-论文/assets/图/<名字>.drawio(architecture 分层架构 / flow 流程图 / er 数据库 E-R / sequence 时序图 / custom 手写 mxfile),调用 draw.io 桌面版导出 3× PNG,并按章节已有图号自动给出「图 4-1 …」图题与可直接粘贴的 Markdown;'
|
|
438
|
+
+ 'export 把已存在的 .drawio 批量导出为 PNG/SVG(探测 PATH 与常见安装目录,失败给出确切命令与三条替代路径,绝不假装成功);'
|
|
439
|
+
+ 'check 核对源文件/导出件/正文引用/图号四者一致,写 06-论文/assets/图/图表清单.md;guide 只输出安装与手工导出指引。';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插图装配层:注册 `thesis_figure` 工具(create / export / check / guide)。
|
|
3
|
+
*
|
|
4
|
+
* 与其它模块一样,业务逻辑在 `figure/actions.ts` 与 `figure/lib/*.ts`(零 cordis 依赖,
|
|
5
|
+
* 可独立单测),本文件只做 `defineTool` 胶水。
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-thesis/figure
|
|
8
|
+
*/
|
|
9
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
10
|
+
import { type FigureOptions } from './actions.ts';
|
|
11
|
+
export { FIGURE_DIR, FIGURE_NOTES, FIGURE_INVENTORY, FIGURE_TOOL_DESCRIPTION, runFigureAction, slugify, resolveChapter, nextFigureIndex, extractReferences } from './actions.ts';
|
|
12
|
+
export type { FigureArgs, FigureOptions, FigureDeps } from './actions.ts';
|
|
13
|
+
export { parseFigureModel, describeModel, FigureSpecError, FIGURE_KINDS } from './lib/spec.ts';
|
|
14
|
+
export type { FigureKind, FigureModel } from './lib/spec.ts';
|
|
15
|
+
export { buildDrawing, LAYOUT, displayWidth, FigureLayoutError } from './lib/layout.ts';
|
|
16
|
+
export type { FigureDrawing } from './lib/layout.ts';
|
|
17
|
+
export { renderMxfile, validateMxfile, STYLE, escapeXmlAttr, labelValue, DEFAULT_PAGE_WIDTH, DEFAULT_PAGE_HEIGHT } from './lib/drawio.ts';
|
|
18
|
+
export type { FigureCell, MxFileOptions, Rect, Point } from './lib/drawio.ts';
|
|
19
|
+
export { detectDrawio, exportDiagram, renderExportNotes, drawioGuide, DEFAULT_SCALE, EXPORT_TIMEOUT_MS } from './lib/export.ts';
|
|
20
|
+
export type { DrawioDetection, DrawioEngine, ExportOutcome, ExportNotesInput } from './lib/export.ts';
|
|
21
|
+
export { checkFigures, renderFigureReport, parseCaption, stemOf, CAPTION_PATTERN } from './lib/check.ts';
|
|
22
|
+
export type { FigureCheckInput, FigureCheckResult, FigureReference, FigureIssue, FigureFileEntry } from './lib/check.ts';
|
|
23
|
+
/** 注册 `thesis_figure` 工具。 */
|
|
24
|
+
export declare function registerFigure(ctx: Context, options: FigureOptions): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插图装配层:注册 `thesis_figure` 工具(create / export / check / guide)。
|
|
3
|
+
*
|
|
4
|
+
* 与其它模块一样,业务逻辑在 `figure/actions.ts` 与 `figure/lib/*.ts`(零 cordis 依赖,
|
|
5
|
+
* 可独立单测),本文件只做 `defineTool` 胶水。
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-thesis/figure
|
|
8
|
+
*/
|
|
9
|
+
import { definePaperTool, sessionCwd, textOutput } from "../shared/index.js";
|
|
10
|
+
import { FIGURE_TOOL_DESCRIPTION, runFigureAction } from "./actions.js";
|
|
11
|
+
export { FIGURE_DIR, FIGURE_NOTES, FIGURE_INVENTORY, FIGURE_TOOL_DESCRIPTION, runFigureAction, slugify, resolveChapter, nextFigureIndex, extractReferences } from "./actions.js";
|
|
12
|
+
export { parseFigureModel, describeModel, FigureSpecError, FIGURE_KINDS } from "./lib/spec.js";
|
|
13
|
+
export { buildDrawing, LAYOUT, displayWidth, FigureLayoutError } from "./lib/layout.js";
|
|
14
|
+
export { renderMxfile, validateMxfile, STYLE, escapeXmlAttr, labelValue, DEFAULT_PAGE_WIDTH, DEFAULT_PAGE_HEIGHT } from "./lib/drawio.js";
|
|
15
|
+
export { detectDrawio, exportDiagram, renderExportNotes, drawioGuide, DEFAULT_SCALE, EXPORT_TIMEOUT_MS } from "./lib/export.js";
|
|
16
|
+
export { checkFigures, renderFigureReport, parseCaption, stemOf, CAPTION_PATTERN } from "./lib/check.js";
|
|
17
|
+
/** 注册 `thesis_figure` 工具。 */
|
|
18
|
+
export function registerFigure(ctx, options) {
|
|
19
|
+
ctx.tools.register(definePaperTool({
|
|
20
|
+
name: 'thesis_figure',
|
|
21
|
+
description: FIGURE_TOOL_DESCRIPTION,
|
|
22
|
+
parameters: {
|
|
23
|
+
action: { type: 'string', required: true, description: 'create(生成 .drawio + 导出 PNG)| export(把已有 .drawio 批量导出)| check(图表清单与一致性检查)| guide(安装与手工导出指引)' },
|
|
24
|
+
kind: { type: 'string', description: 'create 时必填:architecture(分层架构图)| flow(流程图)| er(数据库 E-R 图)| sequence(时序图)| custom(直接贴 mxfile XML)' },
|
|
25
|
+
name: { type: 'string', description: '文件名主干(缺省取 title),如 arch-overview;落在 06-论文/assets/图/<name>.drawio' },
|
|
26
|
+
title: { type: 'string', description: '图名,用于 draw.io 页签与图题,如「系统总体架构图」' },
|
|
27
|
+
chapter: { type: 'string', description: '写在哪一章(如 04-系统设计 或章号 4):据此按该章已有图号自动编号出「图 4-1 …」' },
|
|
28
|
+
caption: { type: 'string', description: '直接指定完整图题(形如「图 4-1 系统总体架构图」);给了就不再用 chapter 自动编号' },
|
|
29
|
+
spec: { type: 'object', additionalProperties: true, description: '结构:architecture={layers:[{name,modules}],edges?}|flow={nodes,edges}|er={entities,relations?}|sequence={participants,messages}|custom={xml}' },
|
|
30
|
+
export: { type: 'boolean', description: 'create 时是否立即导出 PNG,默认 true(本机没有 draw.io 时只提示、不失败)' },
|
|
31
|
+
force: { type: 'boolean', description: 'create 时覆盖同名 .drawio,默认 false(覆盖前把旧文件另存为 <名字>-<时间戳>.drawio)' },
|
|
32
|
+
format: { type: 'string', description: '导出格式:png(默认,论文用)| svg(幻灯/网页用)' },
|
|
33
|
+
scale: { type: 'number', description: '导出倍率,默认 3(约 370dpi)' },
|
|
34
|
+
},
|
|
35
|
+
output: textOutput(),
|
|
36
|
+
async execute(rawArgs, exec) {
|
|
37
|
+
return await runFigureAction(ctx.fs, sessionCwd(exec), rawArgs, options, exec.signal);
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
40
|
+
}
|