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,1016 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.pdf` **尽力而为**的文本抽取(零依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 能抽出来的:
|
|
5
|
+
* - 文本型 PDF:解析间接对象、解 `FlateDecode`(`node:zlib` 的 `inflateRawSync`)、
|
|
6
|
+
* `ASCIIHexDecode`/`ASCII85Decode` 流;扫描内容流里 `BT…ET` 块中的
|
|
7
|
+
* `Tj` / `TJ` / `'` / `"` 操作数(含 `\(` `\)` `\\` 与八进制转义);
|
|
8
|
+
* - 有 `ToUnicode` CMap 的字体:用 CMap 还原字符(含中文、符号字体);
|
|
9
|
+
* - 走 `/Resources /Font` 的字体名 → CMap 映射,`Tf` 切换字体时同步切换解码表。
|
|
10
|
+
*
|
|
11
|
+
* **抽不出来的(会明确说,绝不编造)**:
|
|
12
|
+
* - 加密 PDF(`/Encrypt`):直接失败;
|
|
13
|
+
* - 扫描版 PDF(页面只有图片、没有文字对象):失败并提示"请提供 .docx 或复制正文";
|
|
14
|
+
* - 对象流(Object Stream)/ 交叉引用流:不解析,可能少读页面(有提示);
|
|
15
|
+
* - 只用 CID 编码、又没有 ToUnicode 的嵌入子集字体:字节无法还原成字符,
|
|
16
|
+
* 结果会被清成占位符并标 `lowConfidence`;
|
|
17
|
+
* - 表格/分栏:PDF 没有语义,行序按内容流顺序,可能错乱。
|
|
18
|
+
*
|
|
19
|
+
* 文字抽取率低(少于 200 字符但页数 > 3)时结果带 `lowConfidence: true`。
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-thesis/ingest
|
|
22
|
+
*/
|
|
23
|
+
import { inflateRawSync } from 'node:zlib';
|
|
24
|
+
import { failureResult, truncateText } from "./types.js";
|
|
25
|
+
/** 单文件字节上限(PDF 可能很大,超过就不硬啃)。 */
|
|
26
|
+
export const PDF_MAX_BYTES = 64 * 1024 * 1024;
|
|
27
|
+
/** 单流解压上限。 */
|
|
28
|
+
const STREAM_LIMIT = 16 * 1024 * 1024;
|
|
29
|
+
/** 解析的对象数上限。 */
|
|
30
|
+
const OBJECT_LIMIT = 20000;
|
|
31
|
+
/** 低置信阈值。 */
|
|
32
|
+
const LOW_CHARS = 200;
|
|
33
|
+
const LOW_PAGES = 3;
|
|
34
|
+
/** TJ 数组里超过该调整量(1000 分之一 em)就补一个空格。 */
|
|
35
|
+
const TJ_SPACE_THRESHOLD = 120;
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// 字节 → latin1 字符串(PDF 的语法字符全是 ASCII,数据用等值 latin1 保真)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
/** 等值 latin1 解码(每字节一个码位,绝不丢数据)。 */
|
|
40
|
+
export function bytesToLatin1(bytes) {
|
|
41
|
+
let out = '';
|
|
42
|
+
const chunk = 8192;
|
|
43
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
44
|
+
out += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
/** CFX 风格的过滤器个数统计(只用兼容名,`/Fl` 这类短名不猜)。 */
|
|
49
|
+
function countFilter(dict, names) {
|
|
50
|
+
let count = 0;
|
|
51
|
+
for (const name of names) {
|
|
52
|
+
const re = new RegExp(`/${name}\\b`, 'g');
|
|
53
|
+
const found = dict.match(re);
|
|
54
|
+
if (found !== null)
|
|
55
|
+
count += found.length;
|
|
56
|
+
}
|
|
57
|
+
return count;
|
|
58
|
+
}
|
|
59
|
+
function isObjectStream(dict) {
|
|
60
|
+
return /\/Type\s*\/ObjStm\b/.test(dict);
|
|
61
|
+
}
|
|
62
|
+
function classify(dict, hasStream) {
|
|
63
|
+
if (/\/Type\s*\/Page[^s]/.test(dict) || /\/Type\s*\/Page\s*(?:\/|>>)/.test(dict))
|
|
64
|
+
return 'page';
|
|
65
|
+
if (hasStream)
|
|
66
|
+
return 'stream';
|
|
67
|
+
if (dict.length > 0)
|
|
68
|
+
return 'dict';
|
|
69
|
+
return 'unknown';
|
|
70
|
+
}
|
|
71
|
+
/** 从对象体里取流数据(未解压)。 */
|
|
72
|
+
function extractStream(body, dict) {
|
|
73
|
+
const decoded = latin1ToBytes(body);
|
|
74
|
+
const marker = /stream\r\n|stream\n|stream\r/.exec(body);
|
|
75
|
+
if (marker === null)
|
|
76
|
+
return undefined;
|
|
77
|
+
const start = marker.index + marker[0].length;
|
|
78
|
+
const lengthMatch = /\/Length\s+(\d+)(?!\s+\d+\s+R)/.exec(dict);
|
|
79
|
+
if (lengthMatch !== null) {
|
|
80
|
+
const length = Number.parseInt(lengthMatch[1], 10);
|
|
81
|
+
if (Number.isFinite(length) && length >= 0 && start + length <= decoded.length) {
|
|
82
|
+
return decoded.subarray(start, start + length);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const end = body.indexOf('endstream', start);
|
|
86
|
+
if (end === -1)
|
|
87
|
+
return undefined;
|
|
88
|
+
// 数据末尾的换行不属于流内容。
|
|
89
|
+
let stop = end;
|
|
90
|
+
if (stop > start && decoded[stop - 1] === 0x0a)
|
|
91
|
+
stop -= 1;
|
|
92
|
+
if (stop > start && decoded[stop - 1] === 0x0d)
|
|
93
|
+
stop -= 1;
|
|
94
|
+
return decoded.subarray(start, stop);
|
|
95
|
+
}
|
|
96
|
+
/** latin1 字符串 → 字节。 */
|
|
97
|
+
function latin1ToBytes(text) {
|
|
98
|
+
const out = new Uint8Array(text.length);
|
|
99
|
+
for (let i = 0; i < text.length; i += 1)
|
|
100
|
+
out[i] = text.charCodeAt(i) & 0xff;
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
/** 解析全部间接对象(`N G obj … endobj`)。 */
|
|
104
|
+
export function parseObjects(raw) {
|
|
105
|
+
const objects = [];
|
|
106
|
+
const re = /(\d+)\s+(\d+)\s+obj\b/g;
|
|
107
|
+
let match = re.exec(raw);
|
|
108
|
+
while (match !== null) {
|
|
109
|
+
if (objects.length >= OBJECT_LIMIT)
|
|
110
|
+
break;
|
|
111
|
+
const num = Number.parseInt(match[1], 10);
|
|
112
|
+
const bodyStart = re.lastIndex;
|
|
113
|
+
const end = raw.indexOf('endobj', bodyStart);
|
|
114
|
+
const body = end === -1 ? raw.slice(bodyStart) : raw.slice(bodyStart, end);
|
|
115
|
+
const streamAt = body.search(/stream\r\n|stream\n|stream\r/);
|
|
116
|
+
const dict = streamAt === -1 ? body : body.slice(0, streamAt);
|
|
117
|
+
const stream = extractStream(body, dict);
|
|
118
|
+
objects.push({ num, dict, ...(stream !== undefined ? { stream } : {}), kind: classify(dict, stream !== undefined) });
|
|
119
|
+
if (end === -1)
|
|
120
|
+
break;
|
|
121
|
+
re.lastIndex = end + 'endobj'.length;
|
|
122
|
+
match = re.exec(raw);
|
|
123
|
+
}
|
|
124
|
+
return objects;
|
|
125
|
+
}
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// 流解码
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
function asciiHexDecode(bytes) {
|
|
130
|
+
const text = bytesToLatin1(bytes);
|
|
131
|
+
const digits = [];
|
|
132
|
+
for (const ch of text) {
|
|
133
|
+
if (ch === '>')
|
|
134
|
+
break;
|
|
135
|
+
const code = ch.charCodeAt(0);
|
|
136
|
+
if (code >= 48 && code <= 57)
|
|
137
|
+
digits.push(code - 48);
|
|
138
|
+
else if (code >= 65 && code <= 70)
|
|
139
|
+
digits.push(code - 55);
|
|
140
|
+
else if (code >= 97 && code <= 102)
|
|
141
|
+
digits.push(code - 87);
|
|
142
|
+
}
|
|
143
|
+
const out = new Uint8Array(Math.floor(digits.length / 2));
|
|
144
|
+
for (let i = 0; i < out.length; i += 1)
|
|
145
|
+
out[i] = (digits[i * 2] << 4) | digits[i * 2 + 1];
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
function ascii85Decode(bytes) {
|
|
149
|
+
const text = bytesToLatin1(bytes);
|
|
150
|
+
const out = [];
|
|
151
|
+
let group = [];
|
|
152
|
+
for (const ch of text) {
|
|
153
|
+
if (ch === '~')
|
|
154
|
+
break;
|
|
155
|
+
if (ch === 'z' && group.length === 0) {
|
|
156
|
+
out.push(0, 0, 0, 0);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const code = ch.charCodeAt(0);
|
|
160
|
+
if (code < 33 || code > 117)
|
|
161
|
+
continue;
|
|
162
|
+
group.push(code - 33);
|
|
163
|
+
if (group.length === 5) {
|
|
164
|
+
let value = 0;
|
|
165
|
+
for (const digit of group)
|
|
166
|
+
value = value * 85 + digit;
|
|
167
|
+
out.push((value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff);
|
|
168
|
+
group = [];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (group.length > 1) {
|
|
172
|
+
const pad = 5 - group.length;
|
|
173
|
+
for (let i = 0; i < pad; i += 1)
|
|
174
|
+
group.push(84);
|
|
175
|
+
let value = 0;
|
|
176
|
+
for (const digit of group)
|
|
177
|
+
value = value * 85 + digit;
|
|
178
|
+
const full = [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff];
|
|
179
|
+
out.push(...full.slice(0, 4 - pad));
|
|
180
|
+
}
|
|
181
|
+
return new Uint8Array(out);
|
|
182
|
+
}
|
|
183
|
+
/** 按过滤器解码一个流;返回 undefined 表示方法不支持(调用方据此提示)。 */
|
|
184
|
+
export function decodeStream(data, dict) {
|
|
185
|
+
const flate = countFilter(dict, ['FlateDecode', 'Fl']);
|
|
186
|
+
if (flate > 0) {
|
|
187
|
+
try {
|
|
188
|
+
// 注意:**不能**把 maxOutputLength 设成 /Length——那是压缩后长度,
|
|
189
|
+
// 解压结果通常更大,会误判成失败。这里只设硬上限防爆。
|
|
190
|
+
return inflateRawSync(data, { maxOutputLength: STREAM_LIMIT });
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
try {
|
|
194
|
+
// 有些流前面带 zlib 头(不应出现,但存在这种坏文件):去掉两字节再试。
|
|
195
|
+
return inflateRawSync(data.subarray(2), { maxOutputLength: STREAM_LIMIT });
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (countFilter(dict, ['ASCIIHexDecode', 'AHx']) > 0)
|
|
203
|
+
return asciiHexDecode(data);
|
|
204
|
+
if (countFilter(dict, ['ASCII85Decode', 'A85']) > 0)
|
|
205
|
+
return ascii85Decode(data);
|
|
206
|
+
// 无过滤器:原始字节
|
|
207
|
+
if (!/\/Filter\b/.test(dict))
|
|
208
|
+
return data;
|
|
209
|
+
// LZW / RunLength / DCT / JPX 等:不实现,交给调用方提示。
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
function hexToCode(hex) {
|
|
213
|
+
const value = Number.parseInt(hex, 16);
|
|
214
|
+
return Number.isFinite(value) ? value : 0;
|
|
215
|
+
}
|
|
216
|
+
function parseHexString(hex) {
|
|
217
|
+
const clean = hex.replace(/[^0-9a-fA-F]/g, '');
|
|
218
|
+
let out = '';
|
|
219
|
+
for (let i = 0; i + 3 < clean.length + 1 && i + 4 <= clean.length; i += 4) {
|
|
220
|
+
const code = Number.parseInt(clean.slice(i, i + 4), 16);
|
|
221
|
+
if (Number.isFinite(code) && code !== 0)
|
|
222
|
+
out += String.fromCharCode(code);
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
/** 解析 ToUnicode CMap 流 → 字体映射。 */
|
|
227
|
+
export function parseToUnicode(cmapText) {
|
|
228
|
+
const map = new Map();
|
|
229
|
+
let codeBytes = 1;
|
|
230
|
+
const spaceMatch = /begincodespacerange([\s\S]*?)endcodespacerange/.exec(cmapText);
|
|
231
|
+
if (spaceMatch !== null) {
|
|
232
|
+
for (const item of spaceMatch[1].matchAll(/<([0-9a-fA-F]+)>\s*<([0-9a-fA-F]+)>/g)) {
|
|
233
|
+
if ((item[1] ?? '').length >= 4)
|
|
234
|
+
codeBytes = 2;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const bfchar = /beginbfchar([\s\S]*?)endbfchar/g;
|
|
238
|
+
let block = bfchar.exec(cmapText);
|
|
239
|
+
while (block !== null) {
|
|
240
|
+
for (const item of block[1].matchAll(/<([0-9a-fA-F]+)>\s*<([0-9a-fA-F]+)>/g)) {
|
|
241
|
+
map.set(hexToCode(item[1]), parseHexString(item[2]));
|
|
242
|
+
}
|
|
243
|
+
block = bfchar.exec(cmapText);
|
|
244
|
+
}
|
|
245
|
+
const bfrange = /beginbfrange([\s\S]*?)endbfrange/g;
|
|
246
|
+
block = bfrange.exec(cmapText);
|
|
247
|
+
while (block !== null) {
|
|
248
|
+
for (const item of block[1].matchAll(/<([0-9a-fA-F]+)>\s*<([0-9a-fA-F]+)>\s*(<[0-9a-fA-F]+>|\[[\s\S]*?\])/g)) {
|
|
249
|
+
const lo = hexToCode(item[1]);
|
|
250
|
+
const hi = hexToCode(item[2]);
|
|
251
|
+
const dest = item[3];
|
|
252
|
+
if (dest.startsWith('[')) {
|
|
253
|
+
let offset = 0;
|
|
254
|
+
for (const entry of dest.matchAll(/<([0-9a-fA-F]+)>/g)) {
|
|
255
|
+
if (lo + offset <= hi)
|
|
256
|
+
map.set(lo + offset, parseHexString(entry[1]));
|
|
257
|
+
offset += 1;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
const base = parseHexString(dest.slice(1, -1));
|
|
262
|
+
const baseCode = base.length > 0 ? base.charCodeAt(0) : 0;
|
|
263
|
+
for (let code = lo; code <= hi && code - lo < 65536; code += 1) {
|
|
264
|
+
map.set(code, String.fromCharCode(baseCode + (code - lo)));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
block = bfrange.exec(cmapText);
|
|
269
|
+
}
|
|
270
|
+
return { codeBytes, map };
|
|
271
|
+
}
|
|
272
|
+
/** 解析一页(或全局)的 `/Resources` → 字体名到解码表的映射。 */
|
|
273
|
+
export function buildFontTables(raw, objects) {
|
|
274
|
+
const tables = new Map();
|
|
275
|
+
// 字体对象号 → ToUnicode 对象号
|
|
276
|
+
const fontToUnicode = new Map();
|
|
277
|
+
for (const object of objects) {
|
|
278
|
+
const match = /\/ToUnicode\s+(\d+)\s+\d+\s+R/.exec(object.dict);
|
|
279
|
+
if (match !== null)
|
|
280
|
+
fontToUnicode.set(object.num, Number.parseInt(match[1], 10));
|
|
281
|
+
}
|
|
282
|
+
const resourceRe = /\/Font\s*<<([^>]*)>>/g;
|
|
283
|
+
let match = resourceRe.exec(raw);
|
|
284
|
+
while (match !== null) {
|
|
285
|
+
for (const item of match[1].matchAll(/\/([A-Za-z0-9#+._-]+)\s+(\d+)\s+\d+\s+R/g)) {
|
|
286
|
+
const name = item[1];
|
|
287
|
+
const fontNum = Number.parseInt(item[2], 10);
|
|
288
|
+
const cmapNum = fontToUnicode.get(fontNum);
|
|
289
|
+
let resource = { codeBytes: 1, map: undefined };
|
|
290
|
+
if (cmapNum !== undefined) {
|
|
291
|
+
const cmapObject = objects.find(object => object.num === cmapNum);
|
|
292
|
+
if (cmapObject?.stream !== undefined) {
|
|
293
|
+
const decoded = decodeStream(cmapObject.stream, cmapObject.dict);
|
|
294
|
+
if (decoded !== undefined) {
|
|
295
|
+
const parsed = parseToUnicode(bytesToLatin1(decoded));
|
|
296
|
+
resource = { codeBytes: parsed.codeBytes, map: parsed.map };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (!tables.has(name))
|
|
301
|
+
tables.set(name, resource);
|
|
302
|
+
}
|
|
303
|
+
match = resourceRe.exec(raw);
|
|
304
|
+
}
|
|
305
|
+
// 回退:全局唯一的 ToUnicode(只有一个字体时非常常见)。
|
|
306
|
+
if (tables.size === 0) {
|
|
307
|
+
const withCmap = fontToUnicode.values().next();
|
|
308
|
+
if (withCmap.done !== true) {
|
|
309
|
+
const cmapObject = objects.find(object => object.num === withCmap.value);
|
|
310
|
+
if (cmapObject?.stream !== undefined) {
|
|
311
|
+
const decoded = decodeStream(cmapObject.stream, cmapObject.dict);
|
|
312
|
+
if (decoded !== undefined) {
|
|
313
|
+
const parsed = parseToUnicode(bytesToLatin1(decoded));
|
|
314
|
+
tables.set('*', { codeBytes: parsed.codeBytes, map: parsed.map });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return tables;
|
|
320
|
+
}
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// 内容流文本抽取
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
const WHITESPACE = new Set(['\u0000', '\t', '\n', '\u000c', '\r', ' ']);
|
|
325
|
+
/** 空白判定(含 PDF 规范里的 \\0 \\t \\n \\f \\r 空格)。 */
|
|
326
|
+
function isWhitespace(ch) {
|
|
327
|
+
return ch !== undefined && WHITESPACE.has(ch);
|
|
328
|
+
}
|
|
329
|
+
/** 十六进制字符串(`<4E2D>` 的内容)→ 字节。 */
|
|
330
|
+
function hexToBytes(hexText) {
|
|
331
|
+
const hex = hexText.replace(/[^0-9a-fA-F]/g, '');
|
|
332
|
+
const bytes = [];
|
|
333
|
+
for (let k = 0; k + 1 < hex.length; k += 2)
|
|
334
|
+
bytes.push(Number.parseInt(hex.slice(k, k + 2), 16));
|
|
335
|
+
if (hex.length % 2 === 1)
|
|
336
|
+
bytes.push(Number.parseInt(`${hex.slice(-1)}0`, 16));
|
|
337
|
+
return bytes;
|
|
338
|
+
}
|
|
339
|
+
/** 若字节看起来就是一个数字字面量(`-300` / `0.5`),返回数值;否则 undefined。 */
|
|
340
|
+
function numericStringValue(bytes) {
|
|
341
|
+
if (bytes.length === 0)
|
|
342
|
+
return undefined;
|
|
343
|
+
let text = '';
|
|
344
|
+
for (const byte of bytes) {
|
|
345
|
+
if (byte > 0x7f)
|
|
346
|
+
return undefined;
|
|
347
|
+
text += String.fromCharCode(byte);
|
|
348
|
+
}
|
|
349
|
+
if (!/^[-+]?(?:\d+\.?\d*|\.\d+)$/.test(text.trim()))
|
|
350
|
+
return undefined;
|
|
351
|
+
const value = Number(text.trim());
|
|
352
|
+
return Number.isFinite(value) ? value : undefined;
|
|
353
|
+
}
|
|
354
|
+
/** 区分「字节数组」与「TJ 项数组」。 */
|
|
355
|
+
function isNumberArray(operand) {
|
|
356
|
+
return operand.length === 0 || typeof operand[0] === 'number';
|
|
357
|
+
}
|
|
358
|
+
/** 找数组(`[` 已消费)的配对 `]` 位置;找不到返回 -1。字符串内的 `]` 不算。 */
|
|
359
|
+
function findArrayEnd(content, start) {
|
|
360
|
+
let i = start;
|
|
361
|
+
let depth = 0;
|
|
362
|
+
while (i < content.length) {
|
|
363
|
+
const ch = content[i];
|
|
364
|
+
if (ch === '(') {
|
|
365
|
+
i = readPdfString(content, i).next;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (ch === '[') {
|
|
369
|
+
depth += 1;
|
|
370
|
+
i += 1;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (ch === ']') {
|
|
374
|
+
if (depth === 0)
|
|
375
|
+
return i;
|
|
376
|
+
depth -= 1;
|
|
377
|
+
i += 1;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
i += 1;
|
|
381
|
+
}
|
|
382
|
+
return -1;
|
|
383
|
+
}
|
|
384
|
+
/** 解析数组正文(不含方括号)为「字符串 / 数字」项序列。 */
|
|
385
|
+
export function parseArrayItems(body) {
|
|
386
|
+
const items = [];
|
|
387
|
+
let i = 0;
|
|
388
|
+
while (i < body.length) {
|
|
389
|
+
const ch = body[i];
|
|
390
|
+
if (isWhitespace(ch)) {
|
|
391
|
+
i += 1;
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (ch === '(') {
|
|
395
|
+
const read = readPdfString(body, i);
|
|
396
|
+
// 有些生成器把字距调整量也写成字符串形式 `(-300)`:
|
|
397
|
+
// 内容是纯数字就当数字处理(否则会被当成正文显示出来)。
|
|
398
|
+
const asNumber = numericStringValue(read.bytes);
|
|
399
|
+
if (asNumber !== undefined)
|
|
400
|
+
items.push({ kind: 'number', value: asNumber });
|
|
401
|
+
else
|
|
402
|
+
items.push({ kind: 'string', bytes: read.bytes });
|
|
403
|
+
i = read.next;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (ch === '<') {
|
|
407
|
+
const end = body.indexOf('>', i);
|
|
408
|
+
if (end === -1)
|
|
409
|
+
break;
|
|
410
|
+
items.push({ kind: 'string', bytes: hexToBytes(body.slice(i + 1, end)) });
|
|
411
|
+
i = end + 1;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (ch === ']') {
|
|
415
|
+
i += 1;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
let end = i;
|
|
419
|
+
while (end < body.length && !isWhitespace(body[end]) && !isDelimiter(body[end]))
|
|
420
|
+
end += 1;
|
|
421
|
+
const text = body.slice(i, end);
|
|
422
|
+
const value = Number(text);
|
|
423
|
+
if (text !== '' && Number.isFinite(value))
|
|
424
|
+
items.push({ kind: 'number', value });
|
|
425
|
+
i = end === i ? i + 1 : end;
|
|
426
|
+
}
|
|
427
|
+
return items;
|
|
428
|
+
}
|
|
429
|
+
function isDelimiter(ch) {
|
|
430
|
+
return ch === '(' || ch === ')' || ch === '<' || ch === '>' || ch === '[' || ch === ']'
|
|
431
|
+
|| ch === '{' || ch === '}' || ch === '/' || ch === '%';
|
|
432
|
+
}
|
|
433
|
+
/** 读一个 PDF 字符串字面量(调用时 `text[i]` 是 `(`)。 */
|
|
434
|
+
export function readPdfString(text, start) {
|
|
435
|
+
const bytes = [];
|
|
436
|
+
let depth = 1;
|
|
437
|
+
let i = start + 1;
|
|
438
|
+
while (i < text.length) {
|
|
439
|
+
const ch = text[i];
|
|
440
|
+
if (ch === '\\') {
|
|
441
|
+
const next = text[i + 1];
|
|
442
|
+
i += 2;
|
|
443
|
+
switch (next) {
|
|
444
|
+
case 'n':
|
|
445
|
+
bytes.push(0x0a);
|
|
446
|
+
break;
|
|
447
|
+
case 'r':
|
|
448
|
+
bytes.push(0x0d);
|
|
449
|
+
break;
|
|
450
|
+
case 't':
|
|
451
|
+
bytes.push(0x09);
|
|
452
|
+
break;
|
|
453
|
+
case 'b':
|
|
454
|
+
bytes.push(0x08);
|
|
455
|
+
break;
|
|
456
|
+
case 'f':
|
|
457
|
+
bytes.push(0x0c);
|
|
458
|
+
break;
|
|
459
|
+
case '(':
|
|
460
|
+
bytes.push(0x28);
|
|
461
|
+
break;
|
|
462
|
+
case ')':
|
|
463
|
+
bytes.push(0x29);
|
|
464
|
+
break;
|
|
465
|
+
case '\\':
|
|
466
|
+
bytes.push(0x5c);
|
|
467
|
+
break;
|
|
468
|
+
case '\r':
|
|
469
|
+
if (text[i] === '\n')
|
|
470
|
+
i += 1;
|
|
471
|
+
break;
|
|
472
|
+
case '\n': break;
|
|
473
|
+
default:
|
|
474
|
+
if (next !== undefined && next >= '0' && next <= '7') {
|
|
475
|
+
let octal = next;
|
|
476
|
+
let consumed = 1;
|
|
477
|
+
while (consumed < 3) {
|
|
478
|
+
const digit = text[i];
|
|
479
|
+
if (digit === undefined || digit < '0' || digit > '7')
|
|
480
|
+
break;
|
|
481
|
+
octal += digit;
|
|
482
|
+
i += 1;
|
|
483
|
+
consumed += 1;
|
|
484
|
+
}
|
|
485
|
+
bytes.push(Number.parseInt(octal, 8) & 0xff);
|
|
486
|
+
}
|
|
487
|
+
else if (next !== undefined) {
|
|
488
|
+
bytes.push(next.charCodeAt(0) & 0xff);
|
|
489
|
+
}
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (ch === '(') {
|
|
495
|
+
depth += 1;
|
|
496
|
+
bytes.push(0x28);
|
|
497
|
+
i += 1;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (ch === ')') {
|
|
501
|
+
depth -= 1;
|
|
502
|
+
if (depth === 0) {
|
|
503
|
+
// 配对结束:next 指向 `)` 之后。
|
|
504
|
+
return { bytes, next: i + 1 };
|
|
505
|
+
}
|
|
506
|
+
bytes.push(0x29);
|
|
507
|
+
i += 1;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
bytes.push(ch.charCodeAt(0) & 0xff);
|
|
511
|
+
i += 1;
|
|
512
|
+
}
|
|
513
|
+
// 未闭合:返回已读内容与串尾位置(不抛错,调用方按 best-effort 处理)。
|
|
514
|
+
return { bytes, next: i };
|
|
515
|
+
}
|
|
516
|
+
/** 用字体表解码一串字节;返回是否发生了「无法映射」的字节。 */
|
|
517
|
+
function decodeWithFont(bytes, font) {
|
|
518
|
+
let out = '';
|
|
519
|
+
let unmapped = false;
|
|
520
|
+
if (font?.map !== undefined && font.map.size > 0) {
|
|
521
|
+
const step = font.codeBytes;
|
|
522
|
+
for (let i = 0; i < bytes.length; i += step) {
|
|
523
|
+
let code = bytes[i];
|
|
524
|
+
if (step === 2)
|
|
525
|
+
code = (code << 8) | (bytes[i + 1] ?? 0);
|
|
526
|
+
const mapped = font.map.get(code);
|
|
527
|
+
if (mapped !== undefined)
|
|
528
|
+
out += mapped;
|
|
529
|
+
else {
|
|
530
|
+
unmapped = true;
|
|
531
|
+
// 未映射的码位按单字节 latin1 兜底,至少保留可读的 ASCII。
|
|
532
|
+
if (step === 1 && code >= 32 && code !== 127)
|
|
533
|
+
out += String.fromCharCode(code);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return { text: out, unmapped };
|
|
537
|
+
}
|
|
538
|
+
for (const byte of bytes) {
|
|
539
|
+
// 无 Unicode 映射:按 latin1 直读(标准 WinAnsi 文档这一段是对的)。
|
|
540
|
+
if (byte >= 32 || byte === 9 || byte === 10 || byte === 13)
|
|
541
|
+
out += String.fromCharCode(byte);
|
|
542
|
+
}
|
|
543
|
+
return { text: out, unmapped: true };
|
|
544
|
+
}
|
|
545
|
+
/** 扫描一段内容流,抽取文本操作数。 */
|
|
546
|
+
export function extractContentText(content, fonts) {
|
|
547
|
+
let out = '';
|
|
548
|
+
let strings = 0;
|
|
549
|
+
let unmappedSources = 0;
|
|
550
|
+
let currentFont = fonts.get('*');
|
|
551
|
+
let inText = false;
|
|
552
|
+
const operands = [];
|
|
553
|
+
let i = 0;
|
|
554
|
+
// 词间空格的状态要**跨 TJ 操作**保留:PDF 里行内空格常写成
|
|
555
|
+
// 「上一段结尾 + 负字距调整」,只在单个数组内部判断会漏掉。
|
|
556
|
+
let lastCharIsSpace = true;
|
|
557
|
+
let pendingWordSpace = false;
|
|
558
|
+
/** 换行:重置"行首"状态。 */
|
|
559
|
+
const newline = () => {
|
|
560
|
+
out += '\n';
|
|
561
|
+
lastCharIsSpace = true;
|
|
562
|
+
pendingWordSpace = false;
|
|
563
|
+
};
|
|
564
|
+
const emit = (bytes) => {
|
|
565
|
+
strings += 1;
|
|
566
|
+
const decoded = decodeWithFont(bytes, currentFont);
|
|
567
|
+
if (decoded.unmapped)
|
|
568
|
+
unmappedSources += 1;
|
|
569
|
+
out += decoded.text;
|
|
570
|
+
if (decoded.text !== '')
|
|
571
|
+
lastCharIsSpace = false;
|
|
572
|
+
};
|
|
573
|
+
/** 显示一个操作数:字符串直接显示;TJ 数组按项显示并在大间距处补空格。 */
|
|
574
|
+
const showOperand = (operand) => {
|
|
575
|
+
if (operand === undefined)
|
|
576
|
+
return;
|
|
577
|
+
if (Array.isArray(operand)) {
|
|
578
|
+
if (operand.length === 0)
|
|
579
|
+
return;
|
|
580
|
+
if (isNumberArray(operand)) {
|
|
581
|
+
emit(operand);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
for (const item of operand) {
|
|
585
|
+
if (item.kind === 'number') {
|
|
586
|
+
// 负调整量够大 = 这里很可能是词间空格(PDF 没有空格字符,全靠字距)。
|
|
587
|
+
if (Math.abs(item.value) > TJ_SPACE_THRESHOLD)
|
|
588
|
+
pendingWordSpace = true;
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
if (pendingWordSpace && !lastCharIsSpace)
|
|
592
|
+
out += ' ';
|
|
593
|
+
pendingWordSpace = false;
|
|
594
|
+
emit(item.bytes);
|
|
595
|
+
}
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (typeof operand === 'string' || typeof operand === 'number')
|
|
599
|
+
return;
|
|
600
|
+
emit(operand);
|
|
601
|
+
};
|
|
602
|
+
while (i < content.length) {
|
|
603
|
+
const ch = content[i];
|
|
604
|
+
if (WHITESPACE.has(ch)) {
|
|
605
|
+
i += 1;
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (ch === '%') {
|
|
609
|
+
const end = content.indexOf('\n', i);
|
|
610
|
+
i = end === -1 ? content.length : end + 1;
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (ch === '(') {
|
|
614
|
+
const read = readPdfString(content, i);
|
|
615
|
+
operands.push(read.bytes);
|
|
616
|
+
i = read.next;
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
if (ch === '<') {
|
|
620
|
+
if (content[i + 1] === '<') {
|
|
621
|
+
// 内联字典(如 BDC 的属性):跳过整段
|
|
622
|
+
let depth = 1;
|
|
623
|
+
i += 2;
|
|
624
|
+
while (i < content.length && depth > 0) {
|
|
625
|
+
if (content.startsWith('<<', i)) {
|
|
626
|
+
depth += 1;
|
|
627
|
+
i += 2;
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (content.startsWith('>>', i)) {
|
|
631
|
+
depth -= 1;
|
|
632
|
+
i += 2;
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
if (content[i] === '(') {
|
|
636
|
+
i = readPdfString(content, i).next;
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
i += 1;
|
|
640
|
+
}
|
|
641
|
+
operands.length = 0;
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
const end = content.indexOf('>', i);
|
|
645
|
+
if (end === -1)
|
|
646
|
+
break;
|
|
647
|
+
const hex = content.slice(i + 1, end).replace(/[^0-9a-fA-F]/g, '');
|
|
648
|
+
const bytes = [];
|
|
649
|
+
for (let k = 0; k + 1 < hex.length; k += 2)
|
|
650
|
+
bytes.push(Number.parseInt(hex.slice(k, k + 2), 16));
|
|
651
|
+
if (hex.length % 2 === 1)
|
|
652
|
+
bytes.push(Number.parseInt(`${hex.slice(-1)}0`, 16));
|
|
653
|
+
operands.push(bytes);
|
|
654
|
+
i = end + 1;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
if (ch === '[') {
|
|
658
|
+
// 把整个数组当作**一个**操作数收集:TJ 的正文就是这个数组,
|
|
659
|
+
// 里面是「字符串 / 数字」交替的项。
|
|
660
|
+
// 用「头部匹配 + 截取数组正文」的写法,避免手写下标推进出错。
|
|
661
|
+
i += 1;
|
|
662
|
+
const closeAt = findArrayEnd(content, i);
|
|
663
|
+
const arrayBody = content.slice(i, closeAt === -1 ? content.length : closeAt);
|
|
664
|
+
operands.push(parseArrayItems(arrayBody));
|
|
665
|
+
i = closeAt === -1 ? content.length : closeAt + 1;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (ch === ']') {
|
|
669
|
+
i += 1;
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (ch === '/') {
|
|
673
|
+
let end = i + 1;
|
|
674
|
+
while (end < content.length && !WHITESPACE.has(content[end]) && !isDelimiter(content[end]))
|
|
675
|
+
end += 1;
|
|
676
|
+
operands.push(content.slice(i + 1, end));
|
|
677
|
+
i = end;
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
if (ch === "'" || ch === '"') {
|
|
681
|
+
// 单引号 = 换行 + 显示字符串;双引号 = 设置字/词间距 + 显示字符串。
|
|
682
|
+
const last = operands[operands.length - 1];
|
|
683
|
+
newline();
|
|
684
|
+
showOperand(last);
|
|
685
|
+
operands.length = 0;
|
|
686
|
+
i += 1;
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
// 名字/数字/操作符
|
|
690
|
+
let end = i;
|
|
691
|
+
while (end < content.length && !WHITESPACE.has(content[end]) && !isDelimiter(content[end]))
|
|
692
|
+
end += 1;
|
|
693
|
+
const token = content.slice(i, end);
|
|
694
|
+
i = end === i ? i + 1 : end;
|
|
695
|
+
if (token === '')
|
|
696
|
+
continue;
|
|
697
|
+
switch (token) {
|
|
698
|
+
case 'BT':
|
|
699
|
+
inText = true;
|
|
700
|
+
operands.length = 0;
|
|
701
|
+
newline();
|
|
702
|
+
break;
|
|
703
|
+
case 'ET':
|
|
704
|
+
inText = false;
|
|
705
|
+
operands.length = 0;
|
|
706
|
+
newline();
|
|
707
|
+
break;
|
|
708
|
+
case 'Tf': {
|
|
709
|
+
const name = operands.length >= 2 ? operands[operands.length - 2] : undefined;
|
|
710
|
+
if (typeof name === 'string') {
|
|
711
|
+
currentFont = fonts.get(name) ?? fonts.get('*');
|
|
712
|
+
}
|
|
713
|
+
operands.length = 0;
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
case 'Tj':
|
|
717
|
+
case "'":
|
|
718
|
+
case '"': {
|
|
719
|
+
const last = operands[operands.length - 1];
|
|
720
|
+
if (token !== 'Tj')
|
|
721
|
+
newline();
|
|
722
|
+
showOperand(last);
|
|
723
|
+
operands.length = 0;
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
case 'TJ': {
|
|
727
|
+
showOperand(operands[operands.length - 1]);
|
|
728
|
+
operands.length = 0;
|
|
729
|
+
break;
|
|
730
|
+
}
|
|
731
|
+
case 'Td':
|
|
732
|
+
case 'TD':
|
|
733
|
+
case 'T*':
|
|
734
|
+
if (inText)
|
|
735
|
+
newline();
|
|
736
|
+
operands.length = 0;
|
|
737
|
+
break;
|
|
738
|
+
default:
|
|
739
|
+
// 数字是操作数(字号/字距等),要留在栈上给后续操作符用;
|
|
740
|
+
// 其他不认识的**操作符**才清栈,避免误配到后面的操作符。
|
|
741
|
+
if (!/^[-+]?(?:\d+\.?\d*|\.\d+)$/.test(token))
|
|
742
|
+
operands.length = 0;
|
|
743
|
+
else
|
|
744
|
+
operands.push(Number(token));
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
if (operands.length > 16)
|
|
748
|
+
operands.length = 0;
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
text: out.replace(/\n{3,}/g, '\n\n').replace(/[ \t]{2,}/g, ' ').trim(),
|
|
752
|
+
strings,
|
|
753
|
+
unmappedSources,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
function parsePdf(objects) {
|
|
757
|
+
const objectStreams = objects.filter(object => isObjectStream(object.dict)).length;
|
|
758
|
+
const pageCount = objects.filter(object => object.kind === 'page').length;
|
|
759
|
+
const imageCount = objects.filter(object => /\/Subtype\s*\/Image\b/.test(object.dict)).length;
|
|
760
|
+
const hasFont = objects.some(object => /\/Type\s*\/Font\b/.test(object.dict));
|
|
761
|
+
const hasToUnicode = objects.some(object => /\/ToUnicode\b/.test(object.dict));
|
|
762
|
+
// 内容流:优先 /Type /Page 的 /Contents(含间接数组),其次"含文本操作符的流"。
|
|
763
|
+
const contentStreams = [];
|
|
764
|
+
const wanted = new Set();
|
|
765
|
+
for (const object of objects) {
|
|
766
|
+
if (object.kind !== 'page')
|
|
767
|
+
continue;
|
|
768
|
+
for (const item of object.dict.matchAll(/\/Contents\s+(\d+)\s+\d+\s+R/g)) {
|
|
769
|
+
wanted.add(Number.parseInt(item[1], 10));
|
|
770
|
+
}
|
|
771
|
+
const arrayText = /\/Contents\s*\[([^\]]*)\]/.exec(object.dict)?.[1];
|
|
772
|
+
if (arrayText !== undefined) {
|
|
773
|
+
for (const item of arrayText.matchAll(/(\d+)\s+\d+\s+R/g))
|
|
774
|
+
wanted.add(Number.parseInt(item[1], 10));
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (wanted.size > 0) {
|
|
778
|
+
for (const object of objects) {
|
|
779
|
+
if (wanted.has(object.num))
|
|
780
|
+
contentStreams.push(object);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
const decodedStreams = new Map();
|
|
784
|
+
for (const object of objects) {
|
|
785
|
+
if (object.stream === undefined)
|
|
786
|
+
continue;
|
|
787
|
+
if (object.kind === 'page')
|
|
788
|
+
continue;
|
|
789
|
+
if (/\/Subtype\s*\/Image\b/.test(object.dict) || object.dict.includes('/FontFile'))
|
|
790
|
+
continue;
|
|
791
|
+
const decoded = decodeStream(object.stream, object.dict);
|
|
792
|
+
if (decoded !== undefined)
|
|
793
|
+
decodedStreams.set(object.num, decoded);
|
|
794
|
+
}
|
|
795
|
+
if (contentStreams.length === 0) {
|
|
796
|
+
for (const [num, data] of decodedStreams) {
|
|
797
|
+
if (!looksLikeContentStream(bytesToLatin1(data)))
|
|
798
|
+
continue;
|
|
799
|
+
const object = objects.find(candidate => candidate.num === num);
|
|
800
|
+
if (object !== undefined)
|
|
801
|
+
contentStreams.push(object);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return {
|
|
805
|
+
objects,
|
|
806
|
+
pageCount: pageCount > 0 ? pageCount : Math.max(1, contentStreams.length),
|
|
807
|
+
contentStreams,
|
|
808
|
+
imageCount,
|
|
809
|
+
hasFont,
|
|
810
|
+
hasToUnicode,
|
|
811
|
+
objectStreams,
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
/** 内容流里出现文本操作符即认为是内容流(字体/图片程序几乎不会包含 `BT`+`Tj`)。 */
|
|
815
|
+
function looksLikeContentStream(text) {
|
|
816
|
+
return /\bBT\b/.test(text) || /\bTj\b/.test(text) || /\bTJ\b/.test(text);
|
|
817
|
+
}
|
|
818
|
+
/** 合法 Unicode 码位(排除代理区与超范围值)。 */
|
|
819
|
+
function validCodePoint(code) {
|
|
820
|
+
if (!Number.isFinite(code) || code < 0 || code > 0x10ffff)
|
|
821
|
+
return false;
|
|
822
|
+
return !(code >= 0xd800 && code <= 0xdfff);
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* 把「被当作 latin1 读的字符串」按原字节尝试 UTF-8 还原。
|
|
826
|
+
* 全部字节都能组成合法 UTF-8(且至少一个多字节序列)时返回还原结果,否则 undefined。
|
|
827
|
+
* 用途:不少工具的 PDF 内容流写的是 UTF-8 字节,但字体没有 ToUnicode 表。
|
|
828
|
+
*/
|
|
829
|
+
function utf8FromLatin1(text) {
|
|
830
|
+
const trimmed = text.trim();
|
|
831
|
+
if (trimmed === '')
|
|
832
|
+
return undefined;
|
|
833
|
+
const bytes = new Uint8Array(trimmed.length);
|
|
834
|
+
for (let i = 0; i < trimmed.length; i += 1) {
|
|
835
|
+
const code = trimmed.charCodeAt(i);
|
|
836
|
+
if (code > 0xff)
|
|
837
|
+
return undefined;
|
|
838
|
+
bytes[i] = code;
|
|
839
|
+
}
|
|
840
|
+
let out = '';
|
|
841
|
+
let index = 0;
|
|
842
|
+
while (index < bytes.length) {
|
|
843
|
+
const byte = bytes[index];
|
|
844
|
+
if (byte < 0x80) {
|
|
845
|
+
out += String.fromCharCode(byte);
|
|
846
|
+
index += 1;
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
let needed = 0;
|
|
850
|
+
let code = 0;
|
|
851
|
+
if (byte >= 0xc2 && byte <= 0xdf) {
|
|
852
|
+
needed = 1;
|
|
853
|
+
code = byte & 0x1f;
|
|
854
|
+
}
|
|
855
|
+
else if (byte >= 0xe0 && byte <= 0xef) {
|
|
856
|
+
needed = 2;
|
|
857
|
+
code = byte & 0x0f;
|
|
858
|
+
}
|
|
859
|
+
else if (byte >= 0xf0 && byte <= 0xf4) {
|
|
860
|
+
needed = 3;
|
|
861
|
+
code = byte & 0x07;
|
|
862
|
+
}
|
|
863
|
+
else
|
|
864
|
+
return undefined;
|
|
865
|
+
if (index + needed >= bytes.length)
|
|
866
|
+
return undefined;
|
|
867
|
+
for (let k = 1; k <= needed; k += 1) {
|
|
868
|
+
const next = bytes[index + k];
|
|
869
|
+
if (next < 0x80 || next > 0xbf)
|
|
870
|
+
return undefined;
|
|
871
|
+
code = (code << 6) | (next & 0x3f);
|
|
872
|
+
}
|
|
873
|
+
index += needed + 1;
|
|
874
|
+
if (!validCodePoint(code))
|
|
875
|
+
return undefined;
|
|
876
|
+
if (code < 0x80 || (code >= 0xd800 && code <= 0xdfff))
|
|
877
|
+
return undefined;
|
|
878
|
+
out += String.fromCodePoint(code);
|
|
879
|
+
}
|
|
880
|
+
return out;
|
|
881
|
+
}
|
|
882
|
+
/** 可打印字符占比(含中日韩文字、常见标点与空白)。 */
|
|
883
|
+
function printableRatio(text) {
|
|
884
|
+
const sample = text.length > 20000 ? text.slice(0, 20000) : text;
|
|
885
|
+
if (sample === '')
|
|
886
|
+
return 0;
|
|
887
|
+
let good = 0;
|
|
888
|
+
for (const ch of sample) {
|
|
889
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
890
|
+
if (code === 9 || code === 10 || code === 13) {
|
|
891
|
+
good += 1;
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
if (code >= 0x20 && code !== 0x7f) {
|
|
895
|
+
good += 1;
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return good / sample.length;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* 读取 PDF 文本(内部函数,返回空串表示抽不出来)。
|
|
903
|
+
* 第二个返回值是提示信息。
|
|
904
|
+
*/
|
|
905
|
+
export function readPdfText(bytes) {
|
|
906
|
+
if (bytes.length === 0)
|
|
907
|
+
return { text: '', lowConfidence: true, reason: '文件是空的。', pages: 0 };
|
|
908
|
+
if (bytes.length > PDF_MAX_BYTES) {
|
|
909
|
+
return { text: '', lowConfidence: true, reason: `PDF 有 ${bytes.length} 字节,超过 ${PDF_MAX_BYTES} 字节上限:请拆分或改用 .docx。`, pages: 0 };
|
|
910
|
+
}
|
|
911
|
+
const raw = bytesToLatin1(bytes);
|
|
912
|
+
if (!raw.startsWith('%PDF-') && !raw.includes('%PDF-')) {
|
|
913
|
+
return { text: '', lowConfidence: true, reason: '文件头不是 %PDF-:这不是 PDF 文件(可能扩展名被改过)。', pages: 0 };
|
|
914
|
+
}
|
|
915
|
+
if (/\/Encrypt\s+\d+\s+\d+\s+R|\/Encrypt\s*<</.test(raw)) {
|
|
916
|
+
return { text: '', lowConfidence: true, reason: '这是加密(受密码保护)的 PDF:无法解析内容。请用阅读器另存为不含密码的 PDF,或直接提供 .docx/正文文本。', pages: 0 };
|
|
917
|
+
}
|
|
918
|
+
const objects = parseObjects(raw);
|
|
919
|
+
if (objects.length === 0) {
|
|
920
|
+
return { text: '', lowConfidence: true, reason: '这个 PDF 里找不到任何间接对象:文件已损坏,或使用了本模块不支持的交叉引用流结构。请用阅读器另存为 .docx 后重试。', pages: 0 };
|
|
921
|
+
}
|
|
922
|
+
const parsed = parsePdf(objects);
|
|
923
|
+
const fonts = buildFontTables(raw, objects);
|
|
924
|
+
const parts = [];
|
|
925
|
+
let strings = 0;
|
|
926
|
+
let unmapped = 0;
|
|
927
|
+
for (const object of parsed.contentStreams) {
|
|
928
|
+
const decoded = object.stream !== undefined ? decodeStream(object.stream, object.dict) : undefined;
|
|
929
|
+
if (decoded === undefined)
|
|
930
|
+
continue;
|
|
931
|
+
const content = bytesToLatin1(decoded);
|
|
932
|
+
if (!/\bTj\b|\bTJ\b|\bBT\b/.test(content))
|
|
933
|
+
continue;
|
|
934
|
+
const extracted = extractContentText(content, fonts);
|
|
935
|
+
strings += extracted.strings;
|
|
936
|
+
unmapped += extracted.unmappedSources;
|
|
937
|
+
if (extracted.text !== '')
|
|
938
|
+
parts.push(extracted.text);
|
|
939
|
+
}
|
|
940
|
+
let text = parts.join('\n\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
941
|
+
const notes = [];
|
|
942
|
+
if (parsed.objectStreams > 0) {
|
|
943
|
+
notes.push(`该 PDF 含 ${parsed.objectStreams} 个对象流(Object Stream):本模块不解析对象流,页面/字体信息可能不完整。`);
|
|
944
|
+
}
|
|
945
|
+
// CID 乱码清理:没有 CMap 的字体解出来的字节不可信,分三步判断
|
|
946
|
+
// (结论都写进 note,不假装一定准确):
|
|
947
|
+
// ① 若字节序列本身就是合法 UTF-8(很多国产工具直接往内容流塞 UTF-8),忠实还原;
|
|
948
|
+
// ② 否则看"可打印字符占比",太低说明是 CID 码位 → 不返回乱码;
|
|
949
|
+
// ③ 占比尚可 → 按 latin1 原样返回但标低置信。
|
|
950
|
+
if (strings > 0 && unmapped / strings > 0.5) {
|
|
951
|
+
const utf8Recovered = utf8FromLatin1(text);
|
|
952
|
+
const printable = printableRatio(text);
|
|
953
|
+
if (utf8Recovered !== undefined) {
|
|
954
|
+
text = utf8Recovered;
|
|
955
|
+
notes.push('该 PDF 的字体缺 ToUnicode 映射,内容流实为 UTF-8 字节:已按 UTF-8 还原,个别字符仍可能有误,关键数据请核对原文。');
|
|
956
|
+
}
|
|
957
|
+
else if (printable < 0.5) {
|
|
958
|
+
text = '';
|
|
959
|
+
notes.push('该 PDF 使用嵌入子集字体(CID)且没有 ToUnicode 映射表,字节无法还原成字符——抽出来会是乱码,因此不返回内容。请提供 .docx 版本,或在阅读器里"另存为文本/复制正文"。');
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
notes.push('该 PDF 的字体缺少 ToUnicode 映射表,抽取结果可能有个别字符不准(已标低置信),关键内容请核对原文。');
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
if (text === '') {
|
|
966
|
+
const scanned = parsed.imageCount > 0 && !parsed.hasFont;
|
|
967
|
+
const reason = scanned
|
|
968
|
+
? '这看起来是扫描版/图片版 PDF(页面只有图片、没有文字层):无法抽取文本。请提供 .docx,或用 OCR 工具转成文字后提供,也可以直接把正文复制成 .txt。'
|
|
969
|
+
: parsed.imageCount > 0
|
|
970
|
+
? '这个 PDF 没有可抽取的文字内容(只有图片):请在阅读器里复制正文,或提供 .docx 版本。'
|
|
971
|
+
: '这个 PDF 里没有文字对象(可能是空文档,或结构不受支持):请提供 .docx 版本。';
|
|
972
|
+
return { text: '', lowConfidence: true, reason, pages: parsed.pageCount };
|
|
973
|
+
}
|
|
974
|
+
if (!parsed.hasToUnicode && parsed.hasFont) {
|
|
975
|
+
notes.push('未发现 ToUnicode 映射表:中文/符号字体的字符可能有误,建议核对关键数据。');
|
|
976
|
+
}
|
|
977
|
+
const lowConfidence = text.length < LOW_CHARS && parsed.pageCount > LOW_PAGES;
|
|
978
|
+
if (lowConfidence)
|
|
979
|
+
notes.push(`全文只有 ${text.length} 字符但页数 ${parsed.pageCount}:抽取率异常低,很可能大部分内容无法抽取,请自行核对。`);
|
|
980
|
+
return {
|
|
981
|
+
text,
|
|
982
|
+
...(notes.length > 0 ? { note: notes.join(' ') } : {}),
|
|
983
|
+
lowConfidence: lowConfidence || unmapped > 0,
|
|
984
|
+
pages: parsed.pageCount,
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
/** 门面用入口:pdf 字节 → 抽取结果。 */
|
|
988
|
+
export function extractPdf(bytes, options) {
|
|
989
|
+
let read;
|
|
990
|
+
try {
|
|
991
|
+
read = readPdfText(bytes);
|
|
992
|
+
}
|
|
993
|
+
catch (error) {
|
|
994
|
+
return failureResult(`解析 PDF 失败:${error instanceof Error ? error.message : String(error)}`);
|
|
995
|
+
}
|
|
996
|
+
if (read.reason !== undefined) {
|
|
997
|
+
const detail = read.note !== undefined ? ` ${read.note}` : '';
|
|
998
|
+
return failureResult(`${read.reason}${detail}`);
|
|
999
|
+
}
|
|
1000
|
+
if (read.text.trim() === '') {
|
|
1001
|
+
return failureResult('未能从这个 PDF 中抽出任何文本:请提供 .docx 版本或正文文本。');
|
|
1002
|
+
}
|
|
1003
|
+
const { text, truncated } = truncateText(read.text, options.maxChars);
|
|
1004
|
+
const notes = [`共 ${read.pages} 页;PDF 文本按内容流顺序提取,表格/分栏顺序可能与肉眼不一致。`];
|
|
1005
|
+
if (read.note !== undefined)
|
|
1006
|
+
notes.push(read.note);
|
|
1007
|
+
if (truncated)
|
|
1008
|
+
notes.push(`内容超过 ${options.maxChars} 字符上限,已截断。`);
|
|
1009
|
+
return {
|
|
1010
|
+
ok: true,
|
|
1011
|
+
text,
|
|
1012
|
+
truncated,
|
|
1013
|
+
note: notes.join(' '),
|
|
1014
|
+
...(read.lowConfidence ? { lowConfidence: true } : {}),
|
|
1015
|
+
};
|
|
1016
|
+
}
|