deel-local-cli 1.5.8 → 1.6.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.
Files changed (57) hide show
  1. package/README.en.md +23 -13
  2. package/README.md +22 -12
  3. package/bin/deel.js +10 -0
  4. package/package.json +2 -2
  5. package/src/acp/map.js +143 -0
  6. package/src/acp/serve.js +160 -13
  7. package/src/agent/commit.js +511 -0
  8. package/src/agent/compact.js +269 -226
  9. package/src/agent/loop.js +154 -12
  10. package/src/agent/mention.js +56 -10
  11. package/src/agent/review.js +192 -0
  12. package/src/agent/session.js +20 -2
  13. package/src/agent/threads.js +1 -1
  14. package/src/backend/adapter.js +342 -280
  15. package/src/backend/azure.js +151 -0
  16. package/src/backend/ctxsize.js +19 -0
  17. package/src/backend/detect.js +106 -0
  18. package/src/backend/http.js +350 -30
  19. package/src/backend/probe.js +48 -1
  20. package/src/backend/proxy.js +151 -0
  21. package/src/backend/quota.js +133 -0
  22. package/src/backend/retry.js +132 -0
  23. package/src/backend/vision.js +185 -0
  24. package/src/commands.js +341 -8
  25. package/src/completion.js +264 -0
  26. package/src/config.js +117 -1
  27. package/src/i18n/en.js +13 -1
  28. package/src/i18n/index.js +22 -3
  29. package/src/i18n/ja.js +266 -0
  30. package/src/i18n/ko.js +12 -0
  31. package/src/i18n/zh.js +266 -0
  32. package/src/oneshot.js +28 -3
  33. package/src/pack/sbom.js +25 -0
  34. package/src/pack/selfpack.js +9 -3
  35. package/src/plugins/manage.js +34 -7
  36. package/src/repl.js +78 -6
  37. package/src/report.js +26 -2
  38. package/src/safety/guard.js +13 -2
  39. package/src/safety/keystore.js +237 -0
  40. package/src/safety/network.js +76 -14
  41. package/src/safety/policy.js +209 -0
  42. package/src/safety/secrets.js +32 -0
  43. package/src/setup.js +40 -7
  44. package/src/tools/clipboard.js +178 -0
  45. package/src/tools/fastgrep.js +217 -0
  46. package/src/tools/fsutil.js +55 -12
  47. package/src/tools/ignore.js +192 -0
  48. package/src/tools/index.js +226 -36
  49. package/src/tools/jobs.js +4 -12
  50. package/src/tools/outline.js +3 -1
  51. package/src/tools/pdf.js +1326 -0
  52. package/src/tools/shell.js +123 -0
  53. package/src/tools/verify.js +20 -4
  54. package/src/tools/webfetch.js +18 -5
  55. package/src/ui/inputbox.js +2 -2
  56. package/src/ui/screen.js +4 -1
  57. package/src/ui/status.js +22 -0
@@ -0,0 +1,1326 @@
1
+ /**
2
+ * PDF 읽기 — 쪽마다 글로.
3
+ *
4
+ * ── 왜 필요한가 ─────────────────────────────────────────────────────────
5
+ *
6
+ * hwpx·docx·pptx 는 읽는데 PDF 를 못 읽었다. 그런데 사내에서 「이대로 만들어」
7
+ * 하고 건네지는 스펙·공문·표준서는 대개 PDF 다. 못 읽으면 사람이 손으로
8
+ * 복사해 붙여야 하고, 그 과정에서 표가 깨지고 쪽 순서가 섞인다.
9
+ *
10
+ * ── 왜 의존성 없이 되나 ─────────────────────────────────────────────────
11
+ *
12
+ * PDF 의 속은 「사전(<< >>)과 흐름(stream)」이고, 흐름은 거의 언제나
13
+ * FlateDecode — 곧 zlib 이다. zlib 은 node 에 들어 있다. 그래서 새로 들이는
14
+ * 것 없이 된다. 어려운 것은 압축이 아니라 **글자를 되찾는 일**이다.
15
+ *
16
+ * PDF 안에 든 것은 글자가 아니라 **글리프 번호**다. 「가」가 아니라
17
+ * 「이 글꼴의 1,283번째 그림」이 적혀 있다. 그래서 글꼴마다 딸린
18
+ * /ToUnicode 표를 읽어 번호를 글자로 되돌려야 한다. 한글 PDF 는 거의
19
+ * 전부 Identity-H(두 바이트 글리프 번호)라 이 표가 없으면 한 글자도
20
+ * 못 읽는다.
21
+ *
22
+ * ── 무엇을 못 하나 (그리고 그걸 말한다) ─────────────────────────────────
23
+ *
24
+ * PDF 는 「보이는 대로 인쇄하기」 위한 형식이라, 애초에 글이 안 들어 있는
25
+ * 것이 흔하다.
26
+ *
27
+ * · 스캔한 문서 — 쪽 전체가 사진 한 장이다. 글이 없다.
28
+ * · /ToUnicode 없는 Identity-H — 글리프 번호만 있고 되돌릴 표가 없다.
29
+ * · 암호가 걸린 문서 — 흐름이 통째로 암호화돼 있다.
30
+ *
31
+ * 이럴 때 **빈 글을 돌려주면 안 된다.** 빈 글은 「이 문서에는 그런 내용이
32
+ * 없다」로 읽히고, 모델은 그걸 근거로 답한다. 그래서 못 읽은 쪽은 몇 쪽이
33
+ * 왜 안 읽혔는지 이름을 붙여 말한다. 세 쪽짜리에서 두 쪽을 못 읽었으면
34
+ * 그 문서로 무엇을 판단하면 안 되는지가 사람에게 보여야 한다.
35
+ *
36
+ * ── 읽기만 한다 ─────────────────────────────────────────────────────────
37
+ *
38
+ * 엑셀·문서와 같다. 고치기는 없다.
39
+ */
40
+ import { readFileSync } from 'node:fs';
41
+ import { inflateSync, inflateRawSync, constants as zlib상수 } from 'node:zlib';
42
+
43
+ // 끝이 잘린 흐름을 부풀릴 때 쓴다 — 「여기까지」로 끝내 달라는 뜻.
44
+ const { Z_SYNC_FLUSH } = zlib상수;
45
+ import { extname, basename } from 'node:path';
46
+
47
+ /** 한 번에 글로 바꿔 줄 최대 글자. 넘으면 자르고 잘랐다고 말한다. */
48
+ const 최대글자 = 120000;
49
+
50
+ /* ────────────────────────────────────────────────────────────────────────
51
+ * 1. 낱말 쪼개기
52
+ * ──────────────────────────────────────────────────────────────────────── */
53
+
54
+ const 공백바이트 = new Set([0x00, 0x09, 0x0a, 0x0c, 0x0d, 0x20]);
55
+ const 구분바이트 = new Set([0x28, 0x29, 0x3c, 0x3e, 0x5b, 0x5d, 0x7b, 0x7d, 0x2f, 0x25]);
56
+
57
+ const 공백인가 = (c) => 공백바이트.has(c);
58
+ const 끝인가 = (c) => c === undefined || 공백바이트.has(c) || 구분바이트.has(c);
59
+
60
+ /** 공백과 주석(%…)을 건너뛴다. */
61
+ function 건너뛰기(b, i) {
62
+ for (;;) {
63
+ while (i < b.length && 공백인가(b[i])) i += 1;
64
+ if (b[i] === 0x25) { // '%' 주석은 줄 끝까지
65
+ while (i < b.length && b[i] !== 0x0a && b[i] !== 0x0d) i += 1;
66
+ continue;
67
+ }
68
+ return i;
69
+ }
70
+ }
71
+
72
+ /* ────────────────────────────────────────────────────────────────────────
73
+ * 2. 값 읽기
74
+ *
75
+ * 돌려주는 꼴:
76
+ * 이름 { 이름: 'Page' }
77
+ * 참조 { 참조: 12, 세대: 0 }
78
+ * 글자 { 바이트: Buffer } ← PDF 의 문자열은 바이트 뭉치다.
79
+ * 사전 보통 객체 { Type: {이름:'Page'}, … }
80
+ * 흐름 { 사전: {...}, 날것: Buffer }
81
+ * 그 밖 숫자 · true/false · null · 배열
82
+ * ──────────────────────────────────────────────────────────────────────── */
83
+
84
+ /** `/Name` — `#XX` 는 16진수 한 글자다. */
85
+ function 이름읽기(b, i) {
86
+ i += 1;
87
+ const 조각 = [];
88
+ while (i < b.length && !끝인가(b[i])) {
89
+ if (b[i] === 0x23 && i + 2 < b.length) {
90
+ const h = parseInt(String.fromCharCode(b[i + 1], b[i + 2]), 16);
91
+ if (Number.isFinite(h)) { 조각.push(h); i += 3; continue; }
92
+ }
93
+ 조각.push(b[i]); i += 1;
94
+ }
95
+ return [{ 이름: Buffer.from(조각).toString('latin1') }, i];
96
+ }
97
+
98
+ const 되돌린것 = { 0x6e: 0x0a, 0x72: 0x0d, 0x74: 0x09, 0x62: 0x08, 0x66: 0x0c };
99
+
100
+ /** `(글자)` — 괄호가 짝을 이루면 안쪽 괄호도 글자다. */
101
+ function 괄호글자읽기(b, i) {
102
+ i += 1;
103
+ const 조각 = [];
104
+ let 깊이 = 1;
105
+ while (i < b.length) {
106
+ const c = b[i];
107
+ if (c === 0x5c) { // 역슬래시
108
+ const n = b[i + 1];
109
+ if (n === undefined) break;
110
+ if (n >= 0x30 && n <= 0x37) { // 8진수 최대 세 자리
111
+ let 여덟 = 0; let k = 0;
112
+ while (k < 3 && b[i + 1 + k] >= 0x30 && b[i + 1 + k] <= 0x37) {
113
+ 여덟 = 여덟 * 8 + (b[i + 1 + k] - 0x30); k += 1;
114
+ }
115
+ 조각.push(여덟 & 0xff); i += 1 + k; continue;
116
+ }
117
+ if (n === 0x0a) { i += 2; continue; } // 줄 이음
118
+ if (n === 0x0d) { i += (b[i + 2] === 0x0a ? 3 : 2); continue; }
119
+ 조각.push(되돌린것[n] ?? n); i += 2; continue;
120
+ }
121
+ if (c === 0x28) 깊이 += 1;
122
+ if (c === 0x29) { 깊이 -= 1; if (!깊이) { i += 1; break; } }
123
+ 조각.push(c); i += 1;
124
+ }
125
+ return [{ 바이트: Buffer.from(조각) }, i];
126
+ }
127
+
128
+ /** `<AB12>` — 홀수면 마지막에 0 을 채운다(규격이 그렇다). */
129
+ function 열여섯글자읽기(b, i) {
130
+ i += 1;
131
+ const 자리 = [];
132
+ while (i < b.length && b[i] !== 0x3e) {
133
+ const c = b[i];
134
+ if (!공백인가(c)) 자리.push(String.fromCharCode(c));
135
+ i += 1;
136
+ }
137
+ i += 1;
138
+ if (자리.length % 2) 자리.push('0');
139
+ const 조각 = [];
140
+ for (let k = 0; k < 자리.length; k += 2) 조각.push(parseInt(자리[k] + 자리[k + 1], 16) & 0xff);
141
+ return [{ 바이트: Buffer.from(조각) }, i];
142
+ }
143
+
144
+ /**
145
+ * 값 하나를 읽는다.
146
+ *
147
+ * @param 흐름풀기 흐름을 만났을 때 `/Length` 를 풀어 줄 함수(참조일 수 있다).
148
+ * 없으면 endstream 을 찾아서 자른다.
149
+ */
150
+ export function 값읽기(b, i, 흐름풀기 = null) {
151
+ i = 건너뛰기(b, i);
152
+ const c = b[i];
153
+ if (c === undefined) return [null, i];
154
+
155
+ if (c === 0x2f) return 이름읽기(b, i);
156
+ if (c === 0x28) return 괄호글자읽기(b, i);
157
+
158
+ if (c === 0x3c) {
159
+ if (b[i + 1] === 0x3c) return 사전읽기(b, i, 흐름풀기);
160
+ return 열여섯글자읽기(b, i);
161
+ }
162
+
163
+ if (c === 0x5b) { // 배열
164
+ i += 1;
165
+ const 것들 = [];
166
+ for (;;) {
167
+ i = 건너뛰기(b, i);
168
+ if (i >= b.length || b[i] === 0x5d) { i += 1; break; }
169
+ const [값, 다음] = 값읽기(b, i, 흐름풀기);
170
+ if (다음 === i) { i += 1; continue; } // 못 읽는 글자는 버리고 나아간다
171
+ 것들.push(값); i = 다음;
172
+ }
173
+ return [것들, i];
174
+ }
175
+
176
+ if (c === 0x5d || c === 0x3e || c === 0x29) return [null, i + 1]; // 짝 안 맞는 닫기
177
+
178
+ // 낱말 하나 (숫자 · true · false · null · 연산자)
179
+ let j = i;
180
+ while (j < b.length && !끝인가(b[j])) j += 1;
181
+ const 말 = b.toString('latin1', i, j);
182
+
183
+ if (말 === 'true') return [true, j];
184
+ if (말 === 'false') return [false, j];
185
+ if (말 === 'null') return [null, j];
186
+
187
+ if (/^[+-]?[\d.]+$/.test(말)) {
188
+ const 수 = Number(말.replace(/^([+-]?)\./, '$10.'));
189
+ // `12 0 R` 인지 넘겨다본다. 참조를 숫자로 읽으면 온 문서가 어긋난다.
190
+ if (Number.isInteger(수) && 수 >= 0) {
191
+ let k = 건너뛰기(b, j);
192
+ let k2 = k;
193
+ while (k2 < b.length && !끝인가(b[k2])) k2 += 1;
194
+ const 둘째 = b.toString('latin1', k, k2);
195
+ if (/^\d+$/.test(둘째)) {
196
+ const k3 = 건너뛰기(b, k2);
197
+ if (b[k3] === 0x52 && 끝인가(b[k3 + 1])) { // 'R'
198
+ return [{ 참조: 수, 세대: Number(둘째) }, k3 + 1];
199
+ }
200
+ }
201
+ }
202
+ return [Number.isFinite(수) ? 수 : 0, j];
203
+ }
204
+
205
+ // 알 수 없는 낱말은 연산자로 본다 (내용 흐름에서 쓴다).
206
+ return [{ 연산자: 말 }, j === i ? i + 1 : j];
207
+ }
208
+
209
+ /** `<< … >>` 그리고 바로 뒤에 stream 이 오면 흐름까지. */
210
+ function 사전읽기(b, i, 흐름풀기) {
211
+ i += 2;
212
+ const 것 = {};
213
+ for (;;) {
214
+ i = 건너뛰기(b, i);
215
+ if (i >= b.length) break;
216
+ if (b[i] === 0x3e && b[i + 1] === 0x3e) { i += 2; break; }
217
+ if (b[i] !== 0x2f) { // 열쇠 자리에 이름이 아닌 것 — 버리고 나아간다
218
+ const [, 다음] = 값읽기(b, i, 흐름풀기);
219
+ if (다음 === i) i += 1; else i = 다음;
220
+ continue;
221
+ }
222
+ const [열쇠, i2] = 이름읽기(b, i);
223
+ const [값, i3] = 값읽기(b, i2, 흐름풀기);
224
+ 것[열쇠.이름] = 값;
225
+ i = i3;
226
+ }
227
+
228
+ const j = 건너뛰기(b, i);
229
+ if (b.toString('latin1', j, j + 6) !== 'stream') return [것, i];
230
+
231
+ // 'stream' 뒤에는 CRLF 나 LF 가 온다 (CR 만은 규격 위반이지만 실제로 있다).
232
+ let s = j + 6;
233
+ if (b[s] === 0x0d) s += 1;
234
+ if (b[s] === 0x0a) s += 1;
235
+
236
+ let 길이 = 흐름풀기 ? 흐름풀기(것.Length) : (typeof 것.Length === 'number' ? 것.Length : null);
237
+ let 끝 = Number.isInteger(길이) && 길이 >= 0 ? s + 길이 : -1;
238
+
239
+ /*
240
+ * 적힌 길이를 곧이곧대로 믿지 않는다.
241
+ *
242
+ * 길이가 틀린 PDF 가 실제로 많다(만든 프로그램이 나중에 고치면서 안 맞춘다).
243
+ * 그대로 자르면 흐름이 깨져 그 쪽이 통째로 안 읽힌다. 그래서 자른 자리에
244
+ * endstream 이 있는지 보고, 아니면 찾아서 맞춘다.
245
+ */
246
+ const 맞나 = 끝 >= 0 && 끝 <= b.length
247
+ && /^\s*endstream/.test(b.toString('latin1', 끝, Math.min(끝 + 20, b.length)));
248
+ if (!맞나) {
249
+ const 찾은 = b.indexOf('endstream', s, 'latin1');
250
+ 끝 = 찾은 < 0 ? b.length : 찾은;
251
+ // endstream 바로 앞의 줄바꿈은 흐름의 일부가 아니다.
252
+ while (끝 > s && (b[끝 - 1] === 0x0a || b[끝 - 1] === 0x0d)) 끝 -= 1;
253
+ }
254
+
255
+ const 날것 = b.subarray(s, Math.max(s, Math.min(끝, b.length)));
256
+ const 뒤 = b.indexOf('endstream', Math.max(s, 끝), 'latin1');
257
+ return [{ 사전: 것, 날것 }, 뒤 < 0 ? b.length : 뒤 + 9];
258
+ }
259
+
260
+ /* ────────────────────────────────────────────────────────────────────────
261
+ * 3. 걸러내기 (filters)
262
+ * ──────────────────────────────────────────────────────────────────────── */
263
+
264
+ /** 앞자리 예측(PNG predictor). xref 흐름과 그림에 쓴다. */
265
+ function 예측되돌리기(자료, { 예측, 칸수, 색수, 비트 }) {
266
+ if (!예측 || 예측 < 2) return 자료;
267
+ if (예측 === 2) return 자료; // TIFF 예측 — 흔치 않고 xref 엔 안 쓴다
268
+ const 한칸 = Math.ceil((색수 * 비트) / 8);
269
+ const 한줄 = Math.ceil((색수 * 비트 * 칸수) / 8);
270
+ const 줄수 = Math.floor(자료.length / (한줄 + 1));
271
+ const 나온것 = Buffer.alloc(줄수 * 한줄);
272
+ let 앞줄 = Buffer.alloc(한줄);
273
+ for (let r = 0; r < 줄수; r += 1) {
274
+ const 표 = 자료[r * (한줄 + 1)];
275
+ const 줄 = Buffer.from(자료.subarray(r * (한줄 + 1) + 1, (r + 1) * (한줄 + 1)));
276
+ for (let k = 0; k < 한줄; k += 1) {
277
+ const a = k >= 한칸 ? 줄[k - 한칸] : 0;
278
+ const bb = 앞줄[k];
279
+ const cc = k >= 한칸 ? 앞줄[k - 한칸] : 0;
280
+ let v = 줄[k];
281
+ if (표 === 1) v += a;
282
+ else if (표 === 2) v += bb;
283
+ else if (표 === 3) v += Math.floor((a + bb) / 2);
284
+ else if (표 === 4) {
285
+ const p = a + bb - cc;
286
+ const pa = Math.abs(p - a); const pb = Math.abs(p - bb); const pc = Math.abs(p - cc);
287
+ v += (pa <= pb && pa <= pc) ? a : (pb <= pc ? bb : cc);
288
+ }
289
+ 줄[k] = v & 0xff;
290
+ }
291
+ 줄.copy(나온것, r * 한줄);
292
+ 앞줄 = 줄;
293
+ }
294
+ return 나온것;
295
+ }
296
+
297
+ function 아스키85풀기(buf) {
298
+ const s = buf.toString('latin1').replace(/\s/g, '').replace(/^<~/, '');
299
+ const 끝 = s.indexOf('~>');
300
+ const 몸 = 끝 < 0 ? s : s.slice(0, 끝);
301
+ const 나온것 = [];
302
+ let 묶음 = []; let i = 0;
303
+ while (i < 몸.length) {
304
+ const ch = 몸[i];
305
+ if (ch === 'z' && !묶음.length) { 나온것.push(0, 0, 0, 0); i += 1; continue; }
306
+ 묶음.push(몸.charCodeAt(i) - 33); i += 1;
307
+ if (묶음.length === 5) {
308
+ let v = 0;
309
+ for (const x of 묶음) v = v * 85 + x;
310
+ 나온것.push((v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff);
311
+ 묶음 = [];
312
+ }
313
+ }
314
+ if (묶음.length > 1) {
315
+ const n = 묶음.length;
316
+ while (묶음.length < 5) 묶음.push(84);
317
+ let v = 0;
318
+ for (const x of 묶음) v = v * 85 + x;
319
+ const 넷 = [(v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff];
320
+ 나온것.push(...넷.slice(0, n - 1));
321
+ }
322
+ return Buffer.from(나온것);
323
+ }
324
+
325
+ function 아스키16풀기(buf) {
326
+ const s = buf.toString('latin1').replace(/\s/g, '');
327
+ const 끝 = s.indexOf('>');
328
+ let 몸 = 끝 < 0 ? s : s.slice(0, 끝);
329
+ if (몸.length % 2) 몸 += '0';
330
+ const 나온것 = [];
331
+ for (let i = 0; i < 몸.length; i += 2) 나온것.push(parseInt(몸.slice(i, i + 2), 16) & 0xff);
332
+ return Buffer.from(나온것);
333
+ }
334
+
335
+ function 되풀이풀기(buf) {
336
+ const 나온것 = [];
337
+ let i = 0;
338
+ while (i < buf.length) {
339
+ const n = buf[i];
340
+ if (n === 128) break;
341
+ if (n < 128) { for (let k = 0; k <= n; k += 1) 나온것.push(buf[i + 1 + k] ?? 0); i += n + 2; }
342
+ else { const v = buf[i + 1] ?? 0; for (let k = 0; k < 257 - n; k += 1) 나온것.push(v); i += 2; }
343
+ }
344
+ return Buffer.from(나온것);
345
+ }
346
+
347
+ /** zlib 머리가 깨진 흐름이 흔해서, 날것(raw)으로도 한 번 더 해 본다. */
348
+ function 부풀리기(buf) {
349
+ try { return inflateSync(buf); } catch { /* 아래로 */ }
350
+ try { return inflateRawSync(buf); } catch { /* 아래로 */ }
351
+ // 앞머리에 쓰레기가 붙은 것들 — zlib 머리(0x78)를 찾아 다시.
352
+ for (let i = 1; i < Math.min(buf.length, 32); i += 1) {
353
+ if (buf[i] === 0x78) {
354
+ try { return inflateSync(buf.subarray(i)); } catch { /* 다음 */ }
355
+ }
356
+ }
357
+ /*
358
+ * 끝이 잘린 흐름은 통째로 버리지 않는다.
359
+ *
360
+ * 마지막 쪽이 잘린 PDF 가 실제로 있다. 여기서 빈 것을 돌려주면 그 쪽이
361
+ * 「글 없는 쪽」이 되어 스캔본과 구분이 안 된다. 부풀린 데까지는 건진다.
362
+ */
363
+ try {
364
+ return inflateSync(buf, { finishFlush: Z_SYNC_FLUSH });
365
+ } catch { /* 아래로 */ }
366
+ try {
367
+ return inflateRawSync(buf, { finishFlush: Z_SYNC_FLUSH });
368
+ } catch { return null; }
369
+ }
370
+
371
+ /** 못 푸는 걸개를 만나면 이름을 돌려준다 — 「왜 안 읽혔나」에 그대로 쓴다. */
372
+ export function 흐름풀기(흐름, 풀기) {
373
+ if (!흐름?.날것) return { ok: false, 왜: '흐름이 아닙니다' };
374
+ const 사전 = 흐름.사전 ?? {};
375
+ let 자료 = 흐름.날것;
376
+ const 걸개들 = [].concat(풀기(사전.Filter) ?? []).map((f) => 풀기(f)).filter(Boolean);
377
+ const 맞춤들 = [].concat(풀기(사전.DecodeParms) ?? 풀기(사전.DP) ?? []);
378
+
379
+ for (let i = 0; i < 걸개들.length; i += 1) {
380
+ const 이름 = 걸개들[i]?.이름;
381
+ const 맞춤 = 풀기(맞춤들[i]) ?? (걸개들.length === 1 ? 풀기(맞춤들[0]) : null) ?? {};
382
+ if (이름 === 'FlateDecode' || 이름 === 'Fl') {
383
+ const 푼것 = 부풀리기(자료);
384
+ if (!푼것) return { ok: false, 왜: '압축을 못 풀었습니다' };
385
+ 자료 = 푼것;
386
+ } else if (이름 === 'ASCII85Decode' || 이름 === 'A85') 자료 = 아스키85풀기(자료);
387
+ else if (이름 === 'ASCIIHexDecode' || 이름 === 'AHx') 자료 = 아스키16풀기(자료);
388
+ else if (이름 === 'RunLengthDecode' || 이름 === 'RL') 자료 = 되풀이풀기(자료);
389
+ else if (이름) {
390
+ // LZWDecode · DCTDecode(JPEG) · JPXDecode · CCITTFaxDecode …
391
+ // 그림 걸개이거나 아주 옛 압축이다. 지어내지 말고 이름을 그대로 돌려준다.
392
+ return { ok: false, 왜: `${이름} 은 못 푸는 압축입니다`, 걸개: 이름 };
393
+ }
394
+ const 예측 = 풀기(맞춤?.Predictor);
395
+ if (예측 && 예측 > 1) {
396
+ 자료 = 예측되돌리기(자료, {
397
+ 예측,
398
+ 칸수: 풀기(맞춤.Columns) ?? 1,
399
+ 색수: 풀기(맞춤.Colors) ?? 1,
400
+ 비트: 풀기(맞춤.BitsPerComponent) ?? 8,
401
+ });
402
+ }
403
+ }
404
+ return { ok: true, 자료 };
405
+ }
406
+
407
+ /* ────────────────────────────────────────────────────────────────────────
408
+ * 4. 문서 — 객체를 어디서 찾나
409
+ *
410
+ * 세 갈래를 다 쓴다.
411
+ * ① 파일을 통째로 훑어 `N G obj` 자리를 적어 둔다.
412
+ * ② xref(표든 흐름이든)를 읽어 덮어쓴다.
413
+ * ③ 객체 흐름(ObjStm) 안에 든 것을 펼친다.
414
+ *
415
+ * ①을 늘 해 두는 것이 요령이다. 현장의 PDF 는 xref 가 틀린 것이 아주 흔한데
416
+ * (덧붙여 저장하다 어긋난다), 그때 xref 만 믿으면 멀쩡한 문서를 「못 읽음」
417
+ * 으로 돌려주게 된다. 사람은 파일이 깨진 줄 안다.
418
+ * ──────────────────────────────────────────────────────────────────────── */
419
+
420
+ class 문서 {
421
+ constructor(buf) {
422
+ this.b = buf;
423
+ this.자리 = new Map(); // 객체번호 → 파일 위치
424
+ this.푼것 = new Map(); // 객체번호 → 읽어 둔 값
425
+ this.묶음속 = new Map(); // 객체번호 → ObjStm 안에서 읽은 값
426
+ this.trailer = {};
427
+ this.훑기();
428
+ this.xref읽기();
429
+ this.묶음펼치기();
430
+ }
431
+
432
+ /** ① 파일 전체에서 `N G obj` 를 찾는다. 뒤에 나온 것이 이긴다(덧붙여 저장). */
433
+ 훑기() {
434
+ const s = this.b.toString('latin1');
435
+ const 무늬 = /(?:^|[\s>\]])(\d+)\s+(\d+)\s+obj\b/g;
436
+ let m;
437
+ while ((m = 무늬.exec(s)) !== null) {
438
+ const 자리 = m.index + m[0].length - `${m[1]} ${m[2]} obj`.length;
439
+ this.자리.set(Number(m[1]), 자리);
440
+ }
441
+ }
442
+
443
+ /** ② xref 를 따라가며 trailer 를 모은다. */
444
+ xref읽기() {
445
+ const s = this.b.toString('latin1');
446
+ const 끝 = s.lastIndexOf('startxref');
447
+ let 자리 = 끝 >= 0 ? Number((s.slice(끝 + 9, 끝 + 40).match(/\d+/) ?? [])[0]) : NaN;
448
+ const 봤다 = new Set();
449
+ let 걸음 = 0;
450
+ while (Number.isInteger(자리) && 자리 >= 0 && 자리 < this.b.length && !봤다.has(자리) && 걸음 < 64) {
451
+ 봤다.add(자리); 걸음 += 1;
452
+ const t = this.xref한칸(자리);
453
+ if (!t) break;
454
+ // 먼저 만난 것이 최신이다 — 이미 있는 열쇠는 안 덮는다.
455
+ for (const [k, v] of Object.entries(t)) if (!(k in this.trailer)) this.trailer[k] = v;
456
+ const 다음 = typeof t.Prev === 'number' ? t.Prev : null;
457
+ // 혼합 파일(XRefStm)은 표와 흐름이 같이 있다.
458
+ if (typeof t.XRefStm === 'number' && !봤다.has(t.XRefStm)) this.xref한칸(t.XRefStm);
459
+ 자리 = 다음;
460
+ }
461
+ if (!this.trailer.Root) {
462
+ // trailer 를 못 찾았으면 카탈로그를 직접 찾는다.
463
+ const m = /(\d+)\s+\d+\s+obj\s*<<[^>]{0,400}?\/Type\s*\/Catalog/.exec(this.b.toString('latin1'));
464
+ if (m) this.trailer.Root = { 참조: Number(m[1]), 세대: 0 };
465
+ }
466
+ }
467
+
468
+ /** xref 한 칸 — 고전 표이거나 흐름이다. trailer 사전을 돌려준다. */
469
+ xref한칸(자리) {
470
+ const 앞 = 건너뛰기(this.b, 자리);
471
+ if (this.b.toString('latin1', 앞, 앞 + 4) === 'xref') {
472
+ // 고전 표: 자리만 적는다(우리는 훑기로 이미 알지만, 여기가 더 정확하다).
473
+ let i = 앞 + 4;
474
+ for (;;) {
475
+ i = 건너뛰기(this.b, i);
476
+ if (this.b.toString('latin1', i, i + 7) === 'trailer') {
477
+ const [t] = 값읽기(this.b, i + 7, (x) => this.풀기(x));
478
+ return t && typeof t === 'object' ? t : {};
479
+ }
480
+ const m = /^(\d+)\s+(\d+)/.exec(this.b.toString('latin1', i, i + 40));
481
+ if (!m) return {};
482
+ let 번호 = Number(m[1]);
483
+ const 수 = Number(m[2]);
484
+ i = 건너뛰기(this.b, i + m[0].length);
485
+ for (let k = 0; k < 수; k += 1) {
486
+ const 줄 = this.b.toString('latin1', i, i + 20);
487
+ const mm = /^(\d{10})\s(\d{5})\s([nf])/.exec(줄);
488
+ if (mm && mm[3] === 'n') {
489
+ const off = Number(mm[1]);
490
+ // 훑기가 찾은 자리를 못 믿을 때만 xref 를 쓴다. 둘 다 있으면
491
+ // 그 자리에 진짜 `N obj` 가 있는 쪽을 고른다.
492
+ if (this.진짜객체인가(off, 번호)) this.자리.set(번호, off);
493
+ else if (!this.자리.has(번호)) this.자리.set(번호, off);
494
+ }
495
+ 번호 += 1;
496
+ i += 20;
497
+ // 줄이 19자인 파일도 있다. 다음 줄 머리를 보고 맞춘다.
498
+ while (i < this.b.length && 공백인가(this.b[i]) && !/\d/.test(String.fromCharCode(this.b[i]))) {
499
+ if (this.b[i] === 0x0a || this.b[i] === 0x0d || this.b[i] === 0x20) break;
500
+ i += 1;
501
+ }
502
+ }
503
+ }
504
+ }
505
+
506
+ // xref 흐름
507
+ const [값] = 값읽기(this.b, 앞, (x) => this.풀기(x));
508
+ const 흐름 = 값?.날것 ? 값 : null;
509
+ if (!흐름) {
510
+ // `N G obj` 로 시작하는 자리일 수도 있다.
511
+ const m = /^\s*\d+\s+\d+\s+obj/.exec(this.b.toString('latin1', 앞, 앞 + 40));
512
+ if (!m) return null;
513
+ const [값2] = 값읽기(this.b, 앞 + m[0].length, (x) => this.풀기(x));
514
+ if (!값2?.날것) return null;
515
+ return this.xref흐름읽기(값2);
516
+ }
517
+ return this.xref흐름읽기(흐름);
518
+ }
519
+
520
+ xref흐름읽기(흐름) {
521
+ const 사전 = 흐름.사전 ?? {};
522
+ if (사전.Type?.이름 !== 'XRef' && !사전.W) return 사전;
523
+ const r = 흐름풀기(흐름, (x) => this.풀기(x));
524
+ if (!r.ok) return 사전;
525
+ const W = (this.풀기(사전.W) ?? []).map((x) => this.풀기(x) ?? 0);
526
+ if (W.length < 3) return 사전;
527
+ const 크기 = this.풀기(사전.Size) ?? 0;
528
+ const 색인 = this.풀기(사전.Index) ?? [0, 크기];
529
+ const 한줄 = W.reduce((a, x) => a + x, 0);
530
+ let p = 0;
531
+ for (let g = 0; g + 1 < 색인.length; g += 2) {
532
+ let 번호 = this.풀기(색인[g]);
533
+ const 수 = this.풀기(색인[g + 1]);
534
+ for (let k = 0; k < 수 && p + 한줄 <= r.자료.length; k += 1) {
535
+ const 밭 = [];
536
+ for (const w of W) {
537
+ let v = 0;
538
+ for (let q = 0; q < w; q += 1) { v = v * 256 + r.자료[p]; p += 1; }
539
+ 밭.push(w === 0 ? null : v);
540
+ }
541
+ const 갈래 = 밭[0] === null ? 1 : 밭[0];
542
+ if (갈래 === 1 && this.진짜객체인가(밭[1], 번호)) this.자리.set(번호, 밭[1]);
543
+ else if (갈래 === 1 && !this.자리.has(번호)) this.자리.set(번호, 밭[1]);
544
+ else if (갈래 === 2) this.묶음자리 = this.묶음자리 ?? new Map(), this.묶음자리.set(번호, 밭[1]);
545
+ 번호 += 1;
546
+ }
547
+ }
548
+ return 사전;
549
+ }
550
+
551
+ /** 이 자리에 정말 `번호 G obj` 가 있나. xref 가 틀린 파일을 가려낸다. */
552
+ 진짜객체인가(자리, 번호) {
553
+ if (!Number.isInteger(자리) || 자리 < 0 || 자리 >= this.b.length) return false;
554
+ const 앞 = 건너뛰기(this.b, 자리);
555
+ const m = /^(\d+)\s+\d+\s+obj/.exec(this.b.toString('latin1', 앞, 앞 + 40));
556
+ return !!m && Number(m[1]) === 번호;
557
+ }
558
+
559
+ /** ③ 객체 흐름을 펼친다. 여기 든 객체는 파일에 낱개로 없다. */
560
+ 묶음펼치기() {
561
+ const 볼것 = new Set(this.묶음자리 ? [...this.묶음자리.values()] : []);
562
+ // 훑기로 찾은 것 중 ObjStm 도 마저 본다 (xref 가 없거나 틀린 파일).
563
+ for (const [번호] of this.자리) {
564
+ const v = this.낱개읽기(번호);
565
+ if (v?.사전?.Type?.이름 === 'ObjStm') 볼것.add(번호);
566
+ }
567
+ for (const 번호 of 볼것) {
568
+ const 흐름 = this.낱개읽기(번호);
569
+ if (!흐름?.날것) continue;
570
+ const r = 흐름풀기(흐름, (x) => this.풀기(x));
571
+ if (!r.ok) continue;
572
+ const N = this.풀기(흐름.사전.N) ?? 0;
573
+ const 첫 = this.풀기(흐름.사전.First) ?? 0;
574
+ const 머리 = r.자료.toString('latin1', 0, 첫);
575
+ const 수들 = (머리.match(/\d+/g) ?? []).map(Number);
576
+ for (let k = 0; k < N; k += 1) {
577
+ const 객체번호 = 수들[k * 2];
578
+ const 안자리 = 수들[k * 2 + 1];
579
+ if (!Number.isInteger(객체번호) || !Number.isInteger(안자리)) continue;
580
+ if (this.묶음속.has(객체번호)) continue;
581
+ const [값] = 값읽기(r.자료, 첫 + 안자리, (x) => this.풀기(x));
582
+ this.묶음속.set(객체번호, 값);
583
+ }
584
+ }
585
+ }
586
+
587
+ /** 파일에서 낱개 객체를 읽는다. */
588
+ 낱개읽기(번호) {
589
+ if (this.푼것.has(번호)) return this.푼것.get(번호);
590
+ const 자리 = this.자리.get(번호);
591
+ if (!Number.isInteger(자리)) return null;
592
+ const 앞 = 건너뛰기(this.b, 자리);
593
+ const m = /^\d+\s+\d+\s+obj/.exec(this.b.toString('latin1', 앞, 앞 + 40));
594
+ if (!m) return null;
595
+ this.푼것.set(번호, null); // 되돌이 참조 막기
596
+ const [값] = 값읽기(this.b, 앞 + m[0].length, (x) => this.풀기(x));
597
+ this.푼것.set(번호, 값);
598
+ return 값;
599
+ }
600
+
601
+ /** 참조면 따라가서 값을 준다. 참조가 아니면 그대로. */
602
+ 풀기(v, 깊이 = 0) {
603
+ if (깊이 > 32) return null;
604
+ if (v && typeof v === 'object' && Number.isInteger(v.참조)) {
605
+ const n = v.참조;
606
+ const 안것 = this.묶음속.has(n) ? this.묶음속.get(n) : this.낱개읽기(n);
607
+ return this.풀기(안것, 깊이 + 1);
608
+ }
609
+ return v;
610
+ }
611
+
612
+ /** 사전에서 열쇠 하나를 풀어서. */
613
+ 꺼내기(사전, 열쇠) {
614
+ const d = this.풀기(사전);
615
+ if (!d || typeof d !== 'object') return null;
616
+ const 안 = d.사전 ?? d;
617
+ return this.풀기(안?.[열쇠]);
618
+ }
619
+ }
620
+
621
+ /* ────────────────────────────────────────────────────────────────────────
622
+ * 5. 쪽 나무
623
+ * ──────────────────────────────────────────────────────────────────────── */
624
+
625
+ /** 물려받는 것들 — 쪽에 없으면 부모에서 가져온다(규격이 그렇다). */
626
+ const 물림 = ['Resources', 'MediaBox', 'CropBox', 'Rotate'];
627
+
628
+ function 쪽모으기(문, 마디, 물린것, 나온것, 본것, 깊이 = 0) {
629
+ const d = 문.풀기(마디);
630
+ if (!d || typeof d !== 'object' || 깊이 > 64 || 나온것.length > 5000) return;
631
+ const 안 = d.사전 ?? d;
632
+ const 이번물림 = { ...물린것 };
633
+ for (const k of 물림) if (안[k] !== undefined) 이번물림[k] = 안[k];
634
+
635
+ const 갈래 = 문.풀기(안.Type)?.이름;
636
+ const 아이들 = 문.풀기(안.Kids);
637
+
638
+ if (갈래 === 'Page' || (!아이들 && 안.Contents !== undefined)) {
639
+ 나온것.push({ 사전: 안, 물림: 이번물림 });
640
+ return;
641
+ }
642
+ if (Array.isArray(아이들)) {
643
+ for (const k of 아이들) {
644
+ const 열쇠 = Number.isInteger(k?.참조) ? k.참조 : null;
645
+ if (열쇠 !== null) { if (본것.has(열쇠)) continue; 본것.add(열쇠); }
646
+ 쪽모으기(문, k, 이번물림, 나온것, 본것, 깊이 + 1);
647
+ }
648
+ }
649
+ }
650
+
651
+ /** 쪽 나무가 깨졌을 때 — 파일에 있는 /Type /Page 를 번호 순으로 줍는다. */
652
+ function 쪽줍기(문) {
653
+ const 나온것 = [];
654
+ const 번호들 = [...문.자리.keys()].sort((a, b) => a - b);
655
+ const 다 = new Set([...번호들, ...문.묶음속.keys()]);
656
+ for (const n of [...다].sort((a, b) => a - b)) {
657
+ const v = 문.묶음속.has(n) ? 문.묶음속.get(n) : 문.낱개읽기(n);
658
+ const 안 = v?.사전 ?? v;
659
+ if (안 && typeof 안 === 'object' && !Array.isArray(안) && 문.풀기(안.Type)?.이름 === 'Page') {
660
+ 나온것.push({ 사전: 안, 물림: {} });
661
+ }
662
+ }
663
+ return 나온것;
664
+ }
665
+
666
+ /* ────────────────────────────────────────────────────────────────────────
667
+ * 6. 글꼴 — 글리프 번호를 글자로
668
+ * ──────────────────────────────────────────────────────────────────────── */
669
+
670
+ /* WinAnsi 가 Latin-1 과 다른 자리(0x80~0x9f)만 적는다. 나머지는 같다. */
671
+ const 윈안시특별 = {
672
+ 0x80: '€', 0x82: '‚', 0x83: 'ƒ', 0x84: '„', 0x85: '…', 0x86: '†', 0x87: '‡',
673
+ 0x88: 'ˆ', 0x89: '‰', 0x8a: 'Š', 0x8b: '‹', 0x8c: 'Œ', 0x8e: 'Ž',
674
+ 0x91: '‘', 0x92: '’', 0x93: '“', 0x94: '”', 0x95: '•', 0x96: '–', 0x97: '—',
675
+ 0x98: '˜', 0x99: '™', 0x9a: 'š', 0x9b: '›', 0x9c: 'œ', 0x9e: 'ž', 0x9f: 'Ÿ',
676
+ };
677
+
678
+ /** 글리프 이름 → 글자. 흔한 것만 — 없으면 없다고 한다. */
679
+ const 이름글자 = {
680
+ space: ' ', exclam: '!', quotedbl: '"', numbersign: '#', dollar: '$', percent: '%',
681
+ ampersand: '&', quotesingle: "'", parenleft: '(', parenright: ')', asterisk: '*',
682
+ plus: '+', comma: ',', hyphen: '-', period: '.', slash: '/', zero: '0', one: '1',
683
+ two: '2', three: '3', four: '4', five: '5', six: '6', seven: '7', eight: '8',
684
+ nine: '9', colon: ':', semicolon: ';', less: '<', equal: '=', greater: '>',
685
+ question: '?', at: '@', bracketleft: '[', backslash: '\\', bracketright: ']',
686
+ asciicircum: '^', underscore: '_', grave: '`', braceleft: '{', bar: '|',
687
+ braceright: '}', asciitilde: '~', quoteleft: '‘', quoteright: '’',
688
+ quotedblleft: '“', quotedblright: '”', endash: '–', emdash: '—', bullet: '•',
689
+ ellipsis: '…', fi: 'fi', fl: 'fl', nbspace: ' ', won: '₩', Euro: '€',
690
+ };
691
+
692
+ function 글리프이름글자(이름) {
693
+ if (!이름) return null;
694
+ if (이름글자[이름]) return 이름글자[이름];
695
+ let m = /^uni([0-9A-Fa-f]{4,6})$/.exec(이름);
696
+ if (m) return String.fromCodePoint(parseInt(m[1], 16));
697
+ m = /^u([0-9A-Fa-f]{4,6})$/.exec(이름);
698
+ if (m) return String.fromCodePoint(parseInt(m[1], 16));
699
+ if (/^[A-Za-z]$/.test(이름)) return 이름;
700
+ m = /^(?:g|cid|G|index)(\d+)$/.exec(이름);
701
+ if (m) return null; // 번호 이름은 글자를 못 안다
702
+ return null;
703
+ }
704
+
705
+ /** `<0041>` 같은 16진수 뭉치를 코드 값으로. */
706
+ function 열여섯값(글) {
707
+ const s = String(글 ?? '').replace(/[^0-9A-Fa-f]/g, '');
708
+ return s ? parseInt(s, 16) : null;
709
+ }
710
+
711
+ /** `<0041>` 을 유니코드 글자로 (UTF-16BE 여러 글자일 수 있다). */
712
+ function 열여섯글자(글) {
713
+ const s = String(글 ?? '').replace(/[^0-9A-Fa-f]/g, '');
714
+ if (!s) return '';
715
+ const 짝 = s.length % 4 ? s.padEnd(Math.ceil(s.length / 4) * 4, '0') : s;
716
+ let 나온것 = '';
717
+ for (let i = 0; i < 짝.length; i += 4) 나온것 += String.fromCharCode(parseInt(짝.slice(i, i + 4), 16));
718
+ return 나온것;
719
+ }
720
+
721
+ /**
722
+ * /ToUnicode CMap 을 읽는다.
723
+ *
724
+ * 이 표가 한글 PDF 의 전부다. 없으면 Identity-H 문서는 한 글자도 못 읽는다.
725
+ */
726
+ export function 유니코드표읽기(글) {
727
+ const 표 = new Map();
728
+ const 코드폭 = new Set();
729
+ const s = String(글 ?? '');
730
+
731
+ for (const m of s.matchAll(/begincodespacerange([\s\S]*?)endcodespacerange/g)) {
732
+ for (const p of m[1].matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g)) {
733
+ 코드폭.add(Math.ceil(p[1].length / 2));
734
+ }
735
+ }
736
+ for (const m of s.matchAll(/beginbfchar([\s\S]*?)endbfchar/g)) {
737
+ for (const p of m[1].matchAll(/<([0-9A-Fa-f]+)>\s*(?:<([0-9A-Fa-f]+)>|\/(\S+))/g)) {
738
+ const 코드 = 열여섯값(p[1]);
739
+ if (코드 === null) continue;
740
+ 코드폭.add(Math.ceil(p[1].length / 2));
741
+ 표.set(코드, p[2] !== undefined ? 열여섯글자(p[2]) : (글리프이름글자(p[3]) ?? ''));
742
+ }
743
+ }
744
+ for (const m of s.matchAll(/beginbfrange([\s\S]*?)endbfrange/g)) {
745
+ const 몸 = m[1];
746
+ // `<시작> <끝> [<a> <b> …]` 꼴
747
+ for (const p of 몸.matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*\[([\s\S]*?)\]/g)) {
748
+ const 시작 = 열여섯값(p[1]);
749
+ 코드폭.add(Math.ceil(p[1].length / 2));
750
+ const 것들 = [...p[3].matchAll(/<([0-9A-Fa-f]+)>/g)].map((x) => 열여섯글자(x[1]));
751
+ 것들.forEach((글자, k) => 표.set(시작 + k, 글자));
752
+ }
753
+ // `<시작> <끝> <첫글자>` 꼴
754
+ for (const p of 몸.matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g)) {
755
+ const 시작 = 열여섯값(p[1]);
756
+ const 끝 = 열여섯값(p[2]);
757
+ const 첫 = 열여섯글자(p[3]);
758
+ 코드폭.add(Math.ceil(p[1].length / 2));
759
+ if (시작 === null || 끝 === null || 끝 < 시작 || 끝 - 시작 > 65535) continue;
760
+ const 밑 = 첫.charCodeAt(첫.length - 1);
761
+ for (let k = 0; k <= 끝 - 시작; k += 1) {
762
+ if (표.has(시작 + k)) continue;
763
+ 표.set(시작 + k, 첫.slice(0, -1) + String.fromCharCode(밑 + k));
764
+ }
765
+ }
766
+ }
767
+ return { 표, 폭: 코드폭.has(2) ? 2 : (코드폭.has(1) ? 1 : 0) };
768
+ }
769
+
770
+ /**
771
+ * 글꼴에 적힌 글자 너비를 읽는다 (1/1000 em → em).
772
+ *
773
+ * 한 바이트 글꼴은 `/FirstChar` 부터 `/Widths` 배열이 죽 이어진다.
774
+ * CID 글꼴은 `/W` 가 두 꼴을 섞어 쓴다 — `c [w …]` 와 `c1 c2 w`.
775
+ * 못 찾은 코드는 `/MissingWidth` 나 `/DW`(기본 1000)로 간다.
776
+ */
777
+ function 너비표읽기(문, 안, 두바이트) {
778
+ const 표 = new Map();
779
+ let 기본 = null;
780
+
781
+ if (두바이트) {
782
+ const 딸린것 = 문.풀기(안.DescendantFonts);
783
+ const 속 = 문.풀기(Array.isArray(딸린것) ? 딸린것[0] : 딸린것);
784
+ const 속안 = 속?.사전 ?? 속;
785
+ if (속안 && typeof 속안 === 'object') {
786
+ const dw = 문.풀기(속안.DW);
787
+ 기본 = (typeof dw === 'number' ? dw : 1000) / 1000;
788
+ const W = 문.풀기(속안.W);
789
+ if (Array.isArray(W)) {
790
+ let i = 0;
791
+ while (i < W.length) {
792
+ const 첫 = 문.풀기(W[i]);
793
+ const 둘째 = 문.풀기(W[i + 1]);
794
+ if (Array.isArray(둘째)) {
795
+ 둘째.forEach((w, k) => {
796
+ const v = 문.풀기(w);
797
+ if (typeof v === 'number') 표.set(첫 + k, v / 1000);
798
+ });
799
+ i += 2;
800
+ } else if (typeof 첫 === 'number' && typeof 둘째 === 'number') {
801
+ const w = 문.풀기(W[i + 2]);
802
+ if (typeof w === 'number' && 둘째 >= 첫 && 둘째 - 첫 <= 65535) {
803
+ for (let c = 첫; c <= 둘째; c += 1) 표.set(c, w / 1000);
804
+ }
805
+ i += 3;
806
+ } else i += 1;
807
+ }
808
+ }
809
+ }
810
+ return { 표, 기본: 기본 ?? 1 };
811
+ }
812
+
813
+ const 첫코드 = 문.풀기(안.FirstChar);
814
+ const 너비들 = 문.풀기(안.Widths);
815
+ if (Array.isArray(너비들) && Number.isInteger(첫코드)) {
816
+ 너비들.forEach((w, k) => {
817
+ const v = 문.풀기(w);
818
+ if (typeof v === 'number') 표.set(첫코드 + k, v / 1000);
819
+ });
820
+ }
821
+ const 서술 = 문.풀기(안.FontDescriptor);
822
+ const 없을때 = 문.풀기((서술?.사전 ?? 서술)?.MissingWidth);
823
+ if (typeof 없을때 === 'number') 기본 = 없을때 / 1000;
824
+ return { 표, 기본 };
825
+ }
826
+
827
+ /**
828
+ * 글꼴 하나를 「바이트 → 글자」 로 바꿔 주는 물건으로 만든다.
829
+ *
830
+ * 못 읽는 글꼴이면 `읽나: false` 를 단다 — 그 쪽이 왜 안 읽혔는지 말하려고.
831
+ */
832
+ function 글꼴만들기(문, 글꼴사전) {
833
+ const d = 문.풀기(글꼴사전);
834
+ const 안 = d?.사전 ?? d;
835
+ if (!안 || typeof 안 !== 'object') return { 폭: 1, 표: new Map(), 읽나: true, 기본: true, 너비: { 표: new Map(), 기본: null } };
836
+
837
+ const 갈래 = 문.풀기(안.Subtype)?.이름;
838
+ const 인코딩값 = 문.풀기(안.Encoding);
839
+ const 인코딩이름 = 인코딩값?.이름 ?? 문.풀기(인코딩값?.BaseEncoding)?.이름 ?? null;
840
+ const 두바이트 = 갈래 === 'Type0';
841
+ const 너비 = 너비표읽기(문, 안, 두바이트);
842
+
843
+ // ① /ToUnicode 가 있으면 그것이 가장 정확하다.
844
+ let 표 = new Map();
845
+ let 폭 = 두바이트 ? 2 : 1;
846
+ const 투 = 문.풀기(안.ToUnicode);
847
+ if (투?.날것) {
848
+ const r = 흐름풀기(투, (x) => 문.풀기(x));
849
+ if (r.ok) {
850
+ const 읽은것 = 유니코드표읽기(r.자료.toString('latin1'));
851
+ 표 = 읽은것.표;
852
+ if (읽은것.폭) 폭 = 읽은것.폭;
853
+ if (표.size) return { 폭, 표, 읽나: true, 너비 };
854
+ }
855
+ }
856
+
857
+ // ② 두 바이트인데 표가 없으면 — 글리프 번호만 있다. 못 읽는다.
858
+ if (두바이트) {
859
+ return {
860
+ 폭: 2,
861
+ 표: new Map(),
862
+ 읽나: false,
863
+ 너비,
864
+ 왜: `글꼴 ${문.풀기(안.BaseFont)?.이름 ?? '(이름 없음)'} 에 /ToUnicode 표가 없습니다`,
865
+ };
866
+ }
867
+
868
+ // ③ 한 바이트 글꼴 — 인코딩과 Differences 로 만든다.
869
+ const 낱개 = new Map();
870
+ const 기본으로 = (코드) => {
871
+ if (인코딩이름 === 'WinAnsiEncoding' && 윈안시특별[코드]) return 윈안시특별[코드];
872
+ if (코드 >= 0x20 && 코드 < 0x7f) return String.fromCharCode(코드);
873
+ if (코드 >= 0xa0) return String.fromCharCode(코드); // Latin-1 자리
874
+ return null;
875
+ };
876
+ for (let c = 0; c < 256; c += 1) {
877
+ const g = 기본으로(c);
878
+ if (g !== null) 낱개.set(c, g);
879
+ }
880
+ const 다름 = 문.풀기(인코딩값?.Differences);
881
+ if (Array.isArray(다름)) {
882
+ let 지금 = 0;
883
+ for (const x of 다름) {
884
+ const v = 문.풀기(x);
885
+ if (typeof v === 'number') { 지금 = v; continue; }
886
+ if (v?.이름) {
887
+ const g = 글리프이름글자(v.이름);
888
+ if (g) 낱개.set(지금, g); else 낱개.delete(지금);
889
+ 지금 += 1;
890
+ }
891
+ }
892
+ }
893
+ return { 폭: 1, 표: 낱개, 읽나: true, 기본: true, 너비 };
894
+ }
895
+
896
+ /**
897
+ * 바이트 뭉치를 이 글꼴로 읽는다.
898
+ *
899
+ * 못 읽은 코드 수와, 붓이 나아간 거리(em 단위)도 같이 낸다. 거리는 글꼴에
900
+ * 적힌 진짜 너비를 쓰고, 없는 코드만 어림으로 메운다.
901
+ */
902
+ function 글자로(글꼴, 바이트들) {
903
+ const 폭 = 글꼴?.폭 === 2 ? 2 : 1;
904
+ const 너비표 = 글꼴?.너비?.표;
905
+ const 너비기본 = 글꼴?.너비?.기본 ?? null;
906
+ let 글 = '';
907
+ let 못읽음 = 0;
908
+ let 나아감 = 0;
909
+ for (let i = 0; i + 폭 <= 바이트들.length + (폭 - 1); i += 폭) {
910
+ const 코드 = 폭 === 2
911
+ ? ((바이트들[i] << 8) | (바이트들[i + 1] ?? 0))
912
+ : 바이트들[i];
913
+ if (코드 === undefined) break;
914
+ const g = 글꼴?.표?.get(코드);
915
+ let 찍은것 = null;
916
+ if (g !== undefined && g !== null && g !== '') { 글 += g; 찍은것 = g; }
917
+ else if (폭 === 1 && 코드 >= 0x20 && 코드 < 0x7f) { 글 += String.fromCharCode(코드); 찍은것 = String.fromCharCode(코드); }
918
+ else 못읽음 += 1;
919
+
920
+ const 적힌것 = 너비표?.get(코드);
921
+ if (typeof 적힌것 === 'number') 나아감 += 적힌것;
922
+ else if (typeof 너비기본 === 'number') 나아감 += 너비기본;
923
+ else 나아감 += 찍은것 ? 글자너비비율(찍은것) : 넓은글자;
924
+ }
925
+ return { 글, 못읽음, 나아감 };
926
+ }
927
+
928
+ /* ────────────────────────────────────────────────────────────────────────
929
+ * 7. 내용 흐름에서 글 뽑기
930
+ * ──────────────────────────────────────────────────────────────────────── */
931
+
932
+ /** 줄이 바뀌었다고 볼 세로 이동(글꼴 크기와 무관한 어림값). */
933
+ const 줄바뀜 = 3.5;
934
+ /** TJ 안의 이만큼 넘는 음수 이동은 낱말 사이 빈칸으로 본다(1/1000 em). */
935
+ const 빈칸이동 = 180;
936
+ /** 글자 하나가 나아간 거리를 글꼴 크기의 몇 배로 볼까. */
937
+ const 넓은글자 = 1.0; // 한중일 — 네모 한 칸
938
+ const 좁은글자 = 0.5; // 그 밖 — 어림값
939
+
940
+ /**
941
+ * 글꼴이 너비를 안 알려줄 때 쓰는 어림값 (글꼴 크기의 배수).
942
+ *
943
+ * 어림은 마지막 수단이다. 처음엔 이것만 썼는데 「jumps」가 「jum ps」로
944
+ * 나왔다 — m 은 0.83em 인데 0.5 로 쳤더니 남는 거리가 빈칸처럼 보였다.
945
+ * 그래서 글꼴에 적힌 진짜 너비(/Widths · /W)를 먼저 읽고, 없을 때만 여기로 온다.
946
+ */
947
+ function 글자너비비율(글자) {
948
+ const c = 글자.codePointAt(0) ?? 0;
949
+ if ((c >= 0x1100 && c <= 0x11ff) || (c >= 0x2e80 && c <= 0x9fff)
950
+ || (c >= 0xa960 && c <= 0xa97f) || (c >= 0xac00 && c <= 0xd7ff)
951
+ || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xff00 && c <= 0xff60)) return 넓은글자;
952
+ return 좁은글자;
953
+ }
954
+
955
+ function 곱하기(m, n) {
956
+ return [
957
+ m[0] * n[0] + m[1] * n[2], m[0] * n[1] + m[1] * n[3],
958
+ m[2] * n[0] + m[3] * n[2], m[2] * n[1] + m[3] * n[3],
959
+ m[4] * n[0] + m[5] * n[2] + n[4], m[4] * n[1] + m[5] * n[3] + n[5],
960
+ ];
961
+ }
962
+
963
+ /**
964
+ * 내용 흐름 하나에서 글을 뽑는다.
965
+ *
966
+ * 자리(행렬)를 따라가며 세로로 움직이면 줄을 바꾼다. 정확한 조판을 흉내내는
967
+ * 것이 아니라 **읽을 수 있는 줄**을 만드는 것이 목적이다.
968
+ */
969
+ function 흐름에서글(문, 자료, 자원, 깊이 = 0) {
970
+ const b = 자료;
971
+ const 줄들 = [];
972
+ let 이번줄 = '';
973
+ let 앞y = null;
974
+ let 앞x = null;
975
+ let 펜x = null; // 붓이 지금 어디까지 갔다고 보는가 (자리바뀜 참고)
976
+ let 못읽은코드 = 0;
977
+ let 글낸적 = false;
978
+ const 글꼴통 = new Map();
979
+
980
+ let Tm = [1, 0, 0, 1, 0, 0];
981
+ let Tlm = [1, 0, 0, 1, 0, 0];
982
+ let TL = 0;
983
+ let 지금글꼴 = null;
984
+ let 글꼴크기 = 0;
985
+
986
+ const 쌓임 = [];
987
+ let i = 0;
988
+
989
+ const 줄맺기 = () => {
990
+ const t = 이번줄.replace(/[ \t]+$/, '');
991
+ if (t.trim()) 줄들.push(t);
992
+ 이번줄 = '';
993
+ };
994
+
995
+ /*
996
+ * 자리가 바뀌었다 — 줄을 바꿀까, 빈칸을 넣을까, 그냥 이어 쓸까.
997
+ *
998
+ * 여기서 조심할 것이 하나 있다. 크롬 같은 프로그램은 **글자 하나마다**
999
+ * `Td` 로 옮기고 `Tj` 로 찍는다. 그래서 가로로 움직였다는 것만 보고 빈칸을
1000
+ * 넣으면 「결제」가 「결 제」가 된다 — 글자 사이마다 빈칸이 박힌다.
1001
+ *
1002
+ * 그 움직임은 대개 **방금 찍은 글자의 너비**다. 그래서 찍은 만큼 붓이
1003
+ * 나아갔다고 치고(펜x), 그보다 더 벌어졌을 때만 빈칸으로 본다.
1004
+ */
1005
+ const 자리바뀜 = () => {
1006
+ const x = Tm[4]; const y = Tm[5];
1007
+ if (앞y !== null && Math.abs(y - 앞y) > 줄바뀜) {
1008
+ 줄맺기();
1009
+ } else if (펜x !== null && 이번줄 && !/\s$/.test(이번줄)) {
1010
+ const 틈 = x - 펜x;
1011
+ if (틈 > 0.25 * 글꼴크기 * Math.abs(Tm[0] || 1)) 이번줄 += ' ';
1012
+ }
1013
+ 앞y = y; 앞x = x; 펜x = x;
1014
+ };
1015
+
1016
+ const 글쓰기 = (바이트들) => {
1017
+ const r = 글자로(지금글꼴, 바이트들);
1018
+ 못읽은코드 += r.못읽음;
1019
+ if (r.글) { 이번줄 += r.글; 글낸적 = true; }
1020
+ else if (r.못읽음) 글낸적 = true; // 글을 그리기는 했다 — 못 읽었을 뿐
1021
+ // 찍은 만큼 붓을 밀어 둔다. 다음 이동이 「글자 너비」인지 「진짜 틈」인지
1022
+ // 가르는 잣대가 이것뿐이다.
1023
+ if (펜x !== null) 펜x += r.나아감 * 글꼴크기 * Math.abs(Tm[0] || 1);
1024
+ };
1025
+
1026
+ while (i < b.length) {
1027
+ i = 건너뛰기(b, i);
1028
+ if (i >= b.length) break;
1029
+
1030
+ // 붙박이 그림은 통째로 건너뛴다. 안 그러면 그림 자료를 낱말로 읽는다.
1031
+ if (b[i] === 0x42 && b[i + 1] === 0x49 && 끝인가(b[i + 2])) { // 'BI'
1032
+ const 끝 = b.indexOf('EI', i, 'latin1');
1033
+ i = 끝 < 0 ? b.length : 끝 + 2;
1034
+ 글낸적 = 글낸적 || false;
1035
+ continue;
1036
+ }
1037
+
1038
+ const [값, 다음] = 값읽기(b, i, null);
1039
+ if (다음 <= i) { i += 1; continue; }
1040
+ i = 다음;
1041
+
1042
+ if (!값 || typeof 값 !== 'object' || !값.연산자) { 쌓임.push(값); if (쌓임.length > 64) 쌓임.shift(); continue; }
1043
+
1044
+ const op = 값.연산자;
1045
+ const 인자 = 쌓임.slice();
1046
+ 쌓임.length = 0;
1047
+
1048
+ switch (op) {
1049
+ case 'BT': Tm = [1, 0, 0, 1, 0, 0]; Tlm = Tm.slice(); 앞y = null; 앞x = null; break;
1050
+ case 'ET': 줄맺기(); break;
1051
+ case 'TL': TL = Number(인자[0]) || 0; break;
1052
+ case 'Td': {
1053
+ const tx = Number(인자[0]) || 0; const ty = Number(인자[1]) || 0;
1054
+ Tlm = 곱하기([1, 0, 0, 1, tx, ty], Tlm); Tm = Tlm.slice(); 자리바뀜(); break;
1055
+ }
1056
+ case 'TD': {
1057
+ const tx = Number(인자[0]) || 0; const ty = Number(인자[1]) || 0;
1058
+ TL = -ty;
1059
+ Tlm = 곱하기([1, 0, 0, 1, tx, ty], Tlm); Tm = Tlm.slice(); 자리바뀜(); break;
1060
+ }
1061
+ case 'Tm': {
1062
+ const n = 인자.slice(0, 6).map((x) => Number(x) || 0);
1063
+ if (n.length === 6) { Tlm = n; Tm = n.slice(); 자리바뀜(); }
1064
+ break;
1065
+ }
1066
+ case 'T*': {
1067
+ Tlm = 곱하기([1, 0, 0, 1, 0, -TL], Tlm); Tm = Tlm.slice(); 자리바뀜(); break;
1068
+ }
1069
+ case 'Tf': {
1070
+ 글꼴크기 = Number(인자[1]) || 0;
1071
+ const 이름 = 인자[0]?.이름;
1072
+ if (이름) {
1073
+ if (!글꼴통.has(이름)) {
1074
+ const 글꼴들 = 문.꺼내기(자원, 'Font');
1075
+ 글꼴통.set(이름, 글꼴만들기(문, (글꼴들?.사전 ?? 글꼴들)?.[이름]));
1076
+ }
1077
+ 지금글꼴 = 글꼴통.get(이름);
1078
+ }
1079
+ break;
1080
+ }
1081
+ case 'Tj': case 'TJ': case "'": case '"': {
1082
+ if (op === "'" || op === '"') { Tlm = 곱하기([1, 0, 0, 1, 0, -TL], Tlm); Tm = Tlm.slice(); 자리바뀜(); }
1083
+ const 몫 = op === '"' ? 인자[2] : 인자[인자.length - 1];
1084
+ if (op === 'TJ') {
1085
+ const 배열 = Array.isArray(몫) ? 몫 : [];
1086
+ for (const x of 배열) {
1087
+ if (typeof x === 'number') {
1088
+ if (-x > 빈칸이동 && 이번줄 && !/\s$/.test(이번줄)) 이번줄 += ' ';
1089
+ } else if (x?.바이트) 글쓰기(x.바이트);
1090
+ }
1091
+ } else if (몫?.바이트) 글쓰기(몫.바이트);
1092
+ break;
1093
+ }
1094
+ case 'Do': {
1095
+ // 폼 XObject 안에도 글이 있다 (머리말·표·도장). 깊이는 막아 둔다.
1096
+ if (깊이 >= 4) break;
1097
+ const 이름 = 인자[0]?.이름;
1098
+ if (!이름) break;
1099
+ const 것들 = 문.꺼내기(자원, 'XObject');
1100
+ const 하나 = 문.풀기((것들?.사전 ?? 것들)?.[이름]);
1101
+ if (!하나?.날것) break;
1102
+ if (문.풀기(하나.사전?.Subtype)?.이름 !== 'Form') break;
1103
+ const r = 흐름풀기(하나, (x) => 문.풀기(x));
1104
+ if (!r.ok) break;
1105
+ const 속자원 = 문.풀기(하나.사전?.Resources) ?? 자원;
1106
+ const 안것 = 흐름에서글(문, r.자료, 속자원, 깊이 + 1);
1107
+ 줄맺기();
1108
+ 줄들.push(...안것.줄들);
1109
+ 못읽은코드 += 안것.못읽은코드;
1110
+ 글낸적 = 글낸적 || 안것.글낸적;
1111
+ break;
1112
+ }
1113
+ default: break;
1114
+ }
1115
+ }
1116
+ 줄맺기();
1117
+ return { 줄들, 못읽은코드, 글낸적 };
1118
+ }
1119
+
1120
+ /* ────────────────────────────────────────────────────────────────────────
1121
+ * 8. 바깥으로
1122
+ * ──────────────────────────────────────────────────────────────────────── */
1123
+
1124
+ /** 이 길로 읽는 파일인가. */
1125
+ export function isPdfPath(p) {
1126
+ return typeof p === 'string' && extname(p).toLowerCase() === '.pdf';
1127
+ }
1128
+
1129
+ /** 속이 정말 PDF 인가 (머리 1KB 안에 %PDF- 가 있다). */
1130
+ export function looksPdf(buf) {
1131
+ if (!buf || buf.length < 5) return false;
1132
+ return buf.toString('latin1', 0, Math.min(1024, buf.length)).includes('%PDF-');
1133
+ }
1134
+
1135
+ /**
1136
+ * PDF 를 쪽마다 글로 읽는다.
1137
+ *
1138
+ * @returns {{ok:true, 갈래:'pdf', 쪽수:number, 덩이들:Array<{이름,문단들}>,
1139
+ * 못읽은쪽:Array<{번호,왜}>, 판:string}
1140
+ * | {ok:false, error:string}}
1141
+ */
1142
+ export function readPdf(경로또는버퍼) {
1143
+ let buf;
1144
+ try {
1145
+ buf = Buffer.isBuffer(경로또는버퍼) ? 경로또는버퍼 : readFileSync(경로또는버퍼);
1146
+ } catch (err) {
1147
+ return { ok: false, error: `못 읽었습니다: ${err.message}` };
1148
+ }
1149
+ if (!looksPdf(buf)) return { ok: false, error: 'PDF 가 아닙니다 (머리에 %PDF- 가 없습니다).' };
1150
+
1151
+ let 문;
1152
+ try { 문 = new 문서(buf); } catch (err) {
1153
+ return { ok: false, error: `PDF 를 여는 데 실패했습니다: ${err.message}` };
1154
+ }
1155
+
1156
+ /*
1157
+ * 암호가 걸렸으면 여기서 멈춘다.
1158
+ *
1159
+ * 흐름이 통째로 암호화돼 있어서, 그냥 읽으면 쓰레기가 나오거나 빈 글이
1160
+ * 나온다. 빈 글을 돌려주면 「내용이 없는 문서」로 읽히므로, 왜 못 읽는지와
1161
+ * 어떻게 하면 되는지를 준다.
1162
+ */
1163
+ if (문.trailer.Encrypt) {
1164
+ return {
1165
+ ok: false,
1166
+ error: '암호가 걸린 PDF 입니다 — 글을 꺼낼 수 없습니다.\n'
1167
+ + ' PDF 뷰어에서 열어 「다른 이름으로 저장」(또는 인쇄 → PDF)으로\n'
1168
+ + ' 암호 없는 사본을 만든 뒤 그 파일을 주세요.',
1169
+ };
1170
+ }
1171
+
1172
+ const 판 = (buf.toString('latin1', 0, 16).match(/%PDF-([\d.]+)/) ?? [])[1] ?? '?';
1173
+
1174
+ // 쪽 모으기 — 나무를 먼저, 안 되면 주워서.
1175
+ const 뿌리 = 문.풀기(문.trailer.Root);
1176
+ const 쪽나무 = 문.꺼내기(뿌리?.사전 ?? 뿌리, 'Pages');
1177
+ let 쪽들 = [];
1178
+ if (쪽나무) 쪽모으기(문, 쪽나무, {}, 쪽들, new Set());
1179
+ if (!쪽들.length) 쪽들 = 쪽줍기(문);
1180
+ if (!쪽들.length) {
1181
+ return { ok: false, error: 'PDF 안에서 쪽을 못 찾았습니다 — 파일이 깨졌을 수 있습니다.' };
1182
+ }
1183
+
1184
+ const 덩이들 = [];
1185
+ const 못읽은쪽 = [];
1186
+
1187
+ 쪽들.forEach((쪽, 순번) => {
1188
+ const 번호 = 순번 + 1;
1189
+ const 안 = 쪽.사전;
1190
+ const 자원 = 문.풀기(안.Resources ?? 쪽.물림.Resources);
1191
+ const 내용 = 문.풀기(안.Contents);
1192
+ const 조각들 = Array.isArray(내용) ? 내용 : (내용 ? [내용] : []);
1193
+
1194
+ if (!조각들.length) {
1195
+ 못읽은쪽.push({ 번호, 왜: '이 쪽에는 그릴 것이 아예 없습니다 (빈 쪽)' });
1196
+ 덩이들.push({ 이름: `${번호}쪽`, 문단들: [] });
1197
+ return;
1198
+ }
1199
+
1200
+ const 자료들 = [];
1201
+ let 막힌걸개 = null;
1202
+ for (const c of 조각들) {
1203
+ const 하나 = 문.풀기(c);
1204
+ if (!하나?.날것) continue;
1205
+ const r = 흐름풀기(하나, (x) => 문.풀기(x));
1206
+ if (!r.ok) { 막힌걸개 = 막힌걸개 ?? r.왜; continue; }
1207
+ 자료들.push(r.자료);
1208
+ 자료들.push(Buffer.from('\n'));
1209
+ }
1210
+
1211
+ if (!자료들.length) {
1212
+ 못읽은쪽.push({ 번호, 왜: 막힌걸개 ?? '내용 흐름을 못 풀었습니다' });
1213
+ 덩이들.push({ 이름: `${번호}쪽`, 문단들: [] });
1214
+ return;
1215
+ }
1216
+
1217
+ let 뽑은것;
1218
+ try { 뽑은것 = 흐름에서글(문, Buffer.concat(자료들), 자원); }
1219
+ catch (err) {
1220
+ 못읽은쪽.push({ 번호, 왜: `내용을 읽다 막혔습니다: ${err.message}` });
1221
+ 덩이들.push({ 이름: `${번호}쪽`, 문단들: [] });
1222
+ return;
1223
+ }
1224
+
1225
+ const 글있나 = 뽑은것.줄들.some((l) => l.trim());
1226
+ if (!글있나) {
1227
+ /*
1228
+ * 글이 하나도 안 나온 쪽. 왜인지 갈라서 말한다 — 스캔본과 글꼴 문제는
1229
+ * 사람이 할 일이 다르다(전자는 OCR, 후자는 다시 뽑기).
1230
+ */
1231
+ const 그림있나 = /\/Subtype\s*\/Image|\bDo\b/.test(Buffer.concat(자료들).toString('latin1').slice(0, 20000))
1232
+ || !!문.꺼내기(자원, 'XObject');
1233
+ 못읽은쪽.push({
1234
+ 번호,
1235
+ 왜: 뽑은것.못읽은코드
1236
+ ? '글꼴에 /ToUnicode 표가 없어 글자를 되돌릴 수 없습니다'
1237
+ : (그림있나 ? '글이 없는 쪽입니다 (스캔한 사진일 수 있습니다 — OCR 이 필요합니다)' : '글이 없는 쪽입니다'),
1238
+ });
1239
+ 덩이들.push({ 이름: `${번호}쪽`, 문단들: [] });
1240
+ return;
1241
+ }
1242
+
1243
+ if (뽑은것.못읽은코드 > 0) {
1244
+ 못읽은쪽.push({
1245
+ 번호,
1246
+ 왜: `${뽑은것.못읽은코드}자를 못 되돌렸습니다 (글꼴 표에 없는 글리프) — 이 쪽은 일부만 읽었습니다`,
1247
+ 일부: true,
1248
+ });
1249
+ }
1250
+ 덩이들.push({ 이름: `${번호}쪽`, 문단들: 뽑은것.줄들 });
1251
+ });
1252
+
1253
+ return { ok: true, 갈래: 'pdf', 판, 쪽수: 쪽들.length, 덩이들, 못읽은쪽 };
1254
+ }
1255
+
1256
+ /**
1257
+ * 쪽들을 한 덩이 글로. 못 읽은 쪽은 **그 자리에** 적는다.
1258
+ *
1259
+ * 끝에 몰아서 적으면 모델이 3쪽 내용을 2쪽 것으로 잘못 쓴다. 자리에 적어야
1260
+ * 「여기가 비었다」가 글의 흐름으로 보인다.
1261
+ */
1262
+ export function toText(r, { maxChars = 최대글자 } = {}) {
1263
+ const 잘림 = [];
1264
+ const 조각 = [];
1265
+ let 셈 = 0;
1266
+ const 왜표 = new Map((r.못읽은쪽 ?? []).map((x) => [x.번호, x]));
1267
+
1268
+ for (const d of r.덩이들 ?? []) {
1269
+ const 번호 = Number((d.이름.match(/^(\d+)/) ?? [])[1]);
1270
+ 조각.push(`--- ${d.이름} ---`);
1271
+ const 못 = 왜표.get(번호);
1272
+ if (못 && !못.일부) {
1273
+ 조각.push(`[이 쪽은 글로 못 읽었습니다 — ${못.왜}]`);
1274
+ continue;
1275
+ }
1276
+ for (const 문단 of d.문단들) {
1277
+ if (셈 + 문단.length > maxChars) {
1278
+ 잘림.push(`${maxChars.toLocaleString('en-US')}자에서 잘랐습니다 — 뒷부분은 안 실렸습니다`);
1279
+ return { text: 조각.join('\n'), 잘림 };
1280
+ }
1281
+ 조각.push(문단);
1282
+ 셈 += 문단.length + 1;
1283
+ }
1284
+ if (못?.일부) 조각.push(`[이 쪽은 일부만 읽었습니다 — ${못.왜}]`);
1285
+ }
1286
+ return { text: 조각.join('\n'), 잘림 };
1287
+ }
1288
+
1289
+ /** 한 줄 요약. Read 의 summary 자리로 간다. */
1290
+ export function summarize(r) {
1291
+ if (!r?.ok) return '';
1292
+ const 통째로못읽음 = (r.못읽은쪽 ?? []).filter((x) => !x.일부).length;
1293
+ const 뒤 = 통째로못읽음 ? ` · ${통째로못읽음}쪽 못 읽음` : '';
1294
+ return `pdf · ${r.쪽수}쪽${뒤}`;
1295
+ }
1296
+
1297
+ /**
1298
+ * 못 읽은 쪽을 사람 말로 묶는다. 글 뒤에 붙인다.
1299
+ *
1300
+ * 여기가 이 파일에서 제일 중요한 자리다. 「3쪽 중 2쪽을 못 읽었다」가 안 보이면
1301
+ * 사람도 모델도 남은 한 쪽으로 문서 전체를 판단한다.
1302
+ */
1303
+ export function 못읽은말(r) {
1304
+ const 것들 = r?.못읽은쪽 ?? [];
1305
+ if (!것들.length) return '';
1306
+ const 통째 = 것들.filter((x) => !x.일부);
1307
+ const 일부 = 것들.filter((x) => x.일부);
1308
+ const 줄 = [];
1309
+ if (통째.length) {
1310
+ const 쪽말 = 통째.map((x) => x.번호).join(' · ');
1311
+ 줄.push(`${r.쪽수}쪽 중 ${통째.length}쪽을 글로 못 읽었습니다 (${쪽말}쪽).`);
1312
+ for (const 왜 of new Set(통째.map((x) => x.왜))) 줄.push(` · ${왜}`);
1313
+ 줄.push(' 이 쪽들의 내용은 여기 없습니다 — 없는 것이 아니라 못 꺼낸 것입니다.');
1314
+ }
1315
+ if (일부.length) 줄.push(`${일부.length}쪽은 일부만 읽었습니다 (${일부.map((x) => x.번호).join(' · ')}쪽).`);
1316
+ return 줄.join('\n');
1317
+ }
1318
+
1319
+ /** 고치려 들 때 하는 말. */
1320
+ export function pdf는못고침(보인이름) {
1321
+ return `PDF 는 이 도구로 고칠 수 없습니다: ${보인이름}\n`
1322
+ + ' 읽기만 됩니다 (쪽마다 글로 바꿔서 보여줍니다). PDF 는 「보이는 대로\n'
1323
+ + ' 인쇄하기」 위한 형식이라, 글로 바꿨다가 되돌리면 조판이 통째로 사라집니다.\n'
1324
+ + ` 내용을 바꿔야 한다면 ${basename(String(보인이름))} 은 그대로 두고, 바뀐 내용을\n`
1325
+ + ' 새 파일에 쓰세요.';
1326
+ }