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
@@ -23,6 +23,14 @@ export function parseMarkdownBlocks(markdown) {
23
23
  i += 1;
24
24
  continue;
25
25
  }
26
+ // 图片:独占一行的 `![图题](路径)`;路径可写成 `<...>`(构建时会改写为绝对路径)。
27
+ // 图题(alt)在图**下方**,与论文规范一致。
28
+ const image = /^!\[([^\]]*)\]\(\s*(?:<([^>]+)>|([^)\s]+))(?:\s+"[^"]*")?\s*\)$/.exec(trimmed);
29
+ if (image !== null) {
30
+ blocks.push({ kind: 'image', alt: image[1] ?? '', src: image[2] ?? image[3] ?? '' });
31
+ i += 1;
32
+ continue;
33
+ }
26
34
  // 表格:连续的 | 行(跳过分隔行)
27
35
  if (trimmed.startsWith('|') && trimmed.endsWith('|')) {
28
36
  const rows = [];
@@ -88,8 +96,12 @@ function parseInline(text) {
88
96
  }
89
97
  return runs;
90
98
  }
91
- function runsXml(runs) {
92
- return runs.map(run => `<w:r><w:rPr>${run.bold ? '<w:b/>' : ''}</w:rPr><w:t xml:space="preserve">${escapeXml(run.text)}</w:t></w:r>`).join('');
99
+ function runsXml(runs, opts) {
100
+ const sizeXml = opts?.size !== undefined ? `<w:sz w:val="${opts.size}"/><w:szCs w:val="${opts.size}"/>` : '';
101
+ return runs.map(run => {
102
+ const bold = opts?.bold ?? run.bold;
103
+ return `<w:r><w:rPr>${bold ? '<w:b/>' : ''}${sizeXml}</w:rPr><w:t xml:space="preserve">${escapeXml(run.text)}</w:t></w:r>`;
104
+ }).join('');
93
105
  }
94
106
  function paragraphXml(text, styleId, pageBreakBefore = false) {
95
107
  const pPr = styleId !== undefined || pageBreakBefore
@@ -97,41 +109,79 @@ function paragraphXml(text, styleId, pageBreakBefore = false) {
97
109
  : '';
98
110
  return `<w:p>${pPr}${runsXml(parseInline(text))}</w:p>`;
99
111
  }
112
+ /** 表题行(`表 4-1 模块划分`):走 Caption 样式居中,与图题同一套口径。 */
113
+ export function isTableCaption(text) {
114
+ return /^表\s*\d+\s*[-–—]\s*\d+\s+\S/.test(text);
115
+ }
100
116
  function blockXml(block) {
101
117
  switch (block.kind) {
102
118
  case 'h1': return paragraphXml(block.text, 'Heading1', true);
103
119
  case 'h2': return paragraphXml(block.text, 'Heading2');
104
120
  case 'h3': return paragraphXml(block.text, 'Heading3');
105
- case 'p': return paragraphXml(block.text);
121
+ case 'p': return isTableCaption(block.text) ? paragraphXml(block.text, 'Caption') : paragraphXml(block.text);
106
122
  case 'bullet': return paragraphXml(`• ${block.text}`, 'ListBullet');
107
- case 'table': {
108
- const rows = block.rows.map(row => {
109
- const cells = row.map(cell => `<w:tc><w:p>${runsXml(parseInline(cell))}</w:p></w:tc>`).join('');
110
- return `<w:tr>${cells}</w:tr>`;
111
- }).join('');
112
- return `<w:tbl><w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="0" w:type="auto"/></w:tblPr>${rows}</w:tbl>`;
113
- }
123
+ case 'table': return renderTableXml(block.rows);
114
124
  }
115
125
  }
116
126
  // ---------------------------------------------------------------------------
127
+ // 表格:三线表(顶线 1.5pt / 栏目线 0.75pt / 底线 1.5pt,无竖线)
128
+ // ---------------------------------------------------------------------------
129
+ const TABLE_HALF_POINTS = 21; // 五号 10.5pt
130
+ const NUMERIC_CELL = /^[\d\s.,%+\-–—~/×x≈]*\s*(?:ms|s|秒|个|次|%)?$/;
131
+ function isNumericColumn(rows, column) {
132
+ const cells = rows.slice(1).map(row => row[column]?.trim() ?? '').filter(cell => cell !== '');
133
+ if (cells.length === 0)
134
+ return false;
135
+ return cells.every(cell => NUMERIC_CELL.test(cell) && /\d/.test(cell));
136
+ }
137
+ function tableCellXml(text, header, center) {
138
+ const rules = header
139
+ ? '<w:tcBorders><w:bottom w:val="single" w:sz="6" w:space="0" w:color="000000"/></w:tcBorders>'
140
+ : '';
141
+ const paragraph = `<w:p><w:pPr><w:jc w:val="${center ? 'center' : 'left'}"/><w:spacing w:before="30" w:after="30"/></w:pPr>` +
142
+ `${runsXml(parseInline(text), { size: TABLE_HALF_POINTS, bold: header })}</w:p>`;
143
+ return `<w:tc><w:tcPr>${rules}<w:vAlign w:val="center"/></w:tcPr>${paragraph}</w:tc>`;
144
+ }
145
+ /**
146
+ * 三线表:表格只靠三条横线成形,不画竖线、不做隔行底纹。
147
+ * 表头跨页重复(tblHeader),表内五号字,表头加粗居中,数字列居中。
148
+ */
149
+ export function renderTableXml(rows) {
150
+ if (rows.length === 0)
151
+ return '';
152
+ const columns = Math.max(...rows.map(row => row.length));
153
+ const borders = '<w:tblBorders>' +
154
+ '<w:top w:val="single" w:sz="12" w:space="0" w:color="000000"/>' +
155
+ '<w:left w:val="none" w:sz="0" w:space="0" w:color="auto"/>' +
156
+ '<w:bottom w:val="single" w:sz="12" w:space="0" w:color="000000"/>' +
157
+ '<w:right w:val="none" w:sz="0" w:space="0" w:color="auto"/>' +
158
+ '<w:insideH w:val="none" w:sz="0" w:space="0" w:color="auto"/>' +
159
+ '<w:insideV w:val="none" w:sz="0" w:space="0" w:color="auto"/>' +
160
+ '</w:tblBorders>';
161
+ const margins = '<w:tblCellMar><w:top w:w="40" w:type="dxa"/><w:left w:w="85" w:type="dxa"/>' +
162
+ '<w:bottom w:w="40" w:type="dxa"/><w:right w:w="85" w:type="dxa"/></w:tblCellMar>';
163
+ const props = `<w:tblPr><w:tblW w:w="5000" w:type="pct"/><w:jc w:val="center"/>${borders}` +
164
+ `<w:tblLayout w:type="autofit"/>${margins}<w:tblLook w:val="04A0" w:firstRow="1" w:lastRow="0" w:firstColumn="0" w:lastColumn="0" w:noHBand="0" w:noVBand="1"/></w:tblPr>`;
165
+ const grid = `<w:tblGrid>${'<w:gridCol/>'.repeat(columns)}</w:tblGrid>`;
166
+ const body = rows.map((row, index) => {
167
+ const header = index === 0;
168
+ const cells = Array.from({ length: columns }, (_, column) => {
169
+ const text = row[column] ?? '';
170
+ return tableCellXml(text, header, header || isNumericColumn(rows, column));
171
+ }).join('');
172
+ const trProps = header ? '<w:trPr><w:cantSplit/><w:tblHeader/></w:trPr>' : '<w:trPr><w:cantSplit/></w:trPr>';
173
+ return `<w:tr>${trProps}${cells}</w:tr>`;
174
+ }).join('');
175
+ return `<w:tbl>${props}${grid}${body}</w:tbl>`;
176
+ }
177
+ // ---------------------------------------------------------------------------
117
178
  // 文档装配
118
179
  // ---------------------------------------------------------------------------
119
- const CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
120
- <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
121
- <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
122
- <Default Extension="xml" ContentType="application/xml"/>
123
- <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
124
- <Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
125
- </Types>
126
- `;
127
180
  const ROOT_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
128
181
  <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
129
182
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
130
183
  </Relationships>
131
184
  `;
132
- const DOCUMENT_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
133
- <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>
134
- `;
135
185
  const STYLES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
136
186
  <w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
137
187
  <w:docDefaults>
@@ -168,6 +218,11 @@ const STYLES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
168
218
  <w:rPr><w:rFonts w:eastAsia="黑体"/><w:b/><w:sz w:val="26"/><w:szCs w:val="26"/></w:rPr>
169
219
  </w:style>
170
220
  <w:style w:type="paragraph" w:styleId="ListBullet"><w:name w:val="List Bullet"/></w:style>
221
+ <w:style w:type="paragraph" w:styleId="Caption">
222
+ <w:name w:val="caption"/>
223
+ <w:pPr><w:jc w:val="center"/><w:spacing w:before="40" w:after="200"/></w:pPr>
224
+ <w:rPr><w:rFonts w:eastAsia="宋体"/><w:sz w:val="21"/><w:szCs w:val="21"/></w:rPr>
225
+ </w:style>
171
226
  <w:style w:type="table" w:styleId="TableGrid">
172
227
  <w:name w:val="Table Grid"/>
173
228
  <w:tblPr>
@@ -183,8 +238,58 @@ const STYLES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
183
238
  </w:style>
184
239
  </w:styles>
185
240
  `;
241
+ /** 1 像素按 96dpi 折算的 EMU。 */
242
+ const EMU_PER_PIXEL = 9_525;
243
+ /** 正文栏宽(A4、2.54cm 页边距约 15.9cm),插图最宽取 15cm。 */
244
+ const MAX_IMAGE_WIDTH_EMU = 5_400_000;
245
+ /** 单张图最高 20cm:再高就会把图题挤到下一页。 */
246
+ const MAX_IMAGE_HEIGHT_EMU = 7_200_000;
247
+ /** 按像素尺寸算出等比例缩放后的 EMU 宽高。 */
248
+ function imageExtent(image) {
249
+ let cx = Math.max(1, image.width) * EMU_PER_PIXEL;
250
+ let cy = Math.max(1, image.height) * EMU_PER_PIXEL;
251
+ const widthScale = Math.min(1, MAX_IMAGE_WIDTH_EMU / cx);
252
+ cx = Math.round(cx * widthScale);
253
+ cy = Math.round(cy * widthScale);
254
+ if (cy > MAX_IMAGE_HEIGHT_EMU) {
255
+ const heightScale = MAX_IMAGE_HEIGHT_EMU / cy;
256
+ cx = Math.round(cx * heightScale);
257
+ cy = Math.round(cy * heightScale);
258
+ }
259
+ return { cx, cy };
260
+ }
261
+ /**
262
+ * 一张图的 WordprocessingML:居中段落 + inline drawing + 图题。
263
+ *
264
+ * 图题用单独的 Caption 段落(居中、五号),而不是塞进图片的替代文字里:
265
+ * 论文的图题必须能被选中、能被目录/交叉引用识别,也才能通过"图题在图下方"的检查。
266
+ */
267
+ function imageXml(alt, part, image, index) {
268
+ if (part === undefined || image === undefined) {
269
+ const missing = alt === '' ? '图片缺失' : alt;
270
+ return paragraphXml(`[${missing}:图片文件未找到,请检查路径]`);
271
+ }
272
+ const { cx, cy } = imageExtent(image);
273
+ const drawing = `<w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0">`
274
+ + `<wp:extent cx="${cx}" cy="${cy}"/><wp:effectExtent l="0" t="0" r="0" b="0"/>`
275
+ + `<wp:docPr id="${index}" name="图片 ${index}"/><wp:cNvGraphicFramePr><a:graphicFrameLocks noChangeAspect="1"/></wp:cNvGraphicFramePr>`
276
+ + '<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic>'
277
+ + `<pic:nvPicPr><pic:cNvPr id="${index}" name="${escapeXml(part.partName)}"/><pic:cNvPicPr/></pic:nvPicPr>`
278
+ + `<pic:blipFill><a:blip r:embed="${part.rId}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>`
279
+ + `<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>`
280
+ + '</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing>';
281
+ const imageParagraph = `<w:p><w:pPr><w:jc w:val="center"/><w:spacing w:before="120" w:after="60"/></w:pPr><w:r>${drawing}</w:r></w:p>`;
282
+ const caption = alt === ''
283
+ ? ''
284
+ : `<w:p><w:pPr><w:pStyle w:val="Caption"/><w:jc w:val="center"/></w:pPr><w:r><w:t xml:space="preserve">${escapeXml(alt)}</w:t></w:r></w:p>`;
285
+ return imageParagraph + caption;
286
+ }
186
287
  /**
187
288
  * 生成完整 .docx 字节。title 渲染为封面页;markdown 为正文(章节合并内容)。
289
+ *
290
+ * 图片按 `images` 里的字节嵌入:`![图 4-1 系统总体架构图](绝对路径)` 会变成
291
+ * 「居中的图 + 图下方的图题」。找不到字节的图片降级成一行可读的占位文字——
292
+ * 缺图必须在 Word 里一眼看见,不能静默消失。
188
293
  */
189
294
  export function buildDocx(opts) {
190
295
  const blocks = parseMarkdownBlocks(opts.markdown);
@@ -192,22 +297,80 @@ export function buildDocx(opts) {
192
297
  body.push(paragraphXml(opts.title, 'TitlePage'));
193
298
  body.push(paragraphXml('本科毕业论文(设计)', 'Subtitle'));
194
299
  body.push('<w:p/>');
195
- for (const block of blocks)
196
- body.push(blockXml(block));
300
+ const parts = [];
301
+ const formats = new Set();
302
+ let imageIndex = 0;
303
+ for (const block of blocks) {
304
+ if (block.kind !== 'image') {
305
+ body.push(blockXml(block));
306
+ continue;
307
+ }
308
+ imageIndex += 1;
309
+ const image = opts.images?.get(block.src);
310
+ let part;
311
+ if (image !== undefined) {
312
+ const extension = image.format === 'png' ? 'png' : 'jpg';
313
+ part = {
314
+ rId: `rIdImg${imageIndex}`,
315
+ partName: `media/image${imageIndex}.${extension}`,
316
+ data: image.data,
317
+ format: image.format,
318
+ };
319
+ parts.push(part);
320
+ formats.add(image.format);
321
+ }
322
+ body.push(imageXml(block.alt, part, image, imageIndex));
323
+ }
197
324
  const document = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
198
- <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
325
+ <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
199
326
  <w:body>
200
327
  ${body.join('')}
201
- <w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr>
328
+ <w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1417" w:right="1417" w:bottom="1417" w:left="1701" w:header="851" w:footer="992" w:gutter="0"/></w:sectPr>
202
329
  </w:body>
203
330
  </w:document>
204
331
  `;
205
332
  const entries = [
206
- { name: '[Content_Types].xml', data: encoder.encode(CONTENT_TYPES) },
333
+ { name: '[Content_Types].xml', data: encoder.encode(contentTypes(formats)) },
207
334
  { name: '_rels/.rels', data: encoder.encode(ROOT_RELS) },
208
335
  { name: 'word/document.xml', data: encoder.encode(document) },
209
336
  { name: 'word/styles.xml', data: encoder.encode(STYLES) },
210
- { name: 'word/_rels/document.xml.rels', data: encoder.encode(DOCUMENT_RELS) },
337
+ { name: 'word/_rels/document.xml.rels', data: encoder.encode(documentRels(parts)) },
211
338
  ];
339
+ for (const part of parts) {
340
+ entries.push({ name: `word/${part.partName}`, data: part.data });
341
+ }
212
342
  return buildZip(entries);
213
343
  }
344
+ /** `[Content_Types].xml`:基础部件 + 实际用到的图片格式。 */
345
+ function contentTypes(formats) {
346
+ const defaults = [
347
+ ...(formats.has('png') ? [' <Default Extension="png" ContentType="image/png"/>'] : []),
348
+ ...(formats.has('jpeg') ? [' <Default Extension="jpg" ContentType="image/jpeg"/>'] : []),
349
+ ].join('\n');
350
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
351
+ <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
352
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
353
+ <Default Extension="xml" ContentType="application/xml"/>
354
+ ${defaults === '' ? '' : `${defaults}\n`} <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
355
+ <Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
356
+ </Types>
357
+ `;
358
+ }
359
+ /**
360
+ * `word/_rels/document.xml.rels`:样式部件必须在关系里**显式登记**。
361
+ *
362
+ * 这一条曾经漏过:`styles.xml` 写进了包、`[Content_Types].xml` 也声明了,但没有
363
+ * relationship 时 Word 不会应用任何样式(标题、图题全变成普通正文),python-docx
364
+ * 更是直接只看到 4 个内置样式。图片关系与样式关系都从这里出。
365
+ */
366
+ function documentRels(parts) {
367
+ const relationships = [
368
+ ' <Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>',
369
+ ...parts.map(part => ` <Relationship Id="${part.rId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="${part.partName}"/>`),
370
+ ].join('\n');
371
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
372
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
373
+ ${relationships}
374
+ </Relationships>
375
+ `;
376
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * 图片头部解析:只为拿到像素宽高,好让 Word 里的图按原比例排版、且不超出栏宽。
3
+ *
4
+ * 不引入图像库:论文插图只有 PNG(draw.io 导出、系统截图)与 JPEG(照片/手机截图),
5
+ * 两种格式的尺寸都在文件头前几十字节里,读几十个字节即可。解析不出来时返回 undefined,
6
+ * 调用方按「未知尺寸」降级(用默认宽度),绝不猜一个尺寸然后把图拉变形。
7
+ *
8
+ * @module dsh-thesis/paper/image
9
+ */
10
+ /** 识别出的图片格式与像素尺寸。 */
11
+ export interface ImageInfo {
12
+ readonly format: 'png' | 'jpeg';
13
+ readonly width: number;
14
+ readonly height: number;
15
+ }
16
+ /** 解析图片宽高;不认识或数据不完整时返回 undefined。 */
17
+ export declare function readImageInfo(bytes: Uint8Array): ImageInfo | undefined;
18
+ /** 从文件名推断格式(用于 Word 的 content-type);不认识返回 undefined。 */
19
+ export declare function imageFormatOf(path: string): 'png' | 'jpeg' | undefined;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 图片头部解析:只为拿到像素宽高,好让 Word 里的图按原比例排版、且不超出栏宽。
3
+ *
4
+ * 不引入图像库:论文插图只有 PNG(draw.io 导出、系统截图)与 JPEG(照片/手机截图),
5
+ * 两种格式的尺寸都在文件头前几十字节里,读几十个字节即可。解析不出来时返回 undefined,
6
+ * 调用方按「未知尺寸」降级(用默认宽度),绝不猜一个尺寸然后把图拉变形。
7
+ *
8
+ * @module dsh-thesis/paper/image
9
+ */
10
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
11
+ function readU32BE(bytes, offset) {
12
+ return ((bytes[offset] ?? 0) << 24) | ((bytes[offset + 1] ?? 0) << 16) | ((bytes[offset + 2] ?? 0) << 8) | (bytes[offset + 3] ?? 0);
13
+ }
14
+ function readU16BE(bytes, offset) {
15
+ return ((bytes[offset] ?? 0) << 8) | (bytes[offset + 1] ?? 0);
16
+ }
17
+ function isPng(bytes) {
18
+ if (bytes.length < 24)
19
+ return false;
20
+ return PNG_SIGNATURE.every((byte, index) => bytes[index] === byte);
21
+ }
22
+ function isJpeg(bytes) {
23
+ return bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8;
24
+ }
25
+ /** 读 PNG 的 IHDR(宽高就在 16..23 字节)。 */
26
+ function pngInfo(bytes) {
27
+ const width = readU32BE(bytes, 16);
28
+ const height = readU32BE(bytes, 20);
29
+ if (width <= 0 || height <= 0)
30
+ return undefined;
31
+ return { format: 'png', width, height };
32
+ }
33
+ /** 扫描 JPEG 的 SOFn 段拿宽高。 */
34
+ function jpegInfo(bytes) {
35
+ let offset = 2;
36
+ while (offset + 9 < bytes.length) {
37
+ if (bytes[offset] !== 0xff) {
38
+ offset += 1;
39
+ continue;
40
+ }
41
+ const marker = bytes[offset + 1] ?? 0;
42
+ // 填充字节 0xFF 与无载荷标记(RSTn / SOI / EOI)直接跳过。
43
+ if (marker === 0xff) {
44
+ offset += 1;
45
+ continue;
46
+ }
47
+ if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
48
+ offset += 2;
49
+ continue;
50
+ }
51
+ const length = readU16BE(bytes, offset + 2);
52
+ if (length < 2)
53
+ return undefined;
54
+ const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
55
+ if (isSof) {
56
+ const height = readU16BE(bytes, offset + 5);
57
+ const width = readU16BE(bytes, offset + 7);
58
+ if (width <= 0 || height <= 0)
59
+ return undefined;
60
+ return { format: 'jpeg', width, height };
61
+ }
62
+ offset += 2 + length;
63
+ }
64
+ return undefined;
65
+ }
66
+ /** 解析图片宽高;不认识或数据不完整时返回 undefined。 */
67
+ export function readImageInfo(bytes) {
68
+ if (isPng(bytes))
69
+ return pngInfo(bytes);
70
+ if (isJpeg(bytes))
71
+ return jpegInfo(bytes);
72
+ return undefined;
73
+ }
74
+ /** 从文件名推断格式(用于 Word 的 content-type);不认识返回 undefined。 */
75
+ export function imageFormatOf(path) {
76
+ const lower = path.toLowerCase();
77
+ if (lower.endsWith('.png'))
78
+ return 'png';
79
+ if (lower.endsWith('.jpg') || lower.endsWith('.jpeg'))
80
+ return 'jpeg';
81
+ return undefined;
82
+ }
@@ -310,7 +310,8 @@ ${HEADER_NOTE}
310
310
 
311
311
  ## 1 总体架构
312
312
 
313
- <架构图:可用 Mermaid(flowchart/architecture)>
313
+ <架构图:用 thesis_figure action=create kind=architecture 生成 .drawio 与 PNG;
314
+ 图放 06-论文/assets/图/,图题写「图 4-1 系统总体架构图」,正文先引用再出图>
314
315
 
315
316
  ## 2 模块划分
316
317
 
@@ -390,6 +391,30 @@ ${HEADER_NOTE}
390
391
  });
391
392
  }
392
393
  files.push({ path: '06-论文/图表/.gitkeep', content: '' });
394
+ files.push({
395
+ path: '06-论文/assets/图/README.md',
396
+ content: `# 这里放论文的图
397
+
398
+ ${HEADER_NOTE}
399
+
400
+ **源文件**:每张图一个 \`.drawio\`(draw.io 格式,随时可改)。**导出件**:同名 \`.png\`(论文用,3× 导出)或 \`.svg\`(幻灯用)。
401
+
402
+ ## 命名与引用
403
+
404
+ - 文件名用英文小写与短横线:\`arch-overview.drawio\`、\`arch-overview.png\`。
405
+ - 章节里的路径相对章节文件(\`06-论文/章节/\`),写成 \`../assets/图/arch-overview.png\`。
406
+ - 图题在图片下一行,格式 \`图 4-1 系统总体架构图\`;正文先写「如图 4-1 所示」再出现图。
407
+
408
+ ## 出图与体检
409
+
410
+ - 生成:\`thesis_figure action=create\`(kind = architecture / flow / er / sequence / custom)。
411
+ - 导出:\`thesis_figure action=export\`(需要 draw.io 桌面版;没有时按 \`action=guide\` 的替代路径导出)。
412
+ - 覆盖同名图时旧版本自动存进 \`历史/\`(不参与导出与体检)。
413
+ - 体检:\`thesis_figure action=check\` → 生成本目录下的 \`图表清单.md\`,核对源文件、导出件、引用与图号。
414
+
415
+ > 红线:论文里的每张图都必须能在这里找到源文件(系统截图除外,需在正文注明来自真实运行)。
416
+ `,
417
+ });
393
418
  files.push({
394
419
  path: '06-论文/assets/学校模板/README.md',
395
420
  content: `# 学校模板与规范
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 候选文献解析:把 Undermind / Elicit / Consensus / Zotero / Google Scholar
3
+ * 导出的 `.bib`、`.ris`、`.csv` 读成统一的 `CandidateRef[]`。
4
+ *
5
+ * 只做**解析**,不做判断:解析结果一律视为「声称值」,必须再过
6
+ * `lit-verify.ts` 的权威库对账才能进 refs.bib。解析器不联网、不改写元数据,
7
+ * 解析不了的字段留空并给出警告(警告会出现在核验报告里)。
8
+ */
9
+ import type { CandidateRef } from './lit-verify.ts';
10
+ export type RefFormat = 'bibtex' | 'ris' | 'csv';
11
+ export interface ParsedRefs {
12
+ readonly format: RefFormat;
13
+ readonly candidates: CandidateRef[];
14
+ readonly warnings: string[];
15
+ }
16
+ /** 按扩展名与内容特征判断格式;都不像时返回 null。 */
17
+ export declare function detectRefFormat(fileName: string, text: string): RefFormat | null;
18
+ export declare function parseRefs(fileName: string, text: string, source: string): ParsedRefs;
19
+ /** 作者串 → 姓名数组。兼容 `A; B`、`A and B`、JSON 数组、`A, B, C`(≥3 个时的兜底)。 */
20
+ export declare function splitAuthors(raw: string): string[];
21
+ interface RawEntry {
22
+ readonly type: string;
23
+ readonly key: string;
24
+ readonly fields: Map<string, string>;
25
+ }
26
+ /** 扫出所有 `@type{key, body}`;用花括号配对找条目结尾,能容忍嵌套 `{}` 与多行字段。 */
27
+ export declare function bibEntries(text: string): RawEntry[];
28
+ /** RFC4180 风格解析:支持双引号包裹、`""` 转义、字段内换行。 */
29
+ export declare function parseCsvRows(text: string, delimiter?: string): string[][];
30
+ export {};