dsh-thesis 1.0.0
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/DESIGN.md +390 -0
- package/LICENSE +21 -0
- package/NOTICE.md +10 -0
- package/README.md +253 -0
- package/cordis.patch.yml +17 -0
- package/docs/host-api.md +640 -0
- package/lib/codewalk/commands.d.ts +23 -0
- package/lib/codewalk/commands.js +168 -0
- package/lib/codewalk/engine.d.ts +108 -0
- package/lib/codewalk/engine.js +449 -0
- package/lib/codewalk/errors.d.ts +31 -0
- package/lib/codewalk/errors.js +20 -0
- package/lib/codewalk/fs-types.d.ts +34 -0
- package/lib/codewalk/fs-types.js +10 -0
- package/lib/codewalk/gate.d.ts +30 -0
- package/lib/codewalk/gate.js +60 -0
- package/lib/codewalk/host-types.d.ts +198 -0
- package/lib/codewalk/host-types.js +11 -0
- package/lib/codewalk/prestep.d.ts +17 -0
- package/lib/codewalk/prestep.js +62 -0
- package/lib/codewalk/register.d.ts +23 -0
- package/lib/codewalk/register.js +31 -0
- package/lib/codewalk/skill.d.ts +15 -0
- package/lib/codewalk/skill.js +57 -0
- package/lib/codewalk/tools.d.ts +18 -0
- package/lib/codewalk/tools.js +314 -0
- package/lib/codewalk/types.d.ts +151 -0
- package/lib/codewalk/types.js +9 -0
- package/lib/commands.d.ts +33 -0
- package/lib/commands.js +60 -0
- package/lib/config.d.ts +125 -0
- package/lib/config.js +139 -0
- package/lib/dedup/commands.d.ts +21 -0
- package/lib/dedup/commands.js +93 -0
- package/lib/dedup/index.d.ts +75 -0
- package/lib/dedup/index.js +331 -0
- package/lib/dedup/normalize.d.ts +125 -0
- package/lib/dedup/normalize.js +329 -0
- package/lib/dedup/report.d.ts +114 -0
- package/lib/dedup/report.js +309 -0
- package/lib/dedup/rewrite.d.ts +71 -0
- package/lib/dedup/rewrite.js +228 -0
- package/lib/dedup/similarity.d.ts +169 -0
- package/lib/dedup/similarity.js +323 -0
- package/lib/gates/dangerous.d.ts +22 -0
- package/lib/gates/dangerous.js +47 -0
- package/lib/gates/detect.d.ts +54 -0
- package/lib/gates/detect.js +106 -0
- package/lib/gates/messages.d.ts +39 -0
- package/lib/gates/messages.js +74 -0
- package/lib/gates/prompt.d.ts +21 -0
- package/lib/gates/prompt.js +56 -0
- package/lib/gates/writing-intent.d.ts +49 -0
- package/lib/gates/writing-intent.js +68 -0
- package/lib/index.d.ts +36 -0
- package/lib/index.js +76 -0
- package/lib/ingest/code.d.ts +71 -0
- package/lib/ingest/code.js +356 -0
- package/lib/ingest/commands.d.ts +23 -0
- package/lib/ingest/commands.js +74 -0
- package/lib/ingest/docx.d.ts +29 -0
- package/lib/ingest/docx.js +92 -0
- package/lib/ingest/index.d.ts +179 -0
- package/lib/ingest/index.js +640 -0
- package/lib/ingest/ooxml.d.ts +58 -0
- package/lib/ingest/ooxml.js +393 -0
- package/lib/ingest/pdf.d.ts +86 -0
- package/lib/ingest/pdf.js +1016 -0
- package/lib/ingest/pptx.d.ts +32 -0
- package/lib/ingest/pptx.js +116 -0
- package/lib/ingest/text.d.ts +25 -0
- package/lib/ingest/text.js +180 -0
- package/lib/ingest/types.d.ts +48 -0
- package/lib/ingest/types.js +47 -0
- package/lib/ingest/xlsx.d.ts +72 -0
- package/lib/ingest/xlsx.js +411 -0
- package/lib/ingest/zip.d.ts +76 -0
- package/lib/ingest/zip.js +393 -0
- package/lib/intake/definitions.d.ts +65 -0
- package/lib/intake/definitions.js +42 -0
- package/lib/intake/gate-rules.d.ts +57 -0
- package/lib/intake/gate-rules.js +118 -0
- package/lib/intake/gate.d.ts +24 -0
- package/lib/intake/gate.js +86 -0
- package/lib/intake/index.d.ts +76 -0
- package/lib/intake/index.js +468 -0
- package/lib/intake/questions.d.ts +169 -0
- package/lib/intake/questions.js +720 -0
- package/lib/intake/spec.d.ts +66 -0
- package/lib/intake/spec.js +221 -0
- package/lib/intake/state.d.ts +192 -0
- package/lib/intake/state.js +420 -0
- package/lib/memory/recall.d.ts +69 -0
- package/lib/memory/recall.js +177 -0
- package/lib/memory/register.d.ts +37 -0
- package/lib/memory/register.js +52 -0
- package/lib/memory/service.d.ts +39 -0
- package/lib/memory/service.js +39 -0
- package/lib/memory/store.d.ts +45 -0
- package/lib/memory/store.js +123 -0
- package/lib/memory/tools.d.ts +19 -0
- package/lib/memory/tools.js +195 -0
- package/lib/memory/types.d.ts +55 -0
- package/lib/memory/types.js +6 -0
- package/lib/paper/commands.d.ts +13 -0
- package/lib/paper/commands.js +84 -0
- package/lib/paper/lib/aicheck.d.ts +38 -0
- package/lib/paper/lib/aicheck.js +241 -0
- package/lib/paper/lib/bibtex.d.ts +21 -0
- package/lib/paper/lib/bibtex.js +114 -0
- package/lib/paper/lib/check.d.ts +32 -0
- package/lib/paper/lib/check.js +215 -0
- package/lib/paper/lib/defense.d.ts +48 -0
- package/lib/paper/lib/defense.js +220 -0
- package/lib/paper/lib/docx.d.ts +35 -0
- package/lib/paper/lib/docx.js +213 -0
- package/lib/paper/lib/layout.d.ts +41 -0
- package/lib/paper/lib/layout.js +572 -0
- package/lib/paper/lib/ledger.d.ts +112 -0
- package/lib/paper/lib/ledger.js +349 -0
- package/lib/paper/lib/lit-api.d.ts +59 -0
- package/lib/paper/lib/lit-api.js +296 -0
- package/lib/paper/lib/lit-cache.d.ts +32 -0
- package/lib/paper/lib/lit-cache.js +91 -0
- package/lib/paper/lib/project.d.ts +29 -0
- package/lib/paper/lib/project.js +93 -0
- package/lib/paper/lib/review.d.ts +37 -0
- package/lib/paper/lib/review.js +194 -0
- package/lib/paper/lib/zip.d.ts +14 -0
- package/lib/paper/lib/zip.js +124 -0
- package/lib/paper/register.d.ts +20 -0
- package/lib/paper/register.js +183 -0
- package/lib/paper/tools/aicheck.d.ts +6 -0
- package/lib/paper/tools/aicheck.js +38 -0
- package/lib/paper/tools/build.d.ts +27 -0
- package/lib/paper/tools/build.js +166 -0
- package/lib/paper/tools/check.d.ts +7 -0
- package/lib/paper/tools/check.js +79 -0
- package/lib/paper/tools/decision.d.ts +13 -0
- package/lib/paper/tools/decision.js +53 -0
- package/lib/paper/tools/defense.d.ts +11 -0
- package/lib/paper/tools/defense.js +74 -0
- package/lib/paper/tools/init.d.ts +23 -0
- package/lib/paper/tools/init.js +99 -0
- package/lib/paper/tools/lit.d.ts +27 -0
- package/lib/paper/tools/lit.js +236 -0
- package/lib/paper/tools/progress.d.ts +18 -0
- package/lib/paper/tools/progress.js +77 -0
- package/lib/paper/tools/review.d.ts +11 -0
- package/lib/paper/tools/review.js +41 -0
- package/lib/ppt/actions.d.ts +97 -0
- package/lib/ppt/actions.js +263 -0
- package/lib/ppt/check.d.ts +64 -0
- package/lib/ppt/check.js +185 -0
- package/lib/ppt/commands.d.ts +28 -0
- package/lib/ppt/commands.js +71 -0
- package/lib/ppt/convert.d.ts +146 -0
- package/lib/ppt/convert.js +423 -0
- package/lib/ppt/index.d.ts +45 -0
- package/lib/ppt/index.js +57 -0
- package/lib/ppt/marp.d.ts +74 -0
- package/lib/ppt/marp.js +165 -0
- package/lib/ppt/outline.d.ts +206 -0
- package/lib/ppt/outline.js +695 -0
- package/lib/shared/define-tool.d.ts +77 -0
- package/lib/shared/define-tool.js +235 -0
- package/lib/shared/fs-errors.d.ts +50 -0
- package/lib/shared/fs-errors.js +90 -0
- package/lib/shared/index.d.ts +31 -0
- package/lib/shared/index.js +32 -0
- package/lib/shared/spawn.d.ts +38 -0
- package/lib/shared/spawn.js +50 -0
- package/lib/shared/text.d.ts +14 -0
- package/lib/shared/text.js +26 -0
- package/package.json +82 -0
- package/skills/code-walkthrough/SKILL.md +94 -0
- package/skills/code-walkthrough/references/examples.md +129 -0
- package/skills/paper-dedup/SKILL.md +58 -0
- package/skills/paper-intake/SKILL.md +64 -0
- package/skills/thesis-citation/SKILL.md +67 -0
- package/skills/thesis-defense/SKILL.md +87 -0
- package/skills/thesis-eng-design/SKILL.md +97 -0
- package/skills/thesis-inquiry/SKILL.md +55 -0
- package/skills/thesis-inquiry/references/intent-spec-template.md +39 -0
- package/skills/thesis-literature/SKILL.md +56 -0
- package/skills/thesis-opener/SKILL.md +77 -0
- package/skills/thesis-pipeline/SKILL.md +121 -0
- package/skills/thesis-writing/SKILL.md +86 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.xlsx` 摄取:每个工作表渲染成一段「表名 + 表头 + 行」的 TSV 文本。
|
|
3
|
+
*
|
|
4
|
+
* 需要处理的 OOXML 细节(都按"够用即止"实现,不做完整 SpreadsheetML):
|
|
5
|
+
* - `xl/workbook.xml` 给出 `<sheet name r:id>`;`xl/_rels/workbook.xml.rels`
|
|
6
|
+
* 把 r:id 映射到 `worksheets/sheetN.xml`(两个文件都可能缺 rels,故有回退猜测);
|
|
7
|
+
* - `xl/sharedStrings.xml`:共享字符串表(`<si>` 可含多个 `<r><t>` 富文本片段);
|
|
8
|
+
* - 内联字符串 `t="inlineStr"`:值直接写在 `<is><t>` 里;
|
|
9
|
+
* - 空单元格补空列:按 `<c r="C5">` 的列字母定位,缺列就用空串占位,保证列对齐;
|
|
10
|
+
* - 日期:Excel 把日期存成序列号(如 45000)。本模块**原样保留数字**,
|
|
11
|
+
* 但在表头行标注该列是「日期序列(Excel 序列号)」,避免把 45000 当数据。
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-thesis/ingest
|
|
14
|
+
*/
|
|
15
|
+
import { failureResult, successResult, truncateText } from "./types.js";
|
|
16
|
+
import { ZipError, decodeBytes, openZip } from "./zip.js";
|
|
17
|
+
/** 单表渲染配置:单表最长字符数(与 CLI 无关的硬上限,防超大表吃内存)。 */
|
|
18
|
+
const SHEET_CHAR_LIMIT = 200_000;
|
|
19
|
+
/** 单表最多渲染的行数(超出即截断并标注)。 */
|
|
20
|
+
const SHEET_ROW_LIMIT = 5000;
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// 轻量 XML 取值
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
/** 取一个标签串里某个属性的值(值可用单/双引号,也可无引号)。 */
|
|
25
|
+
export function attr(tag, name) {
|
|
26
|
+
const match = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s/>]+))`).exec(tag);
|
|
27
|
+
if (match === null)
|
|
28
|
+
return undefined;
|
|
29
|
+
return match[1] ?? match[2] ?? match[3];
|
|
30
|
+
}
|
|
31
|
+
const TAG_RE = /<(\/?)([A-Za-z_][\w.:-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>/g;
|
|
32
|
+
/** 按出现顺序扫描标签(不构建 DOM,只给位置与属性)。 */
|
|
33
|
+
export function scanTags(xml) {
|
|
34
|
+
const out = [];
|
|
35
|
+
TAG_RE.lastIndex = 0;
|
|
36
|
+
let match = TAG_RE.exec(xml);
|
|
37
|
+
while (match !== null) {
|
|
38
|
+
out.push({
|
|
39
|
+
closing: match[1] === '/',
|
|
40
|
+
name: match[2],
|
|
41
|
+
attrs: match[3] ?? '',
|
|
42
|
+
selfClosing: match[4] === '/',
|
|
43
|
+
start: match.index,
|
|
44
|
+
end: match.index + match[0].length,
|
|
45
|
+
});
|
|
46
|
+
match = TAG_RE.exec(xml);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/** 标签短名(去掉可能存在的命名空间前缀)。 */
|
|
51
|
+
function shortName(name) {
|
|
52
|
+
const colon = name.lastIndexOf(':');
|
|
53
|
+
return colon === -1 ? name : name.slice(colon + 1);
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// workbook / rels
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
/** 从 `xl/workbook.xml` 抽工作表顺序与名字。 */
|
|
59
|
+
export function parseWorkbookSheets(workbookXml) {
|
|
60
|
+
const sheets = [];
|
|
61
|
+
const re = /<(?:\w+:)?sheet\b([^>]*?)(\/?)>/g;
|
|
62
|
+
let match = re.exec(workbookXml);
|
|
63
|
+
while (match !== null) {
|
|
64
|
+
const tag = match[1] ?? '';
|
|
65
|
+
const relId = attr(tag, 'r:id') ?? attr(tag, 'id');
|
|
66
|
+
const name = attr(tag, 'name') ?? `工作表${sheets.length + 1}`;
|
|
67
|
+
sheets.push({ name, ...(relId !== undefined ? { relId } : {}) });
|
|
68
|
+
match = re.exec(workbookXml);
|
|
69
|
+
}
|
|
70
|
+
return sheets;
|
|
71
|
+
}
|
|
72
|
+
/** 从 `xl/_rels/workbook.xml.rels` 抽 rId → 目标路径 的映射。 */
|
|
73
|
+
export function parseWorkbookRels(relsXml) {
|
|
74
|
+
const map = new Map();
|
|
75
|
+
const re = /<(?:\w+:)?Relationship\b([^>]*?)\/?>/g;
|
|
76
|
+
let match = re.exec(relsXml);
|
|
77
|
+
while (match !== null) {
|
|
78
|
+
const tag = match[1] ?? '';
|
|
79
|
+
const id = attr(tag, 'Id');
|
|
80
|
+
const target = attr(tag, 'Target');
|
|
81
|
+
if (id !== undefined && target !== undefined)
|
|
82
|
+
map.set(id, target);
|
|
83
|
+
match = re.exec(relsXml);
|
|
84
|
+
}
|
|
85
|
+
return map;
|
|
86
|
+
}
|
|
87
|
+
/** 把 rels 的 Target 归一到 ZIP 内的绝对路径。 */
|
|
88
|
+
function resolveTarget(target) {
|
|
89
|
+
let path = target.replace(/\\/g, '/');
|
|
90
|
+
if (path.startsWith('/'))
|
|
91
|
+
return path.slice(1);
|
|
92
|
+
// 相对 `xl/workbook.xml` 解析(Target 常见写法是 "worksheets/sheet1.xml")。
|
|
93
|
+
const parts = `xl/${path}`.split('/');
|
|
94
|
+
const out = [];
|
|
95
|
+
for (const part of parts) {
|
|
96
|
+
if (part === '' || part === '.')
|
|
97
|
+
continue;
|
|
98
|
+
if (part === '..')
|
|
99
|
+
out.pop();
|
|
100
|
+
else
|
|
101
|
+
out.push(part);
|
|
102
|
+
}
|
|
103
|
+
return out.join('/');
|
|
104
|
+
}
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// sharedStrings / styles
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
/** 抽出一段 XML 里所有 `<t>` 的文本(`<si>` 的富文本片段拼接用)。 */
|
|
109
|
+
function collectText(xml) {
|
|
110
|
+
return collectTags(xml, 't');
|
|
111
|
+
}
|
|
112
|
+
/** 抽出一段 XML 里指定标签的文本(共享字符串用 `t`,单元格值用 `v`)。 */
|
|
113
|
+
function collectTags(xml, tag) {
|
|
114
|
+
let out = '';
|
|
115
|
+
const re = new RegExp(`<(?:[\\w.-]+:)?${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[\\w.-]+:)?${tag}>`, 'g');
|
|
116
|
+
let match = re.exec(xml);
|
|
117
|
+
while (match !== null) {
|
|
118
|
+
out += decodeXmlLite(match[1] ?? '');
|
|
119
|
+
match = re.exec(xml);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
/** 最小实体解码(xlsx 里出现的实体与 docx 相同;此处避免多一次 import 循环)。 */
|
|
124
|
+
function decodeXmlLite(input) {
|
|
125
|
+
return input
|
|
126
|
+
.replace(/</g, '<')
|
|
127
|
+
.replace(/>/g, '>')
|
|
128
|
+
.replace(/"/g, '"')
|
|
129
|
+
.replace(/'/g, "'")
|
|
130
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (m, hex) => {
|
|
131
|
+
const code = Number.parseInt(hex, 16);
|
|
132
|
+
return code >= 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : m;
|
|
133
|
+
})
|
|
134
|
+
.replace(/&#([0-9]+);/g, (m, dec) => {
|
|
135
|
+
const code = Number.parseInt(dec, 10);
|
|
136
|
+
return code >= 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : m;
|
|
137
|
+
})
|
|
138
|
+
.replace(/&/g, '&');
|
|
139
|
+
}
|
|
140
|
+
/** 解析 `xl/sharedStrings.xml`:索引 → 文本。 */
|
|
141
|
+
export function parseSharedStrings(xml) {
|
|
142
|
+
const items = [];
|
|
143
|
+
const re = /<(?:[\w.-]+:)?si(?:\s[^>]*)?>([\s\S]*?)<\/(?:[\w.-]+:)?si>/g;
|
|
144
|
+
let match = re.exec(xml);
|
|
145
|
+
while (match !== null) {
|
|
146
|
+
items.push(collectText(match[1] ?? ''));
|
|
147
|
+
match = re.exec(xml);
|
|
148
|
+
}
|
|
149
|
+
return items;
|
|
150
|
+
}
|
|
151
|
+
/** 内置的日期/时间数字格式 id(ECMA-376 第 18 章)。 */
|
|
152
|
+
const BUILTIN_DATE_FORMATS = new Set([
|
|
153
|
+
14, 15, 16, 17, 18, 19, 20, 21, 22, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36,
|
|
154
|
+
45, 46, 47, 50, 51, 52, 53, 54, 55, 56, 57, 58,
|
|
155
|
+
]);
|
|
156
|
+
/** 自定义格式串是否像日期/时间(去掉引号、转义、颜色/条件段后看 ymdhs 字符)。 */
|
|
157
|
+
export function isDateFormatCode(code) {
|
|
158
|
+
const cleaned = code
|
|
159
|
+
.replace(/\[[^\]]*\]/g, '')
|
|
160
|
+
.replace(/\\./g, '')
|
|
161
|
+
.replace(/"[^"]*"/g, '');
|
|
162
|
+
// 去掉第 4 段(条件/颜色)之前的处理已够;这里只看是否含日期时间占位符。
|
|
163
|
+
return /[ymdhs]/i.test(cleaned) && !/^[#0.,%]+$/.test(cleaned);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* 解析 `xl/styles.xml`:返回「cellXfs 下标 → 是否日期格式」。
|
|
167
|
+
* 有些工具不写 cellXfs(此时全按非日期处理,数值原样保留)。
|
|
168
|
+
*/
|
|
169
|
+
export function parseStyleDateFlags(stylesXml) {
|
|
170
|
+
if (stylesXml === undefined)
|
|
171
|
+
return [];
|
|
172
|
+
const custom = new Map();
|
|
173
|
+
const fmtRe = /<(?:\w+:)?numFmt\b([^>]*?)\/?>/g;
|
|
174
|
+
let match = fmtRe.exec(stylesXml);
|
|
175
|
+
while (match !== null) {
|
|
176
|
+
const tag = match[1] ?? '';
|
|
177
|
+
const id = Number.parseInt(attr(tag, 'numFmtId') ?? '', 10);
|
|
178
|
+
const code = attr(tag, 'formatCode');
|
|
179
|
+
if (Number.isFinite(id) && code !== undefined)
|
|
180
|
+
custom.set(id, isDateFormatCode(code));
|
|
181
|
+
match = fmtRe.exec(stylesXml);
|
|
182
|
+
}
|
|
183
|
+
const cellXfsMatch = /<(?:\w+:)?cellXfs\b[^>]*>([\s\S]*?)<\/(?:\w+:)?cellXfs>/.exec(stylesXml);
|
|
184
|
+
if (cellXfsMatch === null)
|
|
185
|
+
return [];
|
|
186
|
+
const flags = [];
|
|
187
|
+
const xfRe = /<(?:\w+:)?xf\b([^>]*?)\/?>/g;
|
|
188
|
+
let xf = xfRe.exec(cellXfsMatch[1] ?? '');
|
|
189
|
+
while (xf !== null) {
|
|
190
|
+
const tag = xf[1] ?? '';
|
|
191
|
+
const id = Number.parseInt(attr(tag, 'numFmtId') ?? '0', 10);
|
|
192
|
+
const isDate = custom.get(id) ?? BUILTIN_DATE_FORMATS.has(id);
|
|
193
|
+
flags.push(isDate);
|
|
194
|
+
xf = xfRe.exec(cellXfsMatch[1] ?? '');
|
|
195
|
+
}
|
|
196
|
+
return flags;
|
|
197
|
+
}
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// 单元格引用
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
/** `C12` → 列下标 2(0 基);非法引用返回 0。 */
|
|
202
|
+
export function columnIndex(ref) {
|
|
203
|
+
let index = 0;
|
|
204
|
+
let seen = false;
|
|
205
|
+
for (let i = 0; i < ref.length; i += 1) {
|
|
206
|
+
const code = ref.charCodeAt(i);
|
|
207
|
+
const upper = code >= 97 && code <= 122 ? code - 32 : code;
|
|
208
|
+
if (upper < 65 || upper > 90)
|
|
209
|
+
break;
|
|
210
|
+
index = index * 26 + (upper - 64);
|
|
211
|
+
seen = true;
|
|
212
|
+
}
|
|
213
|
+
return seen ? index - 1 : 0;
|
|
214
|
+
}
|
|
215
|
+
/** 解析一行里的单元格。 */
|
|
216
|
+
export function parseRow(rowXml, shared, dateFlags) {
|
|
217
|
+
const cells = [];
|
|
218
|
+
// 逐标签扫描(正则匹配"属性 + 开/闭标签"容易在自闭合标签上出错):
|
|
219
|
+
// 每遇到一个 <c> 就取它的属性串,再用下一个标签的结束位置切出 body。
|
|
220
|
+
const tags = scanTags(rowXml);
|
|
221
|
+
for (const [index, tag] of tags.entries()) {
|
|
222
|
+
if (shortName(tag.name) !== 'c' || tag.closing)
|
|
223
|
+
continue;
|
|
224
|
+
const ref = attr(tag.attrs, 'r') ?? '';
|
|
225
|
+
const column = columnIndex(ref);
|
|
226
|
+
while (cells.length < column)
|
|
227
|
+
cells.push({ kind: 'text', text: '' });
|
|
228
|
+
// body = 开标签结束 → **配对**闭标签开始(单元格内还有 <v>/<is> 等子标签,
|
|
229
|
+
// 不能只看"下一个标签");自闭合或找不到配对闭标签时视为空。
|
|
230
|
+
let body = '';
|
|
231
|
+
if (!tag.selfClosing) {
|
|
232
|
+
let depth = 0;
|
|
233
|
+
for (let k = index + 1; k < tags.length; k += 1) {
|
|
234
|
+
const candidate = tags[k];
|
|
235
|
+
if (!candidate.closing) {
|
|
236
|
+
if (!candidate.selfClosing && shortName(candidate.name) === 'c')
|
|
237
|
+
depth += 1;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (shortName(candidate.name) !== 'c')
|
|
241
|
+
continue;
|
|
242
|
+
if (depth > 0) {
|
|
243
|
+
depth -= 1;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
body = rowXml.slice(tag.end, candidate.start);
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const type = attr(tag.attrs, 't') ?? 'n';
|
|
251
|
+
const styleIndex = Number.parseInt(attr(tag.attrs, 's') ?? '', 10);
|
|
252
|
+
const isDate = Number.isFinite(styleIndex) && dateFlags[styleIndex] === true;
|
|
253
|
+
let value;
|
|
254
|
+
if (type === 'inlineStr') {
|
|
255
|
+
value = { kind: 'text', text: collectText(body) };
|
|
256
|
+
}
|
|
257
|
+
else if (type === 's') {
|
|
258
|
+
const sharedIndex = Number.parseInt(collectTags(body, 'v').trim(), 10);
|
|
259
|
+
value = { kind: 'text', text: Number.isFinite(sharedIndex) ? shared[sharedIndex] ?? '' : '' };
|
|
260
|
+
}
|
|
261
|
+
else if (type === 'b') {
|
|
262
|
+
value = { kind: 'text', text: collectTags(body, 'v').trim() === '1' ? 'TRUE' : 'FALSE' };
|
|
263
|
+
}
|
|
264
|
+
else if (type === 'str') {
|
|
265
|
+
value = { kind: 'text', text: collectTags(body, 'v') };
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
const raw = collectTags(body, 'v').trim();
|
|
269
|
+
value = { kind: isDate && raw !== '' ? 'date' : 'number', text: raw };
|
|
270
|
+
}
|
|
271
|
+
cells.length = column;
|
|
272
|
+
cells.push(value);
|
|
273
|
+
}
|
|
274
|
+
return cells;
|
|
275
|
+
}
|
|
276
|
+
/** 把一行单元格渲染成 TSV(空单元格补空列,保证对齐)。 */
|
|
277
|
+
function renderRow(cells) {
|
|
278
|
+
const parts = [];
|
|
279
|
+
for (const cell of cells) {
|
|
280
|
+
// 值里的制表符/换行会破坏 TSV 对齐:换成空格。
|
|
281
|
+
parts.push(cell.text.replace(/[\t\r\n]+/g, ' ').trim());
|
|
282
|
+
}
|
|
283
|
+
return parts.join('\t');
|
|
284
|
+
}
|
|
285
|
+
/** 渲染一个工作表:表名 + 表头 + TSV 行。 */
|
|
286
|
+
export function renderSheet(name, sheetXml, shared, dateFlags) {
|
|
287
|
+
const rowRe = /<(?:[\w.-]+:)?row\b([^>]*?)(?:\/>|>([\s\S]*?)<\/(?:[\w.-]+:)?row>)/g;
|
|
288
|
+
let match = rowRe.exec(sheetXml);
|
|
289
|
+
const lines = [];
|
|
290
|
+
const dateColumns = new Set();
|
|
291
|
+
let dataRows = 0;
|
|
292
|
+
let first = true;
|
|
293
|
+
let truncatedRows = false;
|
|
294
|
+
while (match !== null) {
|
|
295
|
+
const body = match[2] ?? '';
|
|
296
|
+
const cells = parseRow(body, shared, dateFlags);
|
|
297
|
+
if (cells.some(cell => cell.text.trim() !== '')) {
|
|
298
|
+
if (dataRows >= SHEET_ROW_LIMIT) {
|
|
299
|
+
truncatedRows = true;
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
dataRows += 1;
|
|
303
|
+
if (!first) {
|
|
304
|
+
for (let i = 0; i < cells.length; i += 1) {
|
|
305
|
+
if (cells[i].kind === 'date')
|
|
306
|
+
dateColumns.add(i);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
lines.push(renderRow(cells));
|
|
310
|
+
first = false;
|
|
311
|
+
}
|
|
312
|
+
match = rowRe.exec(sheetXml);
|
|
313
|
+
}
|
|
314
|
+
const hints = [];
|
|
315
|
+
if (dateColumns.size > 0) {
|
|
316
|
+
const letters = [...dateColumns].sort((a, b) => a - b).map(i => `第 ${i + 1} 列`);
|
|
317
|
+
hints.push(`列类型提示:${letters.join('、')}为日期列,值保留为 Excel 序列号(如 45000 表示某天,需自行换算)。`);
|
|
318
|
+
}
|
|
319
|
+
if (truncatedRows)
|
|
320
|
+
hints.push(`该表超过 ${SHEET_ROW_LIMIT} 行,只渲染前 ${SHEET_ROW_LIMIT} 行。`);
|
|
321
|
+
const header = [
|
|
322
|
+
`## 工作表:${name}`,
|
|
323
|
+
`(共 ${dataRows} 行非空数据)`,
|
|
324
|
+
...hints,
|
|
325
|
+
'',
|
|
326
|
+
].join('\n');
|
|
327
|
+
const body = lines.join('\n');
|
|
328
|
+
const whole = `${header}${body}\n`;
|
|
329
|
+
const limited = truncateText(whole, SHEET_CHAR_LIMIT);
|
|
330
|
+
return { text: limited.text, rows: dataRows, truncated: limited.truncated, truncatedRows };
|
|
331
|
+
}
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// 入口
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
/** 读取一个 .xlsx:返回按工作表顺序渲染的 TSV 文本。 */
|
|
336
|
+
export function readXlsx(bytes) {
|
|
337
|
+
const zip = openZip(bytes);
|
|
338
|
+
const workbookBytes = zip.read('xl/workbook.xml');
|
|
339
|
+
if (workbookBytes === undefined) {
|
|
340
|
+
throw new ZipError('missing-workbook', '这个 ZIP 里没有 xl/workbook.xml:不是有效的 .xlsx(可能是 .ods 或已损坏)。请用 Excel/WPS 另存为 .xlsx。');
|
|
341
|
+
}
|
|
342
|
+
const workbookXml = decodeBytes(workbookBytes);
|
|
343
|
+
const sheets = parseWorkbookSheets(workbookXml);
|
|
344
|
+
if (sheets.length === 0) {
|
|
345
|
+
throw new ZipError('no-sheets', '工作簿里没有任何工作表:无法抽取内容。');
|
|
346
|
+
}
|
|
347
|
+
const relsBytes = zip.read('xl/_rels/workbook.xml.rels');
|
|
348
|
+
const rels = relsBytes !== undefined ? parseWorkbookRels(decodeBytes(relsBytes)) : new Map();
|
|
349
|
+
const sharedBytes = zip.read('xl/sharedStrings.xml');
|
|
350
|
+
const shared = sharedBytes !== undefined ? parseSharedStrings(decodeBytes(sharedBytes)) : [];
|
|
351
|
+
const stylesBytes = zip.read('xl/styles.xml');
|
|
352
|
+
const dateFlags = parseStyleDateFlags(stylesBytes !== undefined ? decodeBytes(stylesBytes) : undefined);
|
|
353
|
+
const blocks = [];
|
|
354
|
+
let rendered = 0;
|
|
355
|
+
for (const [index, sheet] of sheets.entries()) {
|
|
356
|
+
const candidates = [];
|
|
357
|
+
const target = sheet.relId !== undefined ? rels.get(sheet.relId) : undefined;
|
|
358
|
+
if (target !== undefined)
|
|
359
|
+
candidates.push(resolveTarget(target));
|
|
360
|
+
candidates.push(`xl/worksheets/sheet${index + 1}.xml`);
|
|
361
|
+
// 有些工具把表放在别的目录,或 rels 与文件名不一致:按出现顺序兜底找。
|
|
362
|
+
const available = zip.names('xl/', '.xml');
|
|
363
|
+
for (const candidate of available) {
|
|
364
|
+
if (/^xl\/worksheets\/[^/]+\.xml$/.test(candidate) && !candidates.includes(candidate))
|
|
365
|
+
candidates.push(candidate);
|
|
366
|
+
}
|
|
367
|
+
const path = candidates.find(candidate => zip.has(candidate));
|
|
368
|
+
if (path === undefined) {
|
|
369
|
+
blocks.push(`## 工作表:${sheet.name}\n(未找到该表的 XML 数据,可能被删除或使用了非常规结构)\n`);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const renderedSheet = renderSheet(sheet.name, decodeBytes(zip.require(path)), shared, dateFlags);
|
|
373
|
+
blocks.push(renderedSheet.text);
|
|
374
|
+
rendered += 1;
|
|
375
|
+
}
|
|
376
|
+
return { text: blocks.join('\n'), sheets: rendered };
|
|
377
|
+
}
|
|
378
|
+
/** 门面用入口:xlsx 字节 → 抽取结果。 */
|
|
379
|
+
export function extractXlsx(bytes, options) {
|
|
380
|
+
let read;
|
|
381
|
+
try {
|
|
382
|
+
read = readXlsx(bytes);
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
const reason = error instanceof ZipError
|
|
386
|
+
? error.message
|
|
387
|
+
: `读取 .xlsx 失败:${error instanceof Error ? error.message : String(error)}`;
|
|
388
|
+
return failureResult(reason);
|
|
389
|
+
}
|
|
390
|
+
if (!hasAnyData(read.text)) {
|
|
391
|
+
return failureResult('这个 .xlsx 的所有工作表都是空的:没有可摄取的数据。');
|
|
392
|
+
}
|
|
393
|
+
return successResult({
|
|
394
|
+
text: read.text,
|
|
395
|
+
maxChars: options.maxChars,
|
|
396
|
+
note: `共 ${read.sheets} 个工作表;表内空单元格已补空列,列对齐可保,但公式只保留缓存值。`,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
/** 工作表是否有任何非空数据行(供"空工作簿"判定)。 */
|
|
400
|
+
export function hasAnyData(text) {
|
|
401
|
+
for (const line of text.split('\n')) {
|
|
402
|
+
const trimmed = line.trim();
|
|
403
|
+
if (trimmed === '' || trimmed.startsWith('#'))
|
|
404
|
+
continue;
|
|
405
|
+
if (trimmed.startsWith('(') || trimmed.startsWith('列类型提示'))
|
|
406
|
+
continue;
|
|
407
|
+
if (trimmed.replace(/\t/g, '') !== '')
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 零依赖 ZIP **读取器**(与 `paper/lib/zip.ts` 的写入器配对)。
|
|
3
|
+
*
|
|
4
|
+
* .docx/.xlsx/.pptx 都是 ZIP 容器,本文件负责把里面的条目安全地取出来:
|
|
5
|
+
* - 从尾部反向定位 EOCD(`0x06054b50`),再解析中央目录项(`0x02014b50`);
|
|
6
|
+
* - 只支持 method 0(STORE)与 method 8(DEFLATE,用 `node:zlib` 的
|
|
7
|
+
* `inflateRawSync`);其余压缩方式显式报错,绝不猜;
|
|
8
|
+
* - 文件名:general purpose flag bit 11 = UTF-8;否则按 CP437 解码(ZIP 规范
|
|
9
|
+
* 的历史默认),再退到 `TextDecoder('gbk')`——中文 Windows 的压缩工具常
|
|
10
|
+
* 直接用 GBK 写条目名;
|
|
11
|
+
* - **防御损坏输入**:截断、越界、ZIP64、条目数/单条目大小超限一律抛
|
|
12
|
+
* {@link ZipError}(可读中文原因),不返回半截垃圾;可选 CRC32 校验。
|
|
13
|
+
*
|
|
14
|
+
* 为什么不引 fflate/yauzl:本插件要求零第三方依赖(与写入器一致),
|
|
15
|
+
* OOXML 用到的两个压缩方法用一个 `inflateRawSync` 就够。
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-thesis/ingest
|
|
18
|
+
*/
|
|
19
|
+
/** 计算 CRC32(校验 STORE/DEFLATE 条目完整性)。 */
|
|
20
|
+
export declare function crc32(data: Uint8Array): number;
|
|
21
|
+
/** ZIP 解析/解压错误:message 是给人看的中文原因,code 便于测试断言。 */
|
|
22
|
+
export declare class ZipError extends Error {
|
|
23
|
+
readonly code: string;
|
|
24
|
+
constructor(code: string, message: string);
|
|
25
|
+
}
|
|
26
|
+
/** 一个中央目录条目(懒解压:只有 read 时才碰数据)。 */
|
|
27
|
+
export interface ZipEntry {
|
|
28
|
+
/** 条目名(ZIP 内路径,统一用 `/`)。 */
|
|
29
|
+
readonly name: string;
|
|
30
|
+
/** 压缩方法:0=STORE,8=DEFLATE。 */
|
|
31
|
+
readonly method: number;
|
|
32
|
+
/** 压缩后大小(字节)。 */
|
|
33
|
+
readonly compressedSize: number;
|
|
34
|
+
/** 解压后大小(字节)。 */
|
|
35
|
+
readonly size: number;
|
|
36
|
+
/** 目录条目(名字以 `/` 结尾)。 */
|
|
37
|
+
readonly directory: boolean;
|
|
38
|
+
}
|
|
39
|
+
/** ZIP 容器读取器:构造时即解析中央目录,条目数据懒解压并缓存。 */
|
|
40
|
+
export declare class ZipReader {
|
|
41
|
+
private readonly bytes;
|
|
42
|
+
private readonly view;
|
|
43
|
+
private readonly entries;
|
|
44
|
+
private readonly byName;
|
|
45
|
+
private readonly cache;
|
|
46
|
+
constructor(bytes: Uint8Array);
|
|
47
|
+
/** 全部条目(含目录条目),按中央目录顺序。 */
|
|
48
|
+
list(): readonly ZipEntry[];
|
|
49
|
+
has(name: string): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* 按名字取条目字节;不存在返回 undefined。
|
|
52
|
+
* 解压失败/校验失败抛 {@link ZipError}(不返回半截数据)。
|
|
53
|
+
*/
|
|
54
|
+
read(name: string): Uint8Array | undefined;
|
|
55
|
+
/** 必须存在的条目;缺失抛 ZipError(给出「可能是别的格式」的提示)。 */
|
|
56
|
+
require(name: string): Uint8Array;
|
|
57
|
+
/** 条目名里所有以 `prefix` 开头、以 `suffix` 结尾的名字(升序)。 */
|
|
58
|
+
names(prefix?: string, suffix?: string): string[];
|
|
59
|
+
/** 从文件尾反向定位 EOCD 签名(最多回退 64KB+22)。 */
|
|
60
|
+
private findEocd;
|
|
61
|
+
private parseCentralDirectory;
|
|
62
|
+
private readEntry;
|
|
63
|
+
private inflate;
|
|
64
|
+
}
|
|
65
|
+
/** 打开一个 ZIP 容器(解析失败抛 {@link ZipError})。 */
|
|
66
|
+
export declare function openZip(bytes: Uint8Array): ZipReader;
|
|
67
|
+
/** 按名字读一个条目;容器损坏抛 {@link ZipError},条目缺失返回 undefined。 */
|
|
68
|
+
export declare function readZipEntry(bytes: Uint8Array, name: string): Uint8Array | undefined;
|
|
69
|
+
/** 条目名清单(便捷函数,跳过目录条目)。 */
|
|
70
|
+
export declare function zipEntryNames(bytes: Uint8Array): string[];
|
|
71
|
+
/** 把条目字节按 UTF-8 解码成文本(XML/文本条目用)。 */
|
|
72
|
+
export declare function zipText(bytes: Uint8Array, name: string): string | undefined;
|
|
73
|
+
/** 测试/调试辅助:条目名在字节流里是否存在(不改动输入)。 */
|
|
74
|
+
export declare function zipContainsName(zip: Uint8Array, name: string): boolean;
|
|
75
|
+
/** 内部:给 ooxml 的其余部分复用的 UTF-8/GBK 解码。 */
|
|
76
|
+
export declare function decodeBytes(bytes: Uint8Array): string;
|