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
package/lib/paper/tools/build.js
CHANGED
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import * as nodePath from 'node:path';
|
|
13
13
|
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
14
15
|
import { tmpdir } from 'node:os';
|
|
15
16
|
import { describeFailure, spawnCommand } from "../../shared/spawn.js";
|
|
16
17
|
import { buildDocx } from "../lib/docx.js";
|
|
18
|
+
import { readImageInfo } from "../lib/image.js";
|
|
17
19
|
import { CHAPTER_META, isWrittenChapter } from "../lib/layout.js";
|
|
18
20
|
import { parseLedger } from "../lib/ledger.js";
|
|
19
21
|
import { findThesisRoot } from "../lib/project.js";
|
|
@@ -43,6 +45,72 @@ function pandocAvailable() {
|
|
|
43
45
|
const PROBE_TIMEOUT_MS = 10_000;
|
|
44
46
|
/** 单次 pandoc 转换的超时(毫秒):大型论文 + 模板可能慢,但必须有上限。 */
|
|
45
47
|
const CONVERT_TIMEOUT_MS = 120_000;
|
|
48
|
+
/** 章节里的图片引用:``,路径可写成 `<带空格的路径>`。 */
|
|
49
|
+
const IMAGE_REF = /!\[([^\]]*)\]\(\s*(?:<([^>]+)>|([^)\s]+))(?:\s+"[^"]*")?\s*\)/g;
|
|
50
|
+
/**
|
|
51
|
+
* 把图片引用解析成磁盘路径:先按章节文件所在目录(Markdown 的常规语义),
|
|
52
|
+
* 再按论文仓库根(学生把路径写成 `06-论文/assets/图/x.png` 时也能用)。
|
|
53
|
+
*/
|
|
54
|
+
function resolveChapterImage(src, chapterAbs, root) {
|
|
55
|
+
const decoded = src.includes('%') ? decodeURI(src) : src;
|
|
56
|
+
const candidates = nodePath.isAbsolute(decoded)
|
|
57
|
+
? [decoded]
|
|
58
|
+
: [nodePath.resolve(nodePath.dirname(chapterAbs), decoded), nodePath.resolve(root, decoded)];
|
|
59
|
+
return candidates.find(candidate => existsSync(candidate));
|
|
60
|
+
}
|
|
61
|
+
/** 读一张图;只接受 PNG/JPEG,其它格式如实报「不能嵌入」。 */
|
|
62
|
+
async function loadImage(absPath) {
|
|
63
|
+
try {
|
|
64
|
+
const data = new Uint8Array(await readFile(absPath));
|
|
65
|
+
const info = readImageInfo(data);
|
|
66
|
+
if (info === undefined)
|
|
67
|
+
return undefined;
|
|
68
|
+
return { data, width: info.width, height: info.height, format: info.format };
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 把一章正文里的图片引用统一改写成**绝对路径**,并把图片字节收集起来。
|
|
76
|
+
*
|
|
77
|
+
* 为什么要改写成绝对路径:pandoc 的临时 Markdown 在系统临时目录里生成,
|
|
78
|
+
* 相对路径到那里必然解析不到;内置引擎则按绝对路径查 {@link DocxImage}。
|
|
79
|
+
* 找不到的图片不静默丢弃——保留原引用并记一条问题,构建说明与工具返回值都会带上。
|
|
80
|
+
*/
|
|
81
|
+
async function prepareChapterImages(text, chapterAbs, chapterRel, root, images, problems) {
|
|
82
|
+
const matches = [...text.matchAll(IMAGE_REF)];
|
|
83
|
+
if (matches.length === 0)
|
|
84
|
+
return text;
|
|
85
|
+
let out = '';
|
|
86
|
+
let cursor = 0;
|
|
87
|
+
for (const match of matches) {
|
|
88
|
+
const whole = match[0];
|
|
89
|
+
const alt = match[1] ?? '';
|
|
90
|
+
const src = match[2] ?? match[3] ?? '';
|
|
91
|
+
out += text.slice(cursor, match.index);
|
|
92
|
+
cursor = match.index + whole.length;
|
|
93
|
+
const abs = resolveChapterImage(src, chapterAbs, root);
|
|
94
|
+
if (abs === undefined) {
|
|
95
|
+
problems.push(`${chapterRel}:图片不存在 ${src}`);
|
|
96
|
+
out += whole;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// 用正斜杠写进 Markdown:pandoc 在 Windows 上也认;反斜杠会被当成 Markdown 转义符。
|
|
100
|
+
const key = abs.replace(/\\/g, '/');
|
|
101
|
+
if (!images.has(key)) {
|
|
102
|
+
const image = await loadImage(abs);
|
|
103
|
+
if (image === undefined) {
|
|
104
|
+
problems.push(`${chapterRel}:不是可嵌入的 PNG/JPEG(${src}),已按原样保留`);
|
|
105
|
+
out += whole;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
images.set(key, image);
|
|
109
|
+
}
|
|
110
|
+
out += ``;
|
|
111
|
+
}
|
|
112
|
+
return out + text.slice(cursor);
|
|
113
|
+
}
|
|
46
114
|
export async function runBuild(fs, args, cwd, signal, writeBinary) {
|
|
47
115
|
const root = await findThesisRoot(fs, cwd, signal);
|
|
48
116
|
if (root === null)
|
|
@@ -55,14 +123,17 @@ export async function runBuild(fs, args, cwd, signal, writeBinary) {
|
|
|
55
123
|
const chapterParts = [];
|
|
56
124
|
const included = [];
|
|
57
125
|
const missing = [];
|
|
126
|
+
const images = new Map();
|
|
127
|
+
const imageProblems = [];
|
|
58
128
|
for (const [index, meta] of CHAPTER_META.entries()) {
|
|
59
|
-
const
|
|
129
|
+
const chapterAbs = nodePath.join(root, '06-论文/章节', `${meta.file}.md`);
|
|
130
|
+
const text = await readOptional(fs, chapterAbs, signal);
|
|
60
131
|
if (text === undefined || !isWrittenChapter(text, index + 1, meta)) {
|
|
61
132
|
missing.push(meta.file);
|
|
62
133
|
continue;
|
|
63
134
|
}
|
|
64
135
|
included.push(meta.file);
|
|
65
|
-
chapterParts.push(text);
|
|
136
|
+
chapterParts.push(await prepareChapterImages(text, chapterAbs, meta.file, root, images, imageProblems));
|
|
66
137
|
}
|
|
67
138
|
if (included.length === 0)
|
|
68
139
|
throw new Error('没有任何已撰写章节(06-论文/章节/*.md 均为空)。');
|
|
@@ -97,7 +168,7 @@ export async function runBuild(fs, args, cwd, signal, writeBinary) {
|
|
|
97
168
|
const docxPath = nodePath.join(root, OUT_DIR, '论文.docx');
|
|
98
169
|
let docxBytes;
|
|
99
170
|
if (engine === 'internal') {
|
|
100
|
-
docxBytes = buildDocx({ title, markdown });
|
|
171
|
+
docxBytes = buildDocx({ title, markdown, images });
|
|
101
172
|
}
|
|
102
173
|
else {
|
|
103
174
|
// pandoc:合并内容写临时文件后转换
|
|
@@ -145,11 +216,13 @@ export async function runBuild(fs, args, cwd, signal, writeBinary) {
|
|
|
145
216
|
`- 学校模板:${template ?? '未发现(请将学校模板放入 06-论文/assets/学校模板/ 后重建)'}`,
|
|
146
217
|
`- 收录章节:${included.join('、') || '无'}`,
|
|
147
218
|
`- 未收录章节(空或缺失):${missing.join('、') || '无'}`,
|
|
219
|
+
`- 插图:${images.size} 张已嵌入${imageProblems.length > 0 ? `,${imageProblems.length} 处有问题(见下)` : ''}`,
|
|
220
|
+
...(imageProblems.length > 0 ? ['', '图片问题:', ...imageProblems.map(problem => `- ${problem}`)] : []),
|
|
148
221
|
`- 产物:论文.docx${pdfPath !== undefined ? '、论文.pdf' : ''}`,
|
|
149
222
|
'',
|
|
150
223
|
].join('\n');
|
|
151
224
|
await fs.writeText(await fs.resolve(nodePath.join(root, OUT_DIR, '构建说明.md'), { signal }), note, undefined, signal);
|
|
152
|
-
return { docxPath, ...(pdfPath !== undefined ? { pdfPath } : {}), engine, template, chapters: included, missing };
|
|
225
|
+
return { docxPath, ...(pdfPath !== undefined ? { pdfPath } : {}), engine, template, chapters: included, missing, images: images.size, imageProblems };
|
|
153
226
|
}
|
|
154
227
|
export function buildSummary(outcome) {
|
|
155
228
|
const lines = [
|
|
@@ -159,6 +232,8 @@ export function buildSummary(outcome) {
|
|
|
159
232
|
`- 学校模板:${outcome.template ?? '未发现'}`,
|
|
160
233
|
`- 收录章节:${outcome.chapters.join('、')}`,
|
|
161
234
|
...(outcome.missing.length > 0 ? [`- 未收录(空/缺失):${outcome.missing.join('、')}`] : []),
|
|
235
|
+
`- 插图:${outcome.images} 张已嵌入${outcome.imageProblems.length > 0 ? `;${outcome.imageProblems.length} 处需处理(见构建说明)` : ''}`,
|
|
236
|
+
...outcome.imageProblems.map(problem => ` · ${problem}`),
|
|
162
237
|
'',
|
|
163
238
|
'打开 论文.docx 检查排版;构建细节见 06-论文/产出/构建说明.md。',
|
|
164
239
|
];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文献核验与导入:thesis_lit_verify / thesis_lit_import。
|
|
3
|
+
*
|
|
4
|
+
* 两个工具解决同一个问题:**候选文献从哪来**与**凭什么敢写进论文**不能混为一谈。
|
|
5
|
+
* - `thesis_lit_verify`:核验(单条或整份导出文件),只出结论,不改 refs.bib;
|
|
6
|
+
* - `thesis_lit_import`:先把导出文件逐条核验,**只有核验通过的**才进 refs.bib,
|
|
7
|
+
* 未通过的写进 08-合规/文献核验报告.md 并逐条说明原因。
|
|
8
|
+
*
|
|
9
|
+
* 失败即拒(fail closed):网络不通 → `unchecked` → 不入库。
|
|
10
|
+
*/
|
|
11
|
+
import type { FileSystem } from '@deepseek-ai/dsh-fs';
|
|
12
|
+
import type { FetchLike, LitRecord } from '../lib/lit-api.ts';
|
|
13
|
+
export interface LitVerifyArgs {
|
|
14
|
+
doi?: string;
|
|
15
|
+
title?: string;
|
|
16
|
+
authors?: string[];
|
|
17
|
+
year?: number;
|
|
18
|
+
venue?: string;
|
|
19
|
+
/** 导出文件路径(.bib/.ris/.csv),给了就批量核验整个文件。 */
|
|
20
|
+
path?: string;
|
|
21
|
+
/** 核验通过后是否顺手收录进 refs.bib,默认 false(核验工具只出结论)。 */
|
|
22
|
+
save?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface LitImportArgs {
|
|
25
|
+
path: string;
|
|
26
|
+
source?: string;
|
|
27
|
+
limit?: number;
|
|
28
|
+
/** 默认 true:核验通过的直接入库。设 false 只核验。 */
|
|
29
|
+
save?: boolean;
|
|
30
|
+
}
|
|
31
|
+
declare const REPORT_TABLE_HEAD = "| \u65E5\u671F | \u52A8\u4F5C | \u6765\u6E90 | \u5BF9\u8C61 | \u6761\u6570 | \u7ED3\u8BBA |\n|---|---|---|---|---|---|";
|
|
32
|
+
export declare function runLitVerify(fs: FileSystem, args: LitVerifyArgs, cwd: string | undefined, signal: AbortSignal | undefined, fetchImpl: FetchLike): Promise<string>;
|
|
33
|
+
export declare function runLitImport(fs: FileSystem, args: LitImportArgs, cwd: string | undefined, signal: AbortSignal | undefined, fetchImpl: FetchLike): Promise<string>;
|
|
34
|
+
export { REPORT_TABLE_HEAD };
|
|
35
|
+
export type { LitRecord };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文献核验与导入:thesis_lit_verify / thesis_lit_import。
|
|
3
|
+
*
|
|
4
|
+
* 两个工具解决同一个问题:**候选文献从哪来**与**凭什么敢写进论文**不能混为一谈。
|
|
5
|
+
* - `thesis_lit_verify`:核验(单条或整份导出文件),只出结论,不改 refs.bib;
|
|
6
|
+
* - `thesis_lit_import`:先把导出文件逐条核验,**只有核验通过的**才进 refs.bib,
|
|
7
|
+
* 未通过的写进 08-合规/文献核验报告.md 并逐条说明原因。
|
|
8
|
+
*
|
|
9
|
+
* 失败即拒(fail closed):网络不通 → `unchecked` → 不入库。
|
|
10
|
+
*/
|
|
11
|
+
import * as nodePath from 'node:path';
|
|
12
|
+
import { describeReadFailure, isMissingError } from "../../shared/fs-errors.js";
|
|
13
|
+
import { loadCache, mergeRecords, saveCache, appendLitRecord } from "../lib/lit-cache.js";
|
|
14
|
+
import { parseRefs } from "../lib/lit-refs.js";
|
|
15
|
+
import { renderOutcome, renderVerifyReport, summarizeOutcomes, VERIFY_REPORT_HEADER, VERIFY_REPORT_REL, verifyAll, } from "../lib/lit-verify.js";
|
|
16
|
+
import { findThesisRoot } from "../lib/project.js";
|
|
17
|
+
import { runLitSave } from "./lit.js";
|
|
18
|
+
const REPORT_TABLE_HEAD = '| 日期 | 动作 | 来源 | 对象 | 条数 | 结论 |\n|---|---|---|---|---|---|';
|
|
19
|
+
async function thesisRoot(fs, cwd, signal) {
|
|
20
|
+
const root = await findThesisRoot(fs, cwd, signal);
|
|
21
|
+
if (root === null) {
|
|
22
|
+
throw new Error('未找到论文工作区(向上查找 00-管理/进度台账.md 失败)。请先 thesis_init,或在论文仓库目录内操作。');
|
|
23
|
+
}
|
|
24
|
+
return root;
|
|
25
|
+
}
|
|
26
|
+
function formatDate() {
|
|
27
|
+
return new Date().toISOString().slice(0, 10);
|
|
28
|
+
}
|
|
29
|
+
async function readLocalFile(fs, root, target, signal) {
|
|
30
|
+
const absolute = nodePath.isAbsolute(target) ? target : nodePath.join(root, target);
|
|
31
|
+
try {
|
|
32
|
+
const text = await fs.readText(await fs.resolve(absolute, { signal }), signal);
|
|
33
|
+
return { name: nodePath.basename(absolute), text };
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (isMissingError(error)) {
|
|
37
|
+
throw new Error(`找不到导出文件:${absolute}\n` +
|
|
38
|
+
`请先在 Undermind / Elicit / Consensus / Zotero 里导出为 .bib 或 .ris,放到论文仓库里再传路径。`);
|
|
39
|
+
}
|
|
40
|
+
throw new Error(describeReadFailure('文献导出文件', absolute, error, signal));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function inferSource(fileName) {
|
|
44
|
+
const text = fileName.toLowerCase();
|
|
45
|
+
if (text.includes('undermind'))
|
|
46
|
+
return 'undermind';
|
|
47
|
+
if (text.includes('elicit'))
|
|
48
|
+
return 'elicit';
|
|
49
|
+
if (text.includes('consensus'))
|
|
50
|
+
return 'consensus';
|
|
51
|
+
if (text.includes('zotero'))
|
|
52
|
+
return 'zotero';
|
|
53
|
+
if (text.includes('scholar'))
|
|
54
|
+
return 'google-scholar';
|
|
55
|
+
return 'file';
|
|
56
|
+
}
|
|
57
|
+
/** 追加核验报告(保留历史:每次核验一个 `## 日期` 小节)。 */
|
|
58
|
+
async function appendVerifyReport(fs, root, section, signal) {
|
|
59
|
+
const path = nodePath.join(root, VERIFY_REPORT_REL);
|
|
60
|
+
let existing;
|
|
61
|
+
try {
|
|
62
|
+
existing = await fs.readText(await fs.resolve(path, { signal }), signal);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (!isMissingError(error))
|
|
66
|
+
throw new Error(describeReadFailure('文献核验报告', path, error, signal));
|
|
67
|
+
existing = VERIFY_REPORT_HEADER;
|
|
68
|
+
}
|
|
69
|
+
const head = existing.trim() === '' ? VERIFY_REPORT_HEADER : existing.trimEnd();
|
|
70
|
+
await fs.writeText(await fs.resolve(path, { signal }), `${head}\n\n${section}\n`, undefined, signal);
|
|
71
|
+
return path;
|
|
72
|
+
}
|
|
73
|
+
function verifyRow(date, source, title, status, note) {
|
|
74
|
+
return `| ${date} | 核验 | ${source} | ${title.replaceAll('|', '\\|').slice(0, 80)} | 1 | ${status}:${note.replaceAll('|', '\\|')} |`;
|
|
75
|
+
}
|
|
76
|
+
function candidateFromArgs(args) {
|
|
77
|
+
const title = (args.title ?? '').trim();
|
|
78
|
+
const doi = (args.doi ?? '').trim();
|
|
79
|
+
if (title === '' && doi === '') {
|
|
80
|
+
throw new Error('请给 title 或 doi(单条核验),或用 path 指定导出的 .bib/.ris/.csv(批量核验)。');
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
title: title === '' ? `(仅凭 DOI 核验)${doi}` : title,
|
|
84
|
+
authors: args.authors ?? [],
|
|
85
|
+
year: args.year ?? null,
|
|
86
|
+
venue: args.venue ?? '',
|
|
87
|
+
...(doi !== '' ? { doi } : {}),
|
|
88
|
+
source: 'manual',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
async function reportAndSave(fs, root, outcomes, label, save, signal) {
|
|
92
|
+
const date = formatDate();
|
|
93
|
+
const section = renderVerifyReport(date, outcomes, label);
|
|
94
|
+
const reportPath = await appendVerifyReport(fs, root, section, signal);
|
|
95
|
+
for (const outcome of outcomes) {
|
|
96
|
+
await appendLitRecord(fs, root, verifyRow(date, outcome.candidate.source, outcome.candidate.title, outcome.status, outcome.message), signal);
|
|
97
|
+
}
|
|
98
|
+
let savedText = '';
|
|
99
|
+
if (save) {
|
|
100
|
+
const records = outcomes.flatMap(o => (o.status === 'verified' && o.record !== undefined ? [o.record] : []));
|
|
101
|
+
if (records.length > 0) {
|
|
102
|
+
const cache = mergeRecords(await loadCache(fs, root, signal), records);
|
|
103
|
+
await saveCache(fs, root, cache, signal);
|
|
104
|
+
savedText = await runLitSave(fs, { ids: records.map(r => r.id) }, root, signal);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
savedText = '没有核验通过的条目,未写入 refs.bib。';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { reportPath, savedText };
|
|
111
|
+
}
|
|
112
|
+
function renderOutcomes(outcomes, maxDetail = 8) {
|
|
113
|
+
const lines = [];
|
|
114
|
+
const passed = outcomes.filter(o => o.status === 'verified');
|
|
115
|
+
const failed = outcomes.filter(o => o.status !== 'verified');
|
|
116
|
+
if (passed.length > 0) {
|
|
117
|
+
lines.push('### 核验通过', '');
|
|
118
|
+
for (const outcome of passed.slice(0, maxDetail))
|
|
119
|
+
lines.push(renderOutcome(outcome), '');
|
|
120
|
+
}
|
|
121
|
+
if (failed.length > 0) {
|
|
122
|
+
lines.push('### 未通过(禁止写入 refs.bib,须换文献或人工核对原文)', '');
|
|
123
|
+
for (const outcome of failed.slice(0, 20))
|
|
124
|
+
lines.push(renderOutcome(outcome), '');
|
|
125
|
+
if (failed.length > 20)
|
|
126
|
+
lines.push(`(还有 ${failed.length - 20} 条未显示,见核验报告。)`, '');
|
|
127
|
+
}
|
|
128
|
+
return lines.join('\n');
|
|
129
|
+
}
|
|
130
|
+
export async function runLitVerify(fs, args, cwd, signal, fetchImpl) {
|
|
131
|
+
const root = await thesisRoot(fs, cwd, signal);
|
|
132
|
+
let candidates;
|
|
133
|
+
let label;
|
|
134
|
+
if (args.path !== undefined && args.path.trim() !== '') {
|
|
135
|
+
const file = await readLocalFile(fs, root, args.path.trim(), signal);
|
|
136
|
+
const parsed = parseRefs(file.name, file.text, inferSource(file.name));
|
|
137
|
+
if (parsed.candidates.length === 0)
|
|
138
|
+
throw new Error(`${file.name} 里没有解析出任何文献条目(格式:${parsed.format})。`);
|
|
139
|
+
candidates = parsed.candidates;
|
|
140
|
+
label = `${file.name}(${parsed.format},${parsed.candidates.length} 条)`;
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
candidates = [candidateFromArgs(args)];
|
|
144
|
+
label = '单条核验';
|
|
145
|
+
}
|
|
146
|
+
const outcomes = await verifyAll(fetchImpl, candidates, signal);
|
|
147
|
+
const { reportPath, savedText } = await reportAndSave(fs, root, outcomes, label, args.save === true, signal);
|
|
148
|
+
const lines = [`核验完成:${label}`, summarizeOutcomes(outcomes), '', renderOutcomes(outcomes)];
|
|
149
|
+
if (savedText !== '')
|
|
150
|
+
lines.push('### 入库结果', '', savedText, '');
|
|
151
|
+
lines.push(`报告:${nodePath.relative(root, reportPath).split(nodePath.sep).join('/')}`);
|
|
152
|
+
if (!args.save) {
|
|
153
|
+
lines.push('下一步:确认结论后,用 thesis_lit_import 把整份导出文件入库(只收核验通过的),或 thesis_lit_verify 带 save: true 收录本条。');
|
|
154
|
+
}
|
|
155
|
+
return lines.join('\n');
|
|
156
|
+
}
|
|
157
|
+
export async function runLitImport(fs, args, cwd, signal, fetchImpl) {
|
|
158
|
+
if (args.path === undefined || args.path.trim() === '') {
|
|
159
|
+
throw new Error('path 必填:Undermind / Elicit / Consensus / Zotero 导出的 .bib / .ris / .csv 路径。');
|
|
160
|
+
}
|
|
161
|
+
const root = await thesisRoot(fs, cwd, signal);
|
|
162
|
+
const file = await readLocalFile(fs, root, args.path.trim(), signal);
|
|
163
|
+
const source = args.source ?? inferSource(file.name);
|
|
164
|
+
const parsed = parseRefs(file.name, file.text, source);
|
|
165
|
+
if (parsed.candidates.length === 0)
|
|
166
|
+
throw new Error(`${file.name} 里没有解析出任何文献条目(格式:${parsed.format})。`);
|
|
167
|
+
const limited = args.limit !== undefined && args.limit > 0 ? parsed.candidates.slice(0, args.limit) : parsed.candidates;
|
|
168
|
+
const outcomes = await verifyAll(fetchImpl, limited, signal);
|
|
169
|
+
const save = args.save !== false;
|
|
170
|
+
const { reportPath, savedText } = await reportAndSave(fs, root, outcomes, `${file.name}(${parsed.format},来源 ${source})`, save, signal);
|
|
171
|
+
const lines = [];
|
|
172
|
+
lines.push(`导入 ${file.name}:解析 ${parsed.candidates.length} 条,本次核验 ${limited.length} 条(格式 ${parsed.format},来源 ${source})。`);
|
|
173
|
+
if (parsed.warnings.length > 0) {
|
|
174
|
+
lines.push('', '解析警告:');
|
|
175
|
+
for (const warning of parsed.warnings.slice(0, 10))
|
|
176
|
+
lines.push(`- ${warning}`);
|
|
177
|
+
}
|
|
178
|
+
lines.push('', summarizeOutcomes(outcomes), '');
|
|
179
|
+
if (save) {
|
|
180
|
+
lines.push('### 入库结果', '', savedText, '');
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
lines.push('(save: false:只核验未入库。)', '');
|
|
184
|
+
}
|
|
185
|
+
lines.push(renderOutcomes(outcomes));
|
|
186
|
+
lines.push(`报告:${nodePath.relative(root, reportPath).split(nodePath.sep).join('/')}`);
|
|
187
|
+
const verified = outcomes.filter(o => o.status === 'verified');
|
|
188
|
+
if (verified.length > 0) {
|
|
189
|
+
lines.push('', '下一步:对核验通过的文献用 thesis_lit_note 生成阅读笔记;未通过的不要改写引用,换用 thesis_lit_search 另找一篇。');
|
|
190
|
+
}
|
|
191
|
+
return lines.join('\n');
|
|
192
|
+
}
|
|
193
|
+
export { REPORT_TABLE_HEAD };
|
package/lib/ppt/check.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import * as nodePath from 'node:path';
|
|
11
11
|
import { MAX_BULLETS_PER_SLIDE, MAX_BULLET_CHARS, MAX_TITLE_CHARS, MIN_BULLETS_PER_SLIDE, audienceProfile } from "./outline.js";
|
|
12
12
|
import { parseMarp } from "./marp.js";
|
|
13
|
+
import { designIssues, parseDeckPages } from "./design.js";
|
|
13
14
|
/** 每页预计时长的允许区间(分钟)。 */
|
|
14
15
|
export const DURATION_MIN_MINUTES = 8;
|
|
15
16
|
export const DURATION_MAX_MINUTES = 12;
|
|
@@ -67,6 +68,10 @@ export function checkDeck(markdown, options) {
|
|
|
67
68
|
else if (pages > 0 && minMinutes < DURATION_MIN_MINUTES) {
|
|
68
69
|
push('time-under', 'warn', '全文', `预计时长 ${minMinutes}-${maxMinutes} 分钟,短于 ${DURATION_MIN_MINUTES} 分钟;老师一般要求讲满,建议把要点讲透或补一页实现细节。`);
|
|
69
70
|
}
|
|
71
|
+
// 9. 版面设计(借鉴歸藏设计瑞士风的口径):版面单调 / 要点过多 / 连续无图 / 缺封面结尾
|
|
72
|
+
for (const issue of designIssues(parseDeckPages(markdown))) {
|
|
73
|
+
push(issue.code, issue.level, issue.where, issue.message);
|
|
74
|
+
}
|
|
70
75
|
const errors = issues.filter(i => i.level === 'error').length;
|
|
71
76
|
const warnings = issues.filter(i => i.level === 'warn').length;
|
|
72
77
|
return {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 答辩幻灯的版面设计系统(借鉴「歸藏设计」瑞士风的核心,重新实现,不引其代码)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么放进插件:幻灯丑几乎从不是"配色没调好",而是**版面单调 + 层级缺失**——
|
|
5
|
+
* 11 页里 9 页都是「标题 + ▪ 列表」,观众看不到任何视觉锚点。这类问题可以确定性地
|
|
6
|
+
* 检查出来,不该靠每次重新叮嘱。
|
|
7
|
+
*
|
|
8
|
+
* 本模块只做三件事:
|
|
9
|
+
* 1. `DESIGN`:把版心、字号、色彩写成常量(生成器与检查器共用同一套口径);
|
|
10
|
+
* 2. `parseDeckPages`:从 Markdown 里读出每页的版面类型(`<!-- _class: xxx -->`);
|
|
11
|
+
* 3. `designIssues`:确定性规则——版面单调、要点过多、连续无图、缺封面/结尾页。
|
|
12
|
+
*/
|
|
13
|
+
/** 16:9 画布与版心(英寸)。生成脚本与检查器都以这里为准。 */
|
|
14
|
+
export declare const DESIGN: {
|
|
15
|
+
readonly canvas: {
|
|
16
|
+
readonly width: 13.333;
|
|
17
|
+
readonly height: 7.5;
|
|
18
|
+
};
|
|
19
|
+
readonly margin: {
|
|
20
|
+
readonly left: 0.95;
|
|
21
|
+
readonly right: 0.95;
|
|
22
|
+
readonly top: 0.34;
|
|
23
|
+
readonly bottom: 0.55;
|
|
24
|
+
};
|
|
25
|
+
/** 字号(pt):kicker / 标题 / 正文 / 数字。层级只有四档,不许逐页乱调。 */
|
|
26
|
+
readonly type: {
|
|
27
|
+
readonly kicker: 10.5;
|
|
28
|
+
readonly title: 32;
|
|
29
|
+
readonly lead: 21;
|
|
30
|
+
readonly body: 15;
|
|
31
|
+
readonly small: 15;
|
|
32
|
+
readonly tiny: 11.5;
|
|
33
|
+
readonly stat: 54;
|
|
34
|
+
};
|
|
35
|
+
/** 纸色 + 墨色 + 单一强调色(克莱因蓝)。不用纯白纯黑。 */
|
|
36
|
+
readonly color: {
|
|
37
|
+
readonly paper: "#FAFAF8";
|
|
38
|
+
readonly ink: "#141414";
|
|
39
|
+
readonly ink2: "#5A5A56";
|
|
40
|
+
readonly hairline: "#D9D6CC";
|
|
41
|
+
readonly card: "#F0EEE8";
|
|
42
|
+
readonly accent: "#002FA7";
|
|
43
|
+
};
|
|
44
|
+
/** 一页最多几条要点;再多就不是幻灯而是说明书。 */
|
|
45
|
+
readonly rules: {
|
|
46
|
+
readonly maxBullets: 4;
|
|
47
|
+
readonly maxBulletChars: 30;
|
|
48
|
+
readonly maxTitleChars: 20;
|
|
49
|
+
readonly minVisualPages: 2;
|
|
50
|
+
readonly maxSameLayoutRatio: 0.6;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* 版面类型。名字即契约:`<!-- _class: compare -->` 会渲染成左右对照版,
|
|
55
|
+
* 而不是又一段项目符号。
|
|
56
|
+
*/
|
|
57
|
+
export declare const DECK_LAYOUTS: readonly ["cover", "agenda", "compare", "rows", "steps", "cards", "figure", "columns", "stats", "closing"];
|
|
58
|
+
export type DeckLayout = (typeof DECK_LAYOUTS)[number];
|
|
59
|
+
export interface DeckPage {
|
|
60
|
+
/** 从 1 开始。 */
|
|
61
|
+
readonly index: number;
|
|
62
|
+
/** 缺省为 `bullets`(没有任何 `_class` 的纯要点页)。 */
|
|
63
|
+
readonly layout: DeckLayout | 'bullets';
|
|
64
|
+
readonly title: string;
|
|
65
|
+
readonly bullets: readonly string[];
|
|
66
|
+
/** 本页有几个 `[panel]` 分栏(≥2 时要点按栏均分,不按页计)。 */
|
|
67
|
+
readonly panels: number;
|
|
68
|
+
readonly hasNotes: boolean;
|
|
69
|
+
}
|
|
70
|
+
export interface DesignIssue {
|
|
71
|
+
readonly code: string;
|
|
72
|
+
readonly level: 'error' | 'warn';
|
|
73
|
+
readonly where: string;
|
|
74
|
+
readonly message: string;
|
|
75
|
+
}
|
|
76
|
+
/** 把 Marp Markdown 切成页,读出每页的版面类型与要点数。 */
|
|
77
|
+
export declare function parseDeckPages(markdown: string): DeckPage[];
|
|
78
|
+
/**
|
|
79
|
+
* 确定性版式检查。返回空数组表示"版面不单调、每页都讲得清"。
|
|
80
|
+
*
|
|
81
|
+
* 刻意只查**可机器判定**的事:版面分布、要点数量、连续无图、封面结尾。
|
|
82
|
+
* 配色好不好看、话术顺不顺,仍然要人看。
|
|
83
|
+
*/
|
|
84
|
+
export declare function designIssues(pages: readonly DeckPage[]): DesignIssue[];
|
|
85
|
+
/** 中文按字计、西文按 0.5 字计——幻灯上的长度用"视觉字数"衡量才准。 */
|
|
86
|
+
export declare function cjkLen(text: string): number;
|