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.
Files changed (52) hide show
  1. package/README.md +5 -4
  2. package/docs/config.md +12 -0
  3. package/docs/design.md +95 -2
  4. package/docs/release.md +94 -85
  5. package/docs/tools.md +8 -5
  6. package/docs/troubleshooting.md +18 -0
  7. package/docs/usage.md +4 -1
  8. package/lib/config.d.ts +13 -0
  9. package/lib/config.js +8 -1
  10. package/lib/figure/actions.d.ts +83 -0
  11. package/lib/figure/actions.js +439 -0
  12. package/lib/figure/index.d.ts +24 -0
  13. package/lib/figure/index.js +40 -0
  14. package/lib/figure/lib/check.d.ts +83 -0
  15. package/lib/figure/lib/check.js +214 -0
  16. package/lib/figure/lib/drawio.d.ts +123 -0
  17. package/lib/figure/lib/drawio.js +156 -0
  18. package/lib/figure/lib/export.d.ts +120 -0
  19. package/lib/figure/lib/export.js +308 -0
  20. package/lib/figure/lib/layout.d.ts +78 -0
  21. package/lib/figure/lib/layout.js +496 -0
  22. package/lib/figure/lib/spec.d.ts +114 -0
  23. package/lib/figure/lib/spec.js +290 -0
  24. package/lib/index.d.ts +1 -0
  25. package/lib/index.js +5 -1
  26. package/lib/paper/lib/check.d.ts +5 -0
  27. package/lib/paper/lib/check.js +21 -0
  28. package/lib/paper/lib/docx.d.ts +26 -0
  29. package/lib/paper/lib/docx.js +190 -27
  30. package/lib/paper/lib/image.d.ts +19 -0
  31. package/lib/paper/lib/image.js +82 -0
  32. package/lib/paper/lib/layout.js +26 -1
  33. package/lib/paper/lib/lit-refs.d.ts +30 -0
  34. package/lib/paper/lib/lit-refs.js +394 -0
  35. package/lib/paper/lib/lit-verify.d.ts +79 -0
  36. package/lib/paper/lib/lit-verify.js +556 -0
  37. package/lib/paper/register.d.ts +1 -1
  38. package/lib/paper/register.js +37 -1
  39. package/lib/paper/tools/build.d.ts +4 -0
  40. package/lib/paper/tools/build.js +79 -4
  41. package/lib/paper/tools/lit-verify.d.ts +35 -0
  42. package/lib/paper/tools/lit-verify.js +193 -0
  43. package/lib/ppt/check.js +5 -0
  44. package/lib/ppt/design.d.ts +86 -0
  45. package/lib/ppt/design.js +218 -0
  46. package/package.json +4 -1
  47. package/skills/thesis-defense/SKILL.md +25 -1
  48. package/skills/thesis-eng-design/SKILL.md +40 -20
  49. package/skills/thesis-literature/SKILL.md +28 -5
  50. package/skills/thesis-opener/SKILL.md +1 -1
  51. package/skills/thesis-pipeline/SKILL.md +4 -3
  52. package/skills/thesis-writing/SKILL.md +4 -1
@@ -0,0 +1,290 @@
1
+ /**
2
+ * 图的「结构化规格」:模型只需描述**有什么**,不描述**放在哪**。
3
+ *
4
+ * 教学场景里的图之所以糙,多半不是因为画得少,而是因为让模型直接写坐标:
5
+ * 节点一多就重叠、箭头乱穿、字号忽大忽小。于是这里把输入收窄成业务语义
6
+ * (层与模块、步骤与分支、实体与字段、参与者与消息),坐标交给
7
+ * `layout.ts` 的确定性版面算法——同样的输入永远得到同一张图。
8
+ *
9
+ * 解析是**严格**的:字段缺失、引用不存在的节点、重名,全部当场报错并给出示例,
10
+ * 绝不让一张「少了两个模块」的图静默落盘。
11
+ *
12
+ * @module dsh-thesis/figure/spec
13
+ */
14
+ /** 全部种类的取值(用于工具参数的枚举与报错提示)。 */
15
+ export const FIGURE_KINDS = ['architecture', 'flow', 'er', 'sequence', 'custom'];
16
+ /** 规格非法:带上**怎么改**的提示,模型据此重试。 */
17
+ export class FigureSpecError extends Error {
18
+ code = 'INVALID_FIGURE_SPEC';
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = 'FigureSpecError';
22
+ }
23
+ }
24
+ // ---------------------------------------------------------------------------
25
+ // 校验小工具
26
+ // ---------------------------------------------------------------------------
27
+ function fail(message) {
28
+ throw new FigureSpecError(message);
29
+ }
30
+ function asRecord(value, path) {
31
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
32
+ fail(`${path} 必须是对象,实际是 ${value === null ? 'null' : Array.isArray(value) ? '数组' : typeof value}`);
33
+ }
34
+ return value;
35
+ }
36
+ function asArray(value, path) {
37
+ if (!Array.isArray(value))
38
+ fail(`${path} 必须是数组`);
39
+ return value;
40
+ }
41
+ function requiredText(value, path) {
42
+ if (typeof value !== 'string')
43
+ fail(`${path} 必须是字符串`);
44
+ const text = value.trim();
45
+ if (text === '')
46
+ fail(`${path} 不能为空`);
47
+ return text;
48
+ }
49
+ function optionalText(value, path) {
50
+ if (value === undefined || value === null)
51
+ return undefined;
52
+ if (typeof value !== 'string')
53
+ fail(`${path} 必须是字符串`);
54
+ const text = value.trim();
55
+ return text === '' ? undefined : text;
56
+ }
57
+ function optionalBool(value, path) {
58
+ if (value === undefined || value === null)
59
+ return undefined;
60
+ if (typeof value !== 'boolean')
61
+ fail(`${path} 必须是布尔值`);
62
+ return value;
63
+ }
64
+ /** 一个引用(边/关系)的解析表:同时接受 id 与显示文本。 */
65
+ function makeResolver(entries, path) {
66
+ const byId = new Map();
67
+ const byText = new Map();
68
+ for (const entry of entries) {
69
+ if (byId.has(entry.id))
70
+ fail(`${path} 里 id「${entry.id}」重复`);
71
+ byId.set(entry.id, entry.id);
72
+ byText.set(entry.text, entry.id);
73
+ }
74
+ return (name) => {
75
+ const id = byId.get(name) ?? byText.get(name);
76
+ if (id === undefined) {
77
+ const known = [...byId.keys()].join('、');
78
+ fail(`${path} 引用了不存在的节点「${name}」;可用的是:${known}`);
79
+ }
80
+ return id;
81
+ };
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // 各类型的解析
85
+ // ---------------------------------------------------------------------------
86
+ const ARCH_EXAMPLE = '{"layers":[{"name":"表现层","modules":["Vue 页面","路由与状态"]},{"name":"业务层","modules":["任务服务","用户服务"]}],"edges":[{"from":"Vue 页面","to":"任务服务","label":"HTTP/JSON"}]}';
87
+ function parseArchitecture(raw) {
88
+ const record = asRecord(raw, 'architecture 的 spec');
89
+ const layersRaw = asArray(record.layers, 'architecture.layers(不能为空)');
90
+ if (layersRaw.length === 0)
91
+ fail(`architecture.layers 不能为空,示例:${ARCH_EXAMPLE}`);
92
+ const layers = layersRaw.map((layerRaw, index) => {
93
+ const path = `architecture.layers[${index}]`;
94
+ const layer = asRecord(layerRaw, path);
95
+ const name = requiredText(layer.name, `${path}.name`);
96
+ const note = optionalText(layer.note, `${path}.note`);
97
+ const modulesRaw = asArray(layer.modules, `${path}.modules(每层至少一个模块)`);
98
+ if (modulesRaw.length === 0)
99
+ fail(`${path}.modules 不能为空:每一层至少要有一个模块`);
100
+ const modules = modulesRaw.map((moduleRaw, moduleIndex) => {
101
+ const modulePath = `${path}.modules[${moduleIndex}]`;
102
+ if (typeof moduleRaw === 'string')
103
+ return { text: requiredText(moduleRaw, modulePath) };
104
+ const module = asRecord(moduleRaw, modulePath);
105
+ const text = requiredText(module.text, `${modulePath}.text`);
106
+ const moduleNote = optionalText(module.note, `${modulePath}.note`);
107
+ const accent = optionalBool(module.accent, `${modulePath}.accent`);
108
+ return { text, ...(moduleNote !== undefined ? { note: moduleNote } : {}), ...(accent === true ? { accent: true } : {}) };
109
+ });
110
+ return { name, ...(note !== undefined ? { note } : {}), modules };
111
+ });
112
+ // 模块名在全图内必须唯一:边用名字引用节点,重名就无法判断指向哪一个。
113
+ // 层名同理:连通向层带的默认箭头也靠名字找终点。
114
+ const layerNames = new Set();
115
+ for (const layer of layers) {
116
+ if (layerNames.has(layer.name))
117
+ fail(`architecture 里层名「${layer.name}」重复:层名用于连线引用,必须唯一。`);
118
+ layerNames.add(layer.name);
119
+ }
120
+ const seen = new Map();
121
+ for (const layer of layers) {
122
+ for (const module of layer.modules) {
123
+ const previous = seen.get(module.text);
124
+ if (previous !== undefined) {
125
+ fail(`architecture 里模块名「${module.text}」重复(分别在「${previous}」与「${layer.name}」)。边用名字引用节点,模块名必须全图唯一。`);
126
+ }
127
+ seen.set(module.text, layer.name);
128
+ }
129
+ }
130
+ const targets = [
131
+ ...layers.map(layer => ({ id: layer.name, text: layer.name })),
132
+ ...layers.flatMap(layer => layer.modules.map(module => ({ id: module.text, text: module.text }))),
133
+ ];
134
+ const resolve = makeResolver(targets, 'architecture.edges');
135
+ const edgesRaw = record.edges === undefined ? [] : asArray(record.edges, 'architecture.edges');
136
+ const edges = edgesRaw.map((edgeRaw, index) => {
137
+ const path = `architecture.edges[${index}]`;
138
+ const edge = asRecord(edgeRaw, path);
139
+ const from = resolve(requiredText(edge.from, `${path}.from`));
140
+ const to = resolve(requiredText(edge.to, `${path}.to`));
141
+ if (from === to)
142
+ fail(`${path} 的 from 与 to 相同(「${from}」),图里不允许自环`);
143
+ const label = optionalText(edge.label, `${path}.label`);
144
+ return { from, to, ...(label !== undefined ? { label } : {}) };
145
+ });
146
+ return { layers, edges };
147
+ }
148
+ const FLOW_EXAMPLE = '{"nodes":[{"id":"start","text":"提交申请","kind":"start"},{"id":"check","text":"参数合法?","kind":"decision"},{"id":"save","text":"写入数据库","kind":"process"}],"edges":[{"from":"start","to":"check"},{"from":"check","to":"save","label":"是"}]}';
149
+ function parseFlow(raw) {
150
+ const record = asRecord(raw, 'flow 的 spec');
151
+ const nodesRaw = asArray(record.nodes, 'flow.nodes(不能为空)');
152
+ if (nodesRaw.length === 0)
153
+ fail(`flow.nodes 不能为空,示例:${FLOW_EXAMPLE}`);
154
+ const nodes = nodesRaw.map((nodeRaw, index) => {
155
+ const path = `flow.nodes[${index}]`;
156
+ const node = asRecord(nodeRaw, path);
157
+ const id = optionalText(node.id, `${path}.id`) ?? `n${index + 1}`;
158
+ const text = requiredText(node.text, `${path}.text`);
159
+ const note = optionalText(node.note, `${path}.note`);
160
+ const kindRaw = optionalText(node.kind, `${path}.kind`) ?? 'process';
161
+ if (kindRaw !== 'start' && kindRaw !== 'process' && kindRaw !== 'decision' && kindRaw !== 'end') {
162
+ fail(`${path}.kind 只能是 start / process / decision / end,实际是「${kindRaw}」`);
163
+ }
164
+ return { id, text, kind: kindRaw, ...(note !== undefined ? { note } : {}) };
165
+ });
166
+ if (nodes.filter(node => node.kind === 'start').length === 0) {
167
+ fail('flow 至少需要一个 kind="start" 的节点(流程从哪开始必须说清楚)');
168
+ }
169
+ const resolve = makeResolver(nodes.map(node => ({ id: node.id, text: node.text })), 'flow.edges');
170
+ const edgesRaw = asArray(record.edges, 'flow.edges(没有连线的流程无法成图)');
171
+ if (edgesRaw.length === 0)
172
+ fail('flow.edges 不能为空:至少要把起点连到下一个节点');
173
+ const edges = edgesRaw.map((edgeRaw, index) => {
174
+ const path = `flow.edges[${index}]`;
175
+ const edge = asRecord(edgeRaw, path);
176
+ const from = resolve(requiredText(edge.from, `${path}.from`));
177
+ const to = resolve(requiredText(edge.to, `${path}.to`));
178
+ if (from === to)
179
+ fail(`${path} 的 from 与 to 相同(「${from}」),图里不允许自环`);
180
+ const label = optionalText(edge.label, `${path}.label`);
181
+ return { from, to, ...(label !== undefined ? { label } : {}) };
182
+ });
183
+ return { nodes, edges };
184
+ }
185
+ const ER_EXAMPLE = '{"entities":[{"name":"用户","fields":[{"name":"id","type":"bigint","key":true},{"name":"用户名","type":"varchar(32)"}]},{"name":"任务","fields":[{"name":"id","type":"bigint","key":true},{"name":"标题","type":"varchar(64)"}]}],"relations":[{"from":"用户","to":"任务","label":"创建","cardinality":"1:N"}]}';
186
+ function parseEr(raw) {
187
+ const record = asRecord(raw, 'er 的 spec');
188
+ const entitiesRaw = asArray(record.entities, 'er.entities(不能为空)');
189
+ if (entitiesRaw.length === 0)
190
+ fail(`er.entities 不能为空,示例:${ER_EXAMPLE}`);
191
+ const entities = entitiesRaw.map((entityRaw, index) => {
192
+ const path = `er.entities[${index}]`;
193
+ const entity = asRecord(entityRaw, path);
194
+ const name = requiredText(entity.name, `${path}.name`);
195
+ const fieldsRaw = asArray(entity.fields, `${path}.fields(每个实体至少一个字段)`);
196
+ if (fieldsRaw.length === 0)
197
+ fail(`${path}.fields 不能为空:实体「${name}」至少要有一个字段`);
198
+ const fields = fieldsRaw.map((fieldRaw, fieldIndex) => {
199
+ const fieldPath = `${path}.fields[${fieldIndex}]`;
200
+ const field = asRecord(fieldRaw, fieldPath);
201
+ const fieldName = requiredText(field.name, `${fieldPath}.name`);
202
+ const type = optionalText(field.type, `${fieldPath}.type`);
203
+ const key = optionalBool(field.key, `${fieldPath}.key`);
204
+ return { name: fieldName, ...(type !== undefined ? { type } : {}), ...(key === true ? { key: true } : {}) };
205
+ });
206
+ return { name, fields };
207
+ });
208
+ const resolve = makeResolver(entities.map(entity => ({ id: entity.name, text: entity.name })), 'er.relations');
209
+ const relationsRaw = record.relations === undefined ? [] : asArray(record.relations, 'er.relations');
210
+ const relations = relationsRaw.map((relationRaw, index) => {
211
+ const path = `er.relations[${index}]`;
212
+ const relation = asRecord(relationRaw, path);
213
+ const from = resolve(requiredText(relation.from, `${path}.from`));
214
+ const to = resolve(requiredText(relation.to, `${path}.to`));
215
+ if (from === to)
216
+ fail(`${path} 的 from 与 to 相同(「${from}」),图里不允许自环`);
217
+ const label = optionalText(relation.label, `${path}.label`);
218
+ const cardinality = optionalText(relation.cardinality, `${path}.cardinality`);
219
+ if (cardinality !== undefined && !/^[1NnMm]\s*[::]\s*[1NnMm]$/.test(cardinality)) {
220
+ fail(`${path}.cardinality 只能写成 1:1 / 1:N / N:1 / M:N,实际是「${cardinality}」`);
221
+ }
222
+ return { from, to, ...(label !== undefined ? { label } : {}), ...(cardinality !== undefined ? { cardinality } : {}) };
223
+ });
224
+ return { entities, relations };
225
+ }
226
+ const SEQ_EXAMPLE = '{"participants":["浏览器","Web 服务","MySQL"],"messages":[{"from":"浏览器","to":"Web 服务","text":"POST /api/tasks"},{"from":"Web 服务","to":"MySQL","text":"INSERT"},{"from":"MySQL","to":"Web 服务","text":"ok","kind":"return"}]}';
227
+ function parseSequence(raw) {
228
+ const record = asRecord(raw, 'sequence 的 spec');
229
+ const participantsRaw = asArray(record.participants, 'sequence.participants(不能为空)');
230
+ if (participantsRaw.length < 2)
231
+ fail(`sequence.participants 至少要有两个参与者,示例:${SEQ_EXAMPLE}`);
232
+ const participants = participantsRaw.map((participantRaw, index) => {
233
+ const path = `sequence.participants[${index}]`;
234
+ if (typeof participantRaw === 'string') {
235
+ const text = requiredText(participantRaw, path);
236
+ return { id: text, text };
237
+ }
238
+ const participant = asRecord(participantRaw, path);
239
+ const text = requiredText(participant.text, `${path}.text`);
240
+ const id = optionalText(participant.id, `${path}.id`) ?? text;
241
+ return { id, text };
242
+ });
243
+ const resolve = makeResolver(participants, 'sequence.messages');
244
+ const messagesRaw = asArray(record.messages, 'sequence.messages(不能为空)');
245
+ if (messagesRaw.length === 0)
246
+ fail(`sequence.messages 不能为空,示例:${SEQ_EXAMPLE}`);
247
+ const messages = messagesRaw.map((messageRaw, index) => {
248
+ const path = `sequence.messages[${index}]`;
249
+ const message = asRecord(messageRaw, path);
250
+ const from = resolve(requiredText(message.from, `${path}.from`));
251
+ const to = resolve(requiredText(message.to, `${path}.to`));
252
+ const text = requiredText(message.text, `${path}.text`);
253
+ const kindRaw = optionalText(message.kind, `${path}.kind`) ?? 'call';
254
+ if (kindRaw !== 'call' && kindRaw !== 'return')
255
+ fail(`${path}.kind 只能是 call / return,实际是「${kindRaw}」`);
256
+ return { from, to, text, kind: kindRaw };
257
+ });
258
+ return { participants, messages };
259
+ }
260
+ function parseCustom(raw) {
261
+ const record = asRecord(raw, 'custom 的 spec');
262
+ const xml = requiredText(record.xml, 'custom.xml(draw.io 文件全文)');
263
+ return { xml };
264
+ }
265
+ /** 解析并校验规格;成功时返回规范化模型,失败时抛 {@link FigureSpecError}。 */
266
+ export function parseFigureModel(kind, raw) {
267
+ if (raw === undefined || raw === null) {
268
+ fail(`缺少 spec:${kind} 图需要结构化规格,另可用 thesis_figure action=create 的示例参数。`);
269
+ }
270
+ switch (kind) {
271
+ case 'architecture': return { kind, model: parseArchitecture(raw) };
272
+ case 'flow': return { kind, model: parseFlow(raw) };
273
+ case 'er': return { kind, model: parseEr(raw) };
274
+ case 'sequence': return { kind, model: parseSequence(raw) };
275
+ case 'custom': return { kind, model: parseCustom(raw) };
276
+ default:
277
+ fail(`未知图种类「${kind}」,可选:${FIGURE_KINDS.join(' / ')}`);
278
+ }
279
+ }
280
+ /** 一句话概括模型规模(工具返回值里给模型看,确认自己传对了)。 */
281
+ export function describeModel(model) {
282
+ switch (model.kind) {
283
+ case 'architecture':
284
+ return `${model.model.layers.length} 层 / ${model.model.layers.reduce((sum, layer) => sum + layer.modules.length, 0)} 个模块 / ${model.model.edges.length} 条连线`;
285
+ case 'flow': return `${model.model.nodes.length} 个节点 / ${model.model.edges.length} 条连线`;
286
+ case 'er': return `${model.model.entities.length} 个实体 / ${model.model.relations.length} 条关系`;
287
+ case 'sequence': return `${model.model.participants.length} 个参与者 / ${model.model.messages.length} 条消息`;
288
+ case 'custom': return `手写 mxfile(${model.model.xml.length} 字符)`;
289
+ }
290
+ }
package/lib/index.d.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * | `intake/` | 材料到手后的逐题追问 → 意图规格(唯一真相) |
13
13
  * | `dedup/` | 原创性自查:本地相似度度量 + 改写处方 + 复测 |
14
14
  * | `ppt/` | 答辩幻灯:Marp Markdown + 外部转换器探测 |
15
+ * | `figure/` | 论文插图:draw.io 源文件 + 导出 PNG + 图表体检 |
15
16
  * | `memory/` | 跨会话事实:学校规范、导师要求、写作偏好(学生不必直接调用) |
16
17
  * | `codewalk/` | 代码演练:把 AI 写的系统拆成答辩讲得清的模块 |
17
18
  * | `gates/` | 闸门:写作意图闸门 + 不可逆操作闸门 + 意图规格闸门 |
package/lib/index.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * | `intake/` | 材料到手后的逐题追问 → 意图规格(唯一真相) |
13
13
  * | `dedup/` | 原创性自查:本地相似度度量 + 改写处方 + 复测 |
14
14
  * | `ppt/` | 答辩幻灯:Marp Markdown + 外部转换器探测 |
15
+ * | `figure/` | 论文插图:draw.io 源文件 + 导出 PNG + 图表体检 |
15
16
  * | `memory/` | 跨会话事实:学校规范、导师要求、写作偏好(学生不必直接调用) |
16
17
  * | `codewalk/` | 代码演练:把 AI 写的系统拆成答辩讲得清的模块 |
17
18
  * | `gates/` | 闸门:写作意图闸门 + 不可逆操作闸门 + 意图规格闸门 |
@@ -30,6 +31,7 @@ import { Config, resolveConfig } from "./config.js";
30
31
  import { registerDedup } from "./dedup/index.js";
31
32
  import { installDangerousGate } from "./gates/dangerous.js";
32
33
  import { installWritingGate } from "./gates/prompt.js";
34
+ import { registerFigure } from "./figure/index.js";
33
35
  import { registerIngest } from "./ingest/index.js";
34
36
  import { installIntakeGate } from "./intake/gate.js";
35
37
  import { registerIntake } from "./intake/index.js";
@@ -57,7 +59,7 @@ export function apply(ctx, config = {}) {
57
59
  registerFacts(ctx, resolved.memoryPath);
58
60
  // 事实回灌:相关时自动把已确认的稳定事实放回上下文(不相关则完全静默)。
59
61
  installFactRecall(ctx, resolved.recall);
60
- // 论文工作区:11 个工具。
62
+ // 论文工作区:13 个工具。
61
63
  registerPaper(ctx, { skillsDir: SKILLS_DIR });
62
64
  // 代码演练(答辩层):3 个工具 + 进度卡注入 + code-walkthrough 技能。
63
65
  registerCodewalk(ctx, resolved.codeWalkthrough);
@@ -69,6 +71,8 @@ export function apply(ctx, config = {}) {
69
71
  registerDedup(ctx, { similarity: resolved.similarity });
70
72
  // 答辩:Markdown 幻灯 + 外部转换器探测。
71
73
  registerPpt(ctx, { ppt: resolved.ppt });
74
+ // 论文插图:draw.io 源文件(唯一真源)+ 导出 PNG + 图表体检。
75
+ registerFigure(ctx, resolved.figure);
72
76
  // 自动驾驶:论文工作区里开口就问一次意图 → 建目标自动续轮;关卡由人拍板。
73
77
  // 不提供任何斜杠命令:人只在「开头选一次」与「过关卡」两处出现。
74
78
  installAutopilot(ctx, { ...resolved.autopilot, specRel: resolved.intake.specRel });
@@ -27,6 +27,11 @@ export interface CheckReport {
27
27
  readonly sections: readonly CheckSection[];
28
28
  readonly allOk: boolean;
29
29
  }
30
+ /** 统计一章里的 Markdown 表格块数与「表 x-y」表题数(用于查"有表无题")。 */
31
+ export declare function countTablesAndCaptions(text: string, chapterNo: number): {
32
+ tables: number;
33
+ captions: number;
34
+ };
30
35
  export declare function runGlobalCheck(input: CheckInput): CheckReport;
31
36
  export declare function renderCheckReport(report: CheckReport): string;
32
37
  export { CHAPTER_META };
@@ -104,6 +104,21 @@ function checkWordCountsGlobal(input) {
104
104
  // ---------------------------------------------------------------------------
105
105
  // 3. 图/表编号(全文逐章)
106
106
  // ---------------------------------------------------------------------------
107
+ /** 统计一章里的 Markdown 表格块数与「表 x-y」表题数(用于查"有表无题")。 */
108
+ export function countTablesAndCaptions(text, chapterNo) {
109
+ const lines = text.split(/\r?\n/);
110
+ let tables = 0;
111
+ let inTable = false;
112
+ for (const line of lines) {
113
+ const isRow = line.trim().startsWith('|');
114
+ if (isRow && !inTable)
115
+ tables += 1;
116
+ inTable = isRow;
117
+ }
118
+ const pattern = new RegExp(`^表\\s*${chapterNo}\\s*[-–—]\\s*\\d+`, 'gm');
119
+ const captions = (text.match(pattern) ?? []).length;
120
+ return { tables, captions };
121
+ }
107
122
  function checkFiguresGlobal(input) {
108
123
  const lines = [];
109
124
  let problems = 0;
@@ -115,6 +130,12 @@ function checkFiguresGlobal(input) {
115
130
  lines.push(`✗ ${chapter.name} ${finding.detail}`);
116
131
  }
117
132
  }
133
+ // 表题缺失:有表格却没有「表 x-y 名称」的行。学位论文要求每张表都有表序与表名。
134
+ const { tables, captions } = countTablesAndCaptions(chapter.text, chapter.no);
135
+ if (tables > captions) {
136
+ problems += 1;
137
+ lines.push(`✗ ${chapter.name}:${tables} 张表但只有 ${captions} 个表题;每张表上方都要有「表 ${chapter.no}-x 名称」`);
138
+ }
118
139
  }
119
140
  if (problems === 0)
120
141
  lines.push('✓ 各章图/表编号章号正确、序号连续(无图表的章不计)。');
@@ -22,14 +22,40 @@ type Block = {
22
22
  } | {
23
23
  readonly kind: 'table';
24
24
  readonly rows: readonly (readonly string[])[];
25
+ } | {
26
+ readonly kind: 'image';
27
+ readonly alt: string;
28
+ readonly src: string;
25
29
  };
26
30
  export declare function parseMarkdownBlocks(markdown: string): Block[];
31
+ /** 表题行(`表 4-1 模块划分`):走 Caption 样式居中,与图题同一套口径。 */
32
+ export declare function isTableCaption(text: string): boolean;
33
+ /**
34
+ * 三线表:表格只靠三条横线成形,不画竖线、不做隔行底纹。
35
+ * 表头跨页重复(tblHeader),表内五号字,表头加粗居中,数字列居中。
36
+ */
37
+ export declare function renderTableXml(rows: readonly (readonly string[])[]): string;
38
+ /** 要嵌进 docx 的图片(由 thesis_build 从章节里的 `![](路径)` 收集)。 */
39
+ export interface DocxImage {
40
+ readonly data: Uint8Array;
41
+ /** 像素宽(文件头解析得到)。 */
42
+ readonly width: number;
43
+ /** 像素高。 */
44
+ readonly height: number;
45
+ readonly format: 'png' | 'jpeg';
46
+ }
27
47
  export interface DocxOptions {
28
48
  readonly title: string;
29
49
  readonly markdown: string;
50
+ /** 图片绝对路径 → 图片字节与像素尺寸;缺省的图片降级为一行占位文字。 */
51
+ readonly images?: ReadonlyMap<string, DocxImage>;
30
52
  }
31
53
  /**
32
54
  * 生成完整 .docx 字节。title 渲染为封面页;markdown 为正文(章节合并内容)。
55
+ *
56
+ * 图片按 `images` 里的字节嵌入:`![图 4-1 系统总体架构图](绝对路径)` 会变成
57
+ * 「居中的图 + 图下方的图题」。找不到字节的图片降级成一行可读的占位文字——
58
+ * 缺图必须在 Word 里一眼看见,不能静默消失。
33
59
  */
34
60
  export declare function buildDocx(opts: DocxOptions): Uint8Array;
35
61
  export {};