deel-local-cli 1.20.13 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.ko.md +97 -78
  2. package/README.md +120 -81
  3. package/bin/deel.js +398 -32
  4. package/package.json +2 -1
  5. package/src/acp/jsonrpc.js +87 -7
  6. package/src/acp/map.js +23 -3
  7. package/src/acp/serve.js +333 -25
  8. package/src/agent/agents.js +110 -12
  9. package/src/agent/askcheck.js +50 -6
  10. package/src/agent/asks.js +59 -5
  11. package/src/agent/budget.js +9 -2
  12. package/src/agent/card.js +8 -2
  13. package/src/agent/commit.js +168 -16
  14. package/src/agent/compact.js +93 -21
  15. package/src/agent/{/354/213/240/353/242/260/353/217/204.js → confidence.js} +1 -1
  16. package/src/agent/effort.js +85 -15
  17. package/src/agent/evidence.js +63 -7
  18. package/src/agent/evolve.js +132 -24
  19. package/src/agent/grade.js +12 -3
  20. package/src/agent/loop.js +408 -81
  21. package/src/agent/memory.js +176 -29
  22. package/src/agent/mention.js +22 -5
  23. package/src/agent/models.js +31 -7
  24. package/src/agent/modes.js +25 -10
  25. package/src/agent/outschema.js +381 -56
  26. package/src/agent/pins.js +33 -2
  27. package/src/agent/project.js +73 -8
  28. package/src/agent/recall.js +40 -9
  29. package/src/agent/{/354/204/261/355/225/234/352/270/260/353/241/235.js → recordshape.js} +19 -8
  30. package/src/agent/review.js +143 -13
  31. package/src/agent/route.js +253 -26
  32. package/src/agent/salvage.js +2 -1
  33. package/src/agent/session.js +457 -31
  34. package/src/agent/store.js +232 -22
  35. package/src/agent/threads.js +52 -3
  36. package/src/backend/adapter.js +508 -85
  37. package/src/backend/azure.js +12 -1
  38. package/src/backend/cachemark.js +31 -5
  39. package/src/backend/clientcert.js +23 -1
  40. package/src/backend/ctxsize.js +56 -9
  41. package/src/backend/detect.js +252 -19
  42. package/src/backend/http.js +383 -42
  43. package/src/backend/learn.js +101 -11
  44. package/src/backend/mcp.js +333 -24
  45. package/src/backend/price.js +61 -13
  46. package/src/backend/probe.js +188 -36
  47. package/src/backend/proxy.js +108 -14
  48. package/src/backend/quota.js +226 -30
  49. package/src/backend/retry.js +46 -3
  50. package/src/backend/scan.js +47 -10
  51. package/src/backend/scanui.js +57 -7
  52. package/src/backend/toolfit.js +204 -22
  53. package/src/backend/vision.js +14 -1
  54. package/src/backend/wire.js +58 -10
  55. package/src/cmdnames.js +16 -0
  56. package/src/commands/common.js +70 -0
  57. package/src/commands/extend.js +299 -0
  58. package/src/commands/model.js +851 -0
  59. package/src/commands/view.js +328 -0
  60. package/src/commands/work.js +823 -0
  61. package/src/commands.js +193 -2082
  62. package/src/completion.js +45 -3
  63. package/src/config.js +454 -41
  64. package/src/configexplain.js +127 -15
  65. package/src/doctor.js +164 -27
  66. package/src/i18n/en.js +22 -0
  67. package/src/i18n/index.js +31 -1
  68. package/src/i18n/ja.js +25 -1
  69. package/src/i18n/ko.js +25 -2
  70. package/src/i18n/zh.js +25 -1
  71. package/src/lsp/client.js +111 -9
  72. package/src/lsp/servers.js +86 -22
  73. package/src/oneshot.js +325 -65
  74. package/src/pack/sbom.js +28 -5
  75. package/src/pack/selfpack.js +54 -5
  76. package/src/pack/sheet.en.js +24 -3
  77. package/src/pack/tar.js +45 -8
  78. package/src/pack/zip.js +103 -8
  79. package/src/plugins/manage.js +165 -14
  80. package/src/preview/serve.js +239 -23
  81. package/src/providers/bedrock.js +30 -5
  82. package/src/providers/gemini.js +8 -0
  83. package/src/providers/index.js +17 -1
  84. package/src/repl.js +254 -61
  85. package/src/report.js +7 -2
  86. package/src/reset.js +151 -25
  87. package/src/safety/audit.js +108 -8
  88. package/src/safety/authcmd.js +75 -5
  89. package/src/safety/guard.js +531 -15
  90. package/src/safety/hooks.js +172 -20
  91. package/src/safety/keystore.js +178 -18
  92. package/src/safety/network.js +209 -6
  93. package/src/safety/policy.js +377 -36
  94. package/src/safety/runmode.js +9 -2
  95. package/src/safety/secrets.js +243 -17
  96. package/src/safety/shellenv.js +64 -3
  97. package/src/safety/trust.js +298 -32
  98. package/src/safety/undo.js +409 -17
  99. package/src/setup.js +175 -17
  100. package/src/skills/discover.js +122 -19
  101. package/src/stats.js +57 -16
  102. package/src/tools/{/355/231/225/354/235/270/353/262/225.js → checkmethods.js} +26 -1
  103. package/src/tools/clipboard.js +53 -20
  104. package/src/tools/convert.js +118 -37
  105. package/src/tools/desc.en.js +48 -5
  106. package/src/tools/doc2md.js +36 -4
  107. package/src/tools/docs.js +94 -18
  108. package/src/tools/edit-match.js +212 -13
  109. package/src/tools/encoding.js +525 -28
  110. package/src/tools/excel-com.js +63 -15
  111. package/src/tools/excel.js +12 -7
  112. package/src/tools/fastgrep.js +501 -38
  113. package/src/tools/fig.js +21 -4
  114. package/src/tools/fsutil.js +235 -21
  115. package/src/tools/hwpxwrite.js +80 -19
  116. package/src/tools/ignore.js +55 -7
  117. package/src/tools/index.js +1083 -148
  118. package/src/tools/jobs.js +328 -50
  119. package/src/tools/kiwi.js +50 -5
  120. package/src/tools/lsp.js +164 -32
  121. package/src/tools/outline.js +52 -12
  122. package/src/tools/pdf.js +367 -59
  123. package/src/tools/shell.js +12 -4
  124. package/src/tools/spawn.js +203 -18
  125. package/src/tools/todo.js +73 -4
  126. package/src/tools/verify.js +328 -45
  127. package/src/tools/webfetch.js +194 -25
  128. package/src/tools/xlsx.js +232 -31
  129. package/src/ui/ansi.js +209 -15
  130. package/src/ui/banner.js +2 -1
  131. package/src/ui/complete.js +46 -4
  132. package/src/ui/diff.js +4 -2
  133. package/src/ui/export.js +91 -17
  134. package/src/ui/inputbox.js +58 -18
  135. package/src/ui/intro.js +9 -2
  136. package/src/ui/level.js +32 -7
  137. package/src/ui/md.js +101 -15
  138. package/src/ui/motion.js +5 -2
  139. package/src/ui/notify.js +7 -1
  140. package/src/ui/office.js +37 -5
  141. package/src/ui/pastechip.js +3 -2
  142. package/src/ui/pick.js +85 -12
  143. package/src/ui/prompt.js +176 -13
  144. package/src/ui/screen.js +2 -1
  145. package/src/ui/spinner.js +6 -1
  146. package/src/ui/status.js +80 -12
  147. package/src/ui/wrap.js +23 -37
  148. /package/src/agent/{/353/213/250/352/263/204.js" → phase.js} +0 -0
  149. /package/src/skills/builtin/{/352/271/212/354/235/264/354/236/210/352/262/214-/353/247/214/353/223/244/352/270/260/SKILL.md" → build-deep/SKILL.md} +0 -0
  150. /package/src/skills/builtin/{/354/260/250/352/267/274/354/260/250/352/267/274-/353/224/224/353/262/204/352/271/205/SKILL.md" → debug-step-by-step/SKILL.md} +0 -0
  151. /package/src/skills/builtin/{/353/201/235/352/271/214/354/247/200-/355/225/230/352/270/260/SKILL.md" → finish-all/SKILL.md} +0 -0
  152. /package/src/skills/builtin/{/354/212/244/354/212/244/353/241/234-/352/262/200/355/206/240/SKILL.md" → self-review/SKILL.md} +0 -0
  153. /package/src/skills/builtin/{/354/275/224/353/223/234-/354/244/204/354/235/264/352/270/260/SKILL.md" → simplify-code/SKILL.md} +0 -0
  154. /package/src/skills/builtin/{/354/260/224/353/237/254/353/263/264/352/270/260/SKILL.md" → spike/SKILL.md} +0 -0
  155. /package/src/skills/builtin/{/352/262/200/354/202/254-/353/250/274/354/240/200/SKILL.md" → test-first/SKILL.md} +0 -0
package/src/tools/pdf.js CHANGED
@@ -122,6 +122,9 @@ function 괄호글자읽기(b, i) {
122
122
  }
123
123
  if (c === 0x28) 깊이 += 1;
124
124
  if (c === 0x29) { 깊이 -= 1; if (!깊이) { i += 1; break; } }
125
+ // 역슬래시 없는 줄끝(CR · CRLF · LF)은 LF 하나로 읽는다 (PDF 규격 7.3.4.2 · 2.0.0 6회차 문서6좁).
126
+ // 그대로 실으면 윈도에서 만든 PDF 의 글에 CR 이 섞여, 줄이 두 번 바뀌거나 CR 이 글자로 남는다.
127
+ if (c === 0x0d) { 조각.push(0x0a); i += b[i + 1] === 0x0a ? 2 : 1; continue; }
125
128
  조각.push(c); i += 1;
126
129
  }
127
130
  return [{ 바이트: Buffer.from(조각) }, i];
@@ -263,13 +266,26 @@ function 사전읽기(b, i, 흐름풀기) {
263
266
  * 3. 걸러내기 (filters)
264
267
  * ──────────────────────────────────────────────────────────────────────── */
265
268
 
266
- /** 앞자리 예측(PNG predictor). xref 흐름과 그림에 쓴다. */
269
+ /**
270
+ * 앞자리 예측(PNG predictor). xref 흐름과 그림에 쓴다.
271
+ *
272
+ * @returns {Buffer|null} null 이면 **되돌릴 수 없는 자료다** — 빈 자료로 넘기지 않는다.
273
+ */
267
274
  function 예측되돌리기(자료, { 예측, 칸수, 색수, 비트 }) {
268
275
  if (!예측 || 예측 < 2) return 자료;
269
276
  if (예측 === 2) return 자료; // TIFF 예측 — 흔치 않고 xref 엔 안 쓴다
270
277
  const 한칸 = Math.ceil((색수 * 비트) / 8);
271
278
  const 한줄 = Math.ceil((색수 * 비트 * 칸수) / 8);
272
279
  const 줄수 = Math.floor(자료.length / (한줄 + 1));
280
+ /*
281
+ * 한 줄도 못 채우는 자료는 되돌린 것이 아니라 **못 되돌린 것**이다.
282
+ *
283
+ * 여태는 줄수 0 으로 빈 버퍼를 지어 ok 로 올려 보냈다. 그러면 그 쪽은
284
+ * 「글이 없는 쪽」 이 되어 스캔본과 구분이 안 되고, Columns 가 틀린 것뿐인데
285
+ * 사람은 원본을 의심한다. 게다가 쓰지도 않을 앞줄 버퍼를 먼저 잡았는데,
286
+ * Columns 가 큰 값이면 그것만으로 1GB 다 (규격이 허락하는 최대치다).
287
+ */
288
+ if (줄수 < 1) return null;
273
289
  const 나온것 = Buffer.alloc(줄수 * 한줄);
274
290
  let 앞줄 = Buffer.alloc(한줄);
275
291
  for (let r = 0; r < 줄수; r += 1) {
@@ -346,30 +362,69 @@ function 되풀이풀기(buf) {
346
362
  return Buffer.from(나온것);
347
363
  }
348
364
 
349
- /** zlib 머리가 깨진 흐름이 흔해서, 날것(raw)으로도 한 번 더 해 본다. */
365
+ /**
366
+ * zlib 머리가 깨진 흐름이 흔해서, 날것(raw)으로도 한 번 더 해 본다.
367
+ *
368
+ * @returns {{자료:Buffer, 잘림?:true}|null} 잘림 이 붙었으면 **끝까지 못 풀었다.**
369
+ */
350
370
  function 부풀리기(buf) {
351
- try { return inflateSync(buf); } catch { /* 아래로 */ }
352
- try { return inflateRawSync(buf); } catch { /* 아래로 */ }
371
+ const 틀 = { maxOutputLength: 흐름풀기상한 };
372
+ try { return { 자료: inflateSync(buf, 틀) }; } catch (e) { if (넘쳤나(e)) return { 넘침: true }; }
373
+ try { return { 자료: inflateRawSync(buf, 틀) }; } catch (e) { if (넘쳤나(e)) return { 넘침: true }; }
353
374
  // 앞머리에 쓰레기가 붙은 것들 — zlib 머리(0x78)를 찾아 다시.
375
+ const 머리자리 = [];
354
376
  for (let i = 1; i < Math.min(buf.length, 32); i += 1) {
355
377
  if (buf[i] === 0x78) {
356
- try { return inflateSync(buf.subarray(i)); } catch { /* 다음 */ }
378
+ 머리자리.push(i);
379
+ try { return { 자료: inflateSync(buf.subarray(i), 틀) }; } catch (e) { if (넘쳤나(e)) return { 넘침: true }; }
357
380
  }
358
381
  }
359
382
  /*
360
- * 끝이 잘린 흐름은 통째로 버리지 않는다.
383
+ * ── 반쯤 푼 것을 다 푼 척했다 ──────────────────────────────────────
361
384
  *
362
- * 마지막 쪽이 잘린 PDF 가 실제로 있다. 여기서 빈 것을 돌려주면 그 쪽이
363
- * 「글 없는 쪽」이 되어 스캔본과 구분이 안 된다. 부풀린 데까지는 건진다.
385
+ * 끝이 잘린 흐름을 통째로 버리지 않는 것은 그대로 둔다 — 마지막 쪽이 잘린
386
+ * PDF 가 실제로 있고, 여기서 빈 것을 돌려주면 그 쪽이 「글 없는 쪽」이 되어
387
+ * 스캔본과 구분이 안 된다. 부풀린 데까지는 건지는 것이 맞다.
388
+ *
389
+ * 틀린 것은 **건졌다는 말을 안 한 것**이다. Z_SYNC_FLUSH 로 반 토막을
390
+ * 받아 놓고 성공과 똑같은 모양으로 돌려주니, 위에서는 온전한 흐름과
391
+ * 구분할 길이 없었다. 그래서 표 절반이 없는 쪽이 「다 읽은 쪽」으로
392
+ * 올라갔고, 모델은 없는 줄 알고 답했다.
393
+ *
394
+ * 이제 지키는 규칙: 반 토막이면 반 토막이라고 같이 돌려준다.
364
395
  */
365
396
  try {
366
- return inflateSync(buf, { finishFlush: Z_SYNC_FLUSH });
367
- } catch { /* 아래로 */ }
397
+ return { 자료: inflateSync(buf, { ...틀, finishFlush: Z_SYNC_FLUSH }), 잘림: true };
398
+ } catch (e) { if (넘쳤나(e)) return { 넘침: true }; }
368
399
  try {
369
- return inflateRawSync(buf, { finishFlush: Z_SYNC_FLUSH });
370
- } catch { return null; }
400
+ return { 자료: inflateRawSync(buf, { ...틀, finishFlush: Z_SYNC_FLUSH }), 잘림: true };
401
+ } catch (e) { if (넘쳤나(e)) return { 넘침: true }; }
402
+ /*
403
+ * 앞머리 쓰레기와 끝 잘림은 겹쳐서 온다 — 둘 다 「저장하다 만 파일」의 흔적이다.
404
+ *
405
+ * 위 두 갈래(쓰레기머리 · 반 토막)를 따로따로만 해 봐서, 겹친 흐름은 반 토막도
406
+ * 못 건지고 null 로 버려졌다. 쓰레기머리 하나만 붙어도, 잘리기만 해도 건지던
407
+ * 흐름이 둘이 겹치면 통째로 사라지는 것은 앞뒤가 안 맞는다.
408
+ */
409
+ for (const i of 머리자리) {
410
+ try {
411
+ return { 자료: inflateSync(buf.subarray(i), { ...틀, finishFlush: Z_SYNC_FLUSH }), 잘림: true };
412
+ } catch (e) { if (넘쳤나(e)) return { 넘침: true }; }
413
+ }
414
+ return null;
371
415
  }
372
416
 
417
+ /*
418
+ * 흐름 하나를 풀어 담는 상한 (2.0.0 6회차 문서6).
419
+ *
420
+ * 상한 없이 풀었더니 4.6MB 짜리 PDF 한 장(1GiB 공백을 눌러 담은 흐름 하나)에 최고 RSS 가 2GB 를 넘었다 —
421
+ * 그런 흐름 몇 개면 deel 이 메모리 부족으로 죽는다. PDF 는 남이 보낸 바이트다. 글 흐름·글꼴 표·xref 는
422
+ * 이 크기에 한참 못 미친다. 넘으면 끝까지 풀지 않고 그 까닭을 쪽의 「못 읽은 까닭」 으로 올린다.
423
+ * (zip.js 의 풀기총상한 과 같은 생각이다.)
424
+ */
425
+ const 흐름풀기상한 = 64 * 1024 * 1024;
426
+ const 넘쳤나 = (e) => e?.code === 'ERR_BUFFER_TOO_LARGE';
427
+
373
428
  /** 못 푸는 걸개를 만나면 이름을 돌려준다 — 「왜 안 읽혔나」에 그대로 쓴다. */
374
429
  export function 흐름풀기(흐름, 풀기) {
375
430
  if (!흐름?.날것) return { ok: false, 왜: '흐름이 아닙니다' };
@@ -377,14 +432,33 @@ export function 흐름풀기(흐름, 풀기) {
377
432
  let 자료 = 흐름.날것;
378
433
  const 걸개들 = [].concat(풀기(사전.Filter) ?? []).map((f) => 풀기(f)).filter(Boolean);
379
434
  const 맞춤들 = [].concat(풀기(사전.DecodeParms) ?? 풀기(사전.DP) ?? []);
435
+ let 잘렸나 = false; // 끝까지 못 푼 흐름 — ok 로 돌려주더라도 이 표는 들고 간다
380
436
 
381
437
  for (let i = 0; i < 걸개들.length; i += 1) {
382
438
  const 이름 = 걸개들[i]?.이름;
383
- const 맞춤 = 풀기(맞춤들[i]) ?? (걸개들.length === 1 ? 풀기(맞춤들[0]) : null) ?? {};
439
+ /*
440
+ * DecodeParms 가 배열이 아니라 **사전 하나**로 적힌 파일이 흔하다.
441
+ *
442
+ * 걸개가 하나면 말이 되는데, 걸개가 둘일 때 그 사전을 자리(i)로 짝지어
443
+ * 첫 걸개에 붙이면 아직 눌려 있는 바이트에 예측을 되돌리게 되어 흐름이
444
+ * 통째로 깨졌다 — `/Filter [/ASCIIHexDecode /FlateDecode]` 에 예측 사전
445
+ * 하나를 적은 흔한 꼴이 그대로 「압축을 못 풀었습니다」가 됐다.
446
+ *
447
+ * 규격상 맞춤을 받는 걸개는 Flate·LZW 뿐이다(ASCII85·ASCIIHex·RunLength 는
448
+ * 받는 맞춤이 없다). 그러니 짝지을 수 없는 사전 하나는 **맞춤을 받는 걸개**
449
+ * 것이다.
450
+ */
451
+ const 맞춤받나 = 이름 === 'FlateDecode' || 이름 === 'Fl' || 이름 === 'LZWDecode' || 이름 === 'LZW';
452
+ const 짝 = 맞춤들.length < 걸개들.length ? (맞춤받나 ? 맞춤들[0] : null) : 맞춤들[i];
453
+ const 맞춤 = 풀기(짝) ?? {};
384
454
  if (이름 === 'FlateDecode' || 이름 === 'Fl') {
385
455
  const 푼것 = 부풀리기(자료);
456
+ if (푼것?.넘침) {
457
+ return { ok: false, 왜: `풀면 ${흐름풀기상한 / 1024 / 1024}MB 를 넘는 흐름이라 풀지 않았습니다 — 압축 폭탄일 수 있습니다` };
458
+ }
386
459
  if (!푼것) return { ok: false, 왜: '압축을 못 풀었습니다' };
387
- 자료 = 푼것;
460
+ if (푼것.잘림) 잘렸나 = true;
461
+ 자료 = 푼것.자료;
388
462
  } else if (이름 === 'ASCII85Decode' || 이름 === 'A85') 자료 = 아스키85풀기(자료);
389
463
  else if (이름 === 'ASCIIHexDecode' || 이름 === 'AHx') 자료 = 아스키16풀기(자료);
390
464
  else if (이름 === 'RunLengthDecode' || 이름 === 'RL') 자료 = 되풀이풀기(자료);
@@ -395,15 +469,28 @@ export function 흐름풀기(흐름, 풀기) {
395
469
  }
396
470
  const 예측 = 풀기(맞춤?.Predictor);
397
471
  if (예측 && 예측 > 1) {
398
- 자료 = 예측되돌리기(자료, {
399
- 예측,
400
- 칸수: 풀기(맞춤.Columns) ?? 1,
401
- 색수: 풀기(맞춤.Colors) ?? 1,
402
- 비트: 풀기(맞춤.BitsPerComponent) ?? 8,
403
- });
472
+ const 칸수 = 풀기(맞춤.Columns) ?? 1;
473
+ const 색수 = 풀기(맞춤.Colors) ?? 1;
474
+ const 비트 = 풀기(맞춤.BitsPerComponent) ?? 8;
475
+ /*
476
+ * 남이 보낸 수를 그대로 크기로 쓰지 않는다. Columns 가 이름(`/x`)이거나 Colors 가 음수면
477
+ * 예측되돌리기 의 `Buffer.alloc` 이 RangeError 를 던졌고, 그게 readPdf **밖으로** 나와
478
+ * 멀쩡한 다른 쪽까지 통째로 못 읽었다. 규격이 허락하는 값이 아니면 이 흐름만 못 푼 것이다.
479
+ * (6회차 Gemini 피디에프6c 를 재다 찾음)
480
+ */
481
+ const 성한수 = (n, 큰것) => Number.isInteger(n) && n >= 1 && n <= 큰것;
482
+ if (!성한수(칸수, 1 << 24) || !성한수(색수, 32) || ![1, 2, 4, 8, 16].includes(비트)) {
483
+ const 보임 = (v) => (v && typeof v === 'object' && v.이름 ? `/${v.이름}` : String(v));
484
+ return { ok: false, 왜: `예측 설정(DecodeParms)이 망가져 풀지 않았습니다 — Columns ${보임(칸수)} · Colors ${보임(색수)} · BitsPerComponent ${보임(비트)}` };
485
+ }
486
+ const 편자료 = 예측되돌리기(자료, { 예측, 칸수, 색수, 비트 });
487
+ if (!편자료) {
488
+ return { ok: false, 왜: `예측(Predictor ${예측})을 되돌릴 자료가 모자랍니다 — ${자료.length}바이트로는 한 줄(${Math.ceil((색수 * 비트 * 칸수) / 8) + 1}바이트)도 못 채웁니다` };
489
+ }
490
+ 자료 = 편자료;
404
491
  }
405
492
  }
406
- return { ok: true, 자료 };
493
+ return 잘렸나 ? { ok: true, 자료, 잘림: true } : { ok: true, 자료 };
407
494
  }
408
495
 
409
496
  /* ────────────────────────────────────────────────────────────────────────
@@ -425,7 +512,8 @@ class 문서 {
425
512
  this.자리 = new Map(); // 객체번호 → 파일 위치
426
513
  this.푼것 = new Map(); // 객체번호 → 읽어 둔 값
427
514
  this.묶음속 = new Map(); // 객체번호 → ObjStm 안에서 읽은 값
428
- this.xref정함 = new Set(); // 최신 xref 가 자리를 정해 준 객체 (옛 판이 못 덮는다)
515
+ this.xref정함 = new Set(); // 최신 xref 가 「낱개로 여기 있다」 고 정해 준 객체
516
+ this.묶음자리 = new Map(); // 객체번호 → 그 객체가 든 ObjStm 번호 (최신 xref 가 정한 것)
429
517
  this.trailer = {};
430
518
  this.훑기();
431
519
  this.xref읽기();
@@ -453,16 +541,34 @@ class 문서 {
453
541
  /**
454
542
  * xref 가 말하는 자리를 받아 적는다.
455
543
  *
456
- * xref 는 **최신 판부터** 거슬러 읽는다(startxref → /Prev). 그래서 한 번
457
- * 정해진 객체를 나중에(= 더 옛 판에서) 다시 적으면 안 된다. 증분 저장한
458
- * PDF 는 고친 객체가 파일 뒤에 새로 붙고 옛것이 그대로 남아 있는데, 옛
459
- * 자리로 덮으면 **고치기 전 값을 읽는다** — 폼에 채운 값이 빈칸으로,
544
+ * ── 어느 판(generation)이 이기나 — 규칙 하나 ──────────────────────────
545
+ *
546
+ * **최신 판이 이긴다.** 최신이 어느 것인지는 둘로 안다.
547
+ *
548
+ * · xref 가 그 객체를 말하면 — xref 는 **최신 판부터** 거슬러 읽으니
549
+ * (startxref → /Prev) **먼저 정해진 것**이 최신이다. 낱개든 묶음(ObjStm)
550
+ * 이든 한 번 정해지면 더 옛 판이 못 덮는다.
551
+ * · 최신 xref 가 그 객체를 모르면 — 증분 저장은 파일 **뒤에** 붙으니
552
+ * 최신 xref 보다 뒤에 있는 진짜 객체가 나중 판이다.
553
+ *
554
+ * 옛 자리로 덮으면 **고치기 전 값을 읽는다** — 폼에 채운 값이 빈칸으로,
460
555
  * 고친 금액이 옛 금액으로 돌아간다. 빈 글은 보이지만 옛 글은 안 보인다.
556
+ * 이 규칙은 여기와 xref흐름읽기 · 묶음펼치기 세 자리가 **같이** 지켜야 한다.
461
557
  */
462
558
  자리정하기(번호, 자리) {
463
559
  if (this.xref정함.has(번호)) return;
560
+ /*
561
+ * 최신 xref 가 「이 객체는 묶음 안에 있다」 고 정한 것을 더 옛 판의 낱개
562
+ * 자리가 덮으면 안 된다. 자리는 안전망으로 적어만 둔다 — 묶음을 못 펼치면
563
+ * 그거라도 읽어야 하니까. 읽을 때는 묶음 쪽을 먼저 본다(풀기).
564
+ */
565
+ if (this.묶음자리.has(번호)) {
566
+ if (!this.자리.has(번호)) this.자리.set(번호, 자리);
567
+ return;
568
+ }
464
569
  // 훑기가 찾은 자리를 못 믿을 때만 xref 를 쓴다. 둘 다 있으면 그 자리에
465
- // 진짜 `N obj` 가 있는 쪽을 고른다.
570
+ // 진짜 `N obj` 가 있는 쪽을 고른다. (xref 가 아예 모르는 객체는 아래
571
+ // 덧붙은판되찾기 가 xref 를 다 읽은 뒤에 되돌린다.)
466
572
  if (this.진짜객체인가(자리, 번호)) {
467
573
  this.자리.set(번호, 자리);
468
574
  this.xref정함.add(번호);
@@ -473,22 +579,29 @@ class 문서 {
473
579
 
474
580
  /** ② xref 를 따라가며 trailer 를 모은다. */
475
581
  xref읽기() {
582
+ const 훑은자리 = new Map(this.자리); // xref 가 덮기 전의 훑기 결과
476
583
  const s = this.b.toString('latin1');
477
584
  const 끝 = s.lastIndexOf('startxref');
478
585
  let 자리 = 끝 >= 0 ? Number((s.slice(끝 + 9, 끝 + 40).match(/\d+/) ?? [])[0]) : NaN;
479
586
  const 봤다 = new Set();
480
587
  let 걸음 = 0;
588
+ let xref끝 = -1; // 본 xref 칸 중 파일에서 가장 뒤에 있는 것
481
589
  while (Number.isInteger(자리) && 자리 >= 0 && 자리 < this.b.length && !봤다.has(자리) && 걸음 < 64) {
482
590
  봤다.add(자리); 걸음 += 1;
591
+ if (자리 > xref끝) xref끝 = 자리;
483
592
  const t = this.xref한칸(자리);
484
593
  if (!t) break;
485
594
  // 먼저 만난 것이 최신이다 — 이미 있는 열쇠는 안 덮는다.
486
595
  for (const [k, v] of Object.entries(t)) if (!(k in this.trailer)) this.trailer[k] = v;
487
596
  const 다음 = typeof t.Prev === 'number' ? t.Prev : null;
488
597
  // 혼합 파일(XRefStm)은 표와 흐름이 같이 있다.
489
- if (typeof t.XRefStm === 'number' && !봤다.has(t.XRefStm)) this.xref한칸(t.XRefStm);
598
+ if (typeof t.XRefStm === 'number' && !봤다.has(t.XRefStm)) {
599
+ if (t.XRefStm > xref끝) xref끝 = t.XRefStm;
600
+ this.xref한칸(t.XRefStm);
601
+ }
490
602
  자리 = 다음;
491
603
  }
604
+ this.덧붙은판되찾기(훑은자리, xref끝);
492
605
  if (!this.trailer.Root) {
493
606
  // trailer 를 못 찾았으면 카탈로그를 직접 찾는다.
494
607
  // `[^>]` 로 막으면 카탈로그 앞에 속사전(`<< … >>`)이 하나라도 있으면
@@ -499,6 +612,30 @@ class 문서 {
499
612
  }
500
613
  }
501
614
 
615
+ /**
616
+ * **어떤 xref 도 모르는** 객체를 되찾는다 — 마지막 xref 보다 뒤에 붙은 것들.
617
+ *
618
+ * 증분 저장은 고친 객체를 파일 뒤에 덧붙이는데, 새 xref 를 못 붙이고 끊긴
619
+ * 파일이 있다(저장하다 멈춘 것, 잘라 붙인 것). 그때 xref 는 옛 자리만 알고,
620
+ * 코드는 그 자리에 진짜 객체가 있다는 이유로 무조건 덮어써 **고치기 전 값**을
621
+ * 읽었다 — 「NEW price 2000」 자리에 「OLD price 1000」 이 나오는데 아무 말도 없다.
622
+ *
623
+ * 자리 한 칸씩 비교하지 않고 **xref 를 다 읽은 뒤** 한 번에 보는 것이 요령이다.
624
+ * 선형화(linearized)한 파일은 맨 끝 startxref 가 **파일 앞머리**의 첫쪽 xref 를
625
+ * 가리켜서, 「최신 xref 보다 뒤면 새 판」 으로 재면 거의 모든 객체가 걸린다.
626
+ * 그러면 본문에 `3 0 obj` 라고 적힌 문서를 xref 가 바로잡아 주던 길이 막힌다.
627
+ * 여기서 견주는 것은 **본 xref 칸 중 가장 뒤엣것**이라 그 함정을 안 밟는다.
628
+ */
629
+ 덧붙은판되찾기(훑은자리, xref끝) {
630
+ if (!(xref끝 >= 0)) return;
631
+ for (const [번호, 자리] of 훑은자리) {
632
+ if (자리 <= xref끝 || !this.진짜객체인가(자리, 번호)) continue;
633
+ this.자리.set(번호, 자리); this.xref정함.add(번호);
634
+ this.묶음자리.delete(번호); // 뒤에 낱개로 붙은 것이 묶음보다 새 판이다
635
+ this.푼것.delete(번호); // 옛 자리로 읽어 둔 것이 있으면 버린다
636
+ }
637
+ }
638
+
502
639
  /** xref 한 칸 — 고전 표이거나 흐름이다. trailer 사전을 돌려준다. */
503
640
  xref한칸(자리) {
504
641
  const 앞 = 건너뛰기(this.b, 자리);
@@ -576,10 +713,9 @@ class 문서 {
576
713
  }
577
714
  const 갈래 = 밭[0] === null ? 1 : 밭[0];
578
715
  if (갈래 === 1) this.자리정하기(번호, 밭[1]);
579
- else if (갈래 === 2 && !this.xref정함.has(번호)) {
580
- // 여기도 최신 판이 이긴다 — 옛 ObjStm 이 고친 객체를 덮으면 안 된다.
581
- this.묶음자리 = this.묶음자리 ?? new Map();
582
- if (!this.묶음자리.has(번호)) this.묶음자리.set(번호, 밭[1]);
716
+ else if (갈래 === 2 && !this.xref정함.has(번호) && !this.묶음자리.has(번호)) {
717
+ // 여기도 같은 규칙이다 — 먼저 정해진 판이 최신이니 옛 ObjStm 이 못 덮는다.
718
+ this.묶음자리.set(번호, 밭[1]);
583
719
  }
584
720
  번호 += 1;
585
721
  }
@@ -597,12 +733,24 @@ class 문서 {
597
733
 
598
734
  /** ③ 객체 흐름을 펼친다. 여기 든 객체는 파일에 낱개로 없다. */
599
735
  묶음펼치기() {
600
- const 볼것 = new Set(this.묶음자리 ? [...this.묶음자리.values()] : []);
601
- // 훑기로 찾은 것 중 ObjStm 도 마저 본다 (xref 가 없거나 틀린 파일).
736
+ /*
737
+ * 볼 차례가 곧 판 차례다 — 먼저 담은 값을 안 덮으니, **최신을 먼저** 봐야 한다.
738
+ *
739
+ * ① 최신 xref 가 짚어 준 묶음 (거슬러 읽었으니 앞이 최신이다)
740
+ * ② 훑기로 찾은 묶음 — 이건 xref 를 못 믿는 파일이다. 그때 최신을 가르는
741
+ * 것은 파일 안 자리뿐이니 **뒤에 있는 것부터** 본다 (증분 저장은 뒤에 붙는다).
742
+ *
743
+ * 앞서는 ②를 훑기가 만난 차례(= 대개 옛것부터)로 봐서, 옛 묶음이 먼저
744
+ * 들어가 새 묶음의 고친 값을 막았다.
745
+ */
746
+ const 볼것 = new Set(this.묶음자리.values());
747
+ const 훑은묶음 = [];
602
748
  for (const [번호] of this.자리) {
603
749
  const v = this.낱개읽기(번호);
604
- if (v?.사전?.Type?.이름 === 'ObjStm') 볼것.add(번호);
750
+ if (v?.사전?.Type?.이름 === 'ObjStm') 훑은묶음.push(번호);
605
751
  }
752
+ 훑은묶음.sort((a, b) => (this.자리.get(b) ?? 0) - (this.자리.get(a) ?? 0));
753
+ for (const 번호 of 훑은묶음) 볼것.add(번호);
606
754
  for (const 번호 of 볼것) {
607
755
  const 흐름 = this.낱개읽기(번호);
608
756
  if (!흐름?.날것) continue;
@@ -829,14 +977,19 @@ export function 유니코드표읽기(글) {
829
977
  for (const p of 몸.matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g)) {
830
978
  const 시작 = 열여섯값(p[1]);
831
979
  const 끝 = 열여섯값(p[2]);
832
- const 첫 = 열여섯글자(p[3]);
833
980
  코드폭.add(Math.ceil(p[1].length / 2));
834
981
  if (시작 === null || 끝 === null || 끝 < 시작 || 끝 - 시작 > 65535) continue;
835
- if (!첫) continue; // `<0000>` 에서 시작하는 범위 — 셀 밑자리가 없다
836
- const 밑 = 첫.charCodeAt(첫.length - 1);
982
+ /*
983
+ * 밑자리는 **마지막 네 자리 16진 값**에서 바로 읽는다 (2.0.0 6회차 문서6). 글로 바꾼 뒤 끝 글자를
984
+ * 봤더니 `<0000>` 은 NUL 을 빼느라 빈 글이 되어 범위를 통째로 건너뛰었다 — `<0000> <00FF> <0000>`
985
+ * 같은 0 기준 표를 쓰는 글꼴은 글이 한 자도 안 나왔다. 0 번 글자(NUL)는 여전히 안 싣는다.
986
+ */
987
+ const 날 = p[3].length % 4 ? p[3].padStart(p[3].length + 4 - (p[3].length % 4), '0') : p[3];
988
+ const 앞글 = 열여섯글자(날.slice(0, -4));
989
+ const 밑 = parseInt(날.slice(-4), 16);
837
990
  for (let k = 0; k <= 끝 - 시작; k += 1) {
838
- if (표.has(시작 + k)) continue;
839
- 표.set(시작 + k, 첫.slice(0, -1) + String.fromCharCode(밑 + k));
991
+ if (표.has(시작 + k) || 밑 + k === 0) continue;
992
+ 표.set(시작 + k, 앞글 + String.fromCharCode(밑 + k));
840
993
  }
841
994
  }
842
995
  }
@@ -917,16 +1070,14 @@ function 글꼴만들기(문, 글꼴사전) {
917
1070
  const 너비 = 너비표읽기(문, 안, 두바이트);
918
1071
 
919
1072
  // ① /ToUnicode 가 있으면 그것이 가장 정확하다.
920
- let 표 = new Map();
921
1073
  let 폭 = 두바이트 ? 2 : 1;
922
1074
  const 투 = 문.풀기(안.ToUnicode);
923
1075
  if (투?.날것) {
924
1076
  const r = 흐름풀기(투, (x) => 문.풀기(x));
925
1077
  if (r.ok) {
926
1078
  const 읽은것 = 유니코드표읽기(r.자료.toString('latin1'));
927
- 표 = 읽은것.표;
928
1079
  if (읽은것.폭) 폭 = 읽은것.폭;
929
- if (표.size) return { 폭, 표, 읽나: true, 너비 };
1080
+ if (읽은것.표.size) return { 폭, 표: 읽은것.표, 읽나: true, 너비 };
930
1081
  }
931
1082
  }
932
1083
 
@@ -1075,6 +1226,7 @@ function 흐름에서글(문, 자료, 자원, 깊이 = 0) {
1075
1226
  let 앞y = null;
1076
1227
  let 앞x = null;
1077
1228
  let 펜x = null; // 붓이 지금 어디까지 갔다고 보는가 (자리바뀜 참고)
1229
+ let BT뒤그대로 = false; // BT 로 자리를 (0,0) 에 되돌린 뒤 아직 안 옮겼다 (아래 BT 머리말)
1078
1230
  let 못읽은코드 = 0;
1079
1231
  let 글낸적 = false;
1080
1232
  let 그림낸적 = false;
@@ -1114,6 +1266,7 @@ function 흐름에서글(문, 자료, 자원, 깊이 = 0) {
1114
1266
  if (틈 > 0.25 * 글꼴크기 * Math.abs(Tm[0] || 1)) 이번줄 += ' ';
1115
1267
  }
1116
1268
  앞y = y; 앞x = x; 펜x = x;
1269
+ BT뒤그대로 = false;
1117
1270
  };
1118
1271
 
1119
1272
  const 글쓰기 = (바이트들) => {
@@ -1158,8 +1311,12 @@ function 흐름에서글(문, 자료, 자원, 깊이 = 0) {
1158
1311
  * 「Total 1,000,000 won」 이 세 줄로 쪼개진다. 줄이 바뀌었는지는 ET 가
1159
1312
  * 아니라 **세로로 움직였는지**가 말해 준다 — 그래서 앞자리를 그대로
1160
1313
  * 들고 다음 덩이의 첫 자리와 견준다.
1314
+ *
1315
+ * BT 는 글 자리도 (0,0) 으로 되돌린다. 거기서 Td·Tm 없이 곧장 찍으면 그 자리를 앞 덩이와 안
1316
+ * 견줘서 앞 글 끝에 붙었다 — 「Hello」 다음 줄의 「World」 가 「HelloWorld」 (2.0.0 6회차 문서6).
1317
+ * 그래서 BT 뒤 첫 찍기가 자리를 안 옮겼으면 그때 한 번 견준다. Td 가 오면 거기서 이미 견줬다.
1161
1318
  */
1162
- case 'BT': Tm = [1, 0, 0, 1, 0, 0]; Tlm = Tm.slice(); break;
1319
+ case 'BT': Tm = [1, 0, 0, 1, 0, 0]; Tlm = Tm.slice(); BT뒤그대로 = true; break;
1163
1320
  case 'ET': break;
1164
1321
  case 'TL': TL = Number(인자[0]) || 0; break;
1165
1322
  case 'Td': {
@@ -1193,6 +1350,7 @@ function 흐름에서글(문, 자료, 자원, 깊이 = 0) {
1193
1350
  }
1194
1351
  case 'Tj': case 'TJ': case "'": case '"': {
1195
1352
  if (op === "'" || op === '"') { Tlm = 곱하기([1, 0, 0, 1, 0, -TL], Tlm); Tm = Tlm.slice(); 자리바뀜(); }
1353
+ if (BT뒤그대로) 자리바뀜();
1196
1354
  const 몫 = op === '"' ? 인자[2] : 인자[인자.length - 1];
1197
1355
  if (op === 'TJ') {
1198
1356
  const 배열 = Array.isArray(몫) ? 몫 : [];
@@ -1293,10 +1451,58 @@ export function readPdf(경로또는버퍼) {
1293
1451
  const 쪽나무 = 문.꺼내기(뿌리?.사전 ?? 뿌리, 'Pages');
1294
1452
  let 쪽들 = [];
1295
1453
  if (쪽나무) 쪽모으기(문, 쪽나무, {}, 쪽들, new Set());
1296
- if (!쪽들.length) 쪽들 = 쪽줍기(문);
1454
+ /*
1455
+ * ── 「3쪽」 이라고 적어 놓고 그 3 을 우리도 모를 때 ────────────────────
1456
+ *
1457
+ * 사람이 본 것 —
1458
+ *
1459
+ * ◧ Read(계약서.pdf) → pdf · 12쪽
1460
+ * --- 3쪽 ---
1461
+ * 제7조 (하자담보책임) …
1462
+ *
1463
+ * 「3쪽에 하자담보 조항이 있다」 고 답이 나갔다. 실제 문서 3쪽은 다른
1464
+ * 조항이었다. 근거로 댄 쪽 번호가 틀린 답은 안 읽은 것보다 나쁘다 —
1465
+ * 사람이 그 쪽을 펴 보고 나서야 어긋난 걸 안다.
1466
+ *
1467
+ * 속에서 벌어진 일 — 쪽 나무(/Root → /Pages)를 못 걸으면(xref 가 깨졌거나
1468
+ * 덧붙여 저장한 파일) 파일에 있는 /Type /Page 를 **객체 번호 순으로** 줍는다.
1469
+ * 객체 번호는 쪽 차례와 아무 상관이 없다. 선형화(linearized)한 파일은 첫 쪽을
1470
+ * 맨 뒤 번호로 두기도 하고, 덧붙여 저장하면 고친 쪽만 큰 번호로 붙는다.
1471
+ * 그렇게 주운 차례에 순번+1 로 「N쪽」 이라는 **또렷한 번호를 찍어 놨다.**
1472
+ * 못읽은쪽에도, 요약에도 아무 표가 안 났으니 누구도 의심할 수 없었다.
1473
+ *
1474
+ * 이제 지키는 규칙: 주워서 세운 차례면 「차례를 확인 못 했다」고 말한다.
1475
+ * 번호를 안 붙일 수는 없다(쪽을 가리킬 말이 없어진다). 대신 그 번호가
1476
+ * 문서의 번호라고 **믿게 두지는 않는다.**
1477
+ */
1478
+ let 쪽차례모름 = false;
1479
+ if (!쪽들.length) {
1480
+ 쪽들 = 쪽줍기(문);
1481
+ 쪽차례모름 = 쪽들.length > 0;
1482
+ }
1297
1483
  if (!쪽들.length) {
1298
1484
  return { ok: false, error: 'PDF 안에서 쪽을 못 찾았습니다 — 파일이 깨졌을 수 있습니다.' };
1299
1485
  }
1486
+ /*
1487
+ * ── 나무가 **반만** 걸렸을 때 ──────────────────────────────────────────
1488
+ *
1489
+ * 위 갈래는 나무를 **하나도** 못 걸었을 때만 선다. /Kids 셋 중 둘이 끊긴 파일
1490
+ * (덧붙여 저장하다 끊긴 계약서·서명 PDF 에 흔하다)에서는 한 쪽만 건지고도
1491
+ * 못읽은쪽 0 · 「pdf · 1쪽」 으로 올라갔다 — 나머지 두 쪽이 「없는 것」 이 되고,
1492
+ * 모델은 한 쪽으로 문서 전체를 판단한다. 바로 위 머리말이 예순 줄에 걸쳐
1493
+ * 경계하는 그 고장인데, 쪽 **차례**만 고치고 쪽 **수**는 안 봤다.
1494
+ *
1495
+ * 문서는 제 입으로 몇 쪽인지 적어 둔다(/Pages 의 /Count). 그 숫자를 pdf.js
1496
+ * 어디서도 안 읽고 있었다. 우리가 건진 것보다 많으면 그 차이를 적는다.
1497
+ * 적힌 수가 더 적은 판(덧붙여 저장하며 /Count 를 안 고친 파일)은 안 적는다 —
1498
+ * 그건 우리가 더 찾은 것이라 잃은 것이 없다.
1499
+ *
1500
+ * 주워 온 차례(쪽차례모름)에는 안 잰다. 그때는 이미 「차례를 못 믿는다」 고
1501
+ * 말하고 있고, /Count 와 견주면 같은 흠을 두 번 말하게 된다.
1502
+ */
1503
+ const 적힌쪽수 = Number(문.꺼내기(쪽나무?.사전 ?? 쪽나무, 'Count'));
1504
+ const 못건진쪽 = (!쪽차례모름 && Number.isFinite(적힌쪽수) && 적힌쪽수 > 쪽들.length)
1505
+ ? 적힌쪽수 - 쪽들.length : 0;
1300
1506
 
1301
1507
  const 덩이들 = [];
1302
1508
  const 못읽은쪽 = [];
@@ -1314,13 +1520,41 @@ export function readPdf(경로또는버퍼) {
1314
1520
  return;
1315
1521
  }
1316
1522
 
1523
+ /*
1524
+ * ── 반만 읽은 쪽을 다 읽은 쪽으로 세었다 ───────────────────────────
1525
+ *
1526
+ * 사람이 본 것 —
1527
+ *
1528
+ * ◧ Read(사양서.pdf) → pdf · 8쪽 (못 읽은 쪽: 없음)
1529
+ * --- 4쪽 ---
1530
+ * 2. 적용 범위
1531
+ *
1532
+ * 4쪽에 있던 조건표가 통째로 없는데 「4쪽은 다 읽었다」고 나갔다.
1533
+ * 모델은 "그런 조건은 문서에 없습니다" 라고 답했다. 없는 것이 아니라
1534
+ * 못 꺼낸 것이었다.
1535
+ *
1536
+ * 속에서 벌어진 일 — 한 쪽의 /Contents 는 흐름 **여러 개**일 수 있다.
1537
+ * 그중 하나가 못 푸는 걸개(LZWDecode·CCITTFaxDecode)거나 참조가 끊겼거나
1538
+ * 끝이 잘려 있으면 막힌걸개에 까닭을 적고 넘어갔는데, 그 까닭을 쓰는 자리가
1539
+ * **자료들이 아예 빈 경우뿐**이었다. 하나만 살아나면 그 쪽은 온전한 쪽이
1540
+ * 됐다. 글이 한 줄이라도 나왔는지(글있나)만 봤으니 표가 날 데가 없었다.
1541
+ *
1542
+ * 바로 아래(글리프를 못 되돌린 쪽)는 이미 `일부: true` 로 정직하게 적고
1543
+ * 있었다. 같은 정직이 여기만 빠져 있었다.
1544
+ *
1545
+ * 이제 지키는 규칙: 흐름 하나라도 막혔으면, 글이 나왔어도 그 쪽은 일부다.
1546
+ */
1317
1547
  const 자료들 = [];
1318
1548
  let 막힌걸개 = null;
1319
1549
  for (const c of 조각들) {
1320
1550
  const 하나 = 문.풀기(c);
1321
- if (!하나?.날것) continue;
1551
+ if (!하나?.날것) {
1552
+ 막힌걸개 = 막힌걸개 ?? '내용 흐름 하나를 못 찾았습니다 (참조가 끊겼습니다)';
1553
+ continue;
1554
+ }
1322
1555
  const r = 흐름풀기(하나, (x) => 문.풀기(x));
1323
1556
  if (!r.ok) { 막힌걸개 = 막힌걸개 ?? r.왜; continue; }
1557
+ if (r.잘림) 막힌걸개 = 막힌걸개 ?? '내용 흐름의 끝이 잘렸습니다 (압축이 끝나기 전에 파일이 끝났습니다)';
1324
1558
  자료들.push(r.자료);
1325
1559
  자료들.push(Buffer.from('\n'));
1326
1560
  }
@@ -1353,28 +1587,45 @@ export function readPdf(경로또는버퍼) {
1353
1587
  * 머리말 로고가 위쪽 자원에 물려 있으면 정말 빈 쪽도 「OCR 이 필요」
1354
1588
  * 가 됐다. 그린 것을 본 쪽은 파서다.
1355
1589
  */
1590
+ /*
1591
+ * 막힌 흐름이 있었으면 **그 까닭이 먼저다.**
1592
+ *
1593
+ * 여태는 여기서 막힌걸개 를 통째로 버리고 「글이 없는 쪽입니다」 로 덮었다.
1594
+ * 흐름 둘 중 하나가 LZW 라 막히고 남은 하나엔 글이 없었을 뿐인데, 사람에게는
1595
+ * 스캔본으로 보였다 — 그러면 있지도 않은 OCR 을 찾으러 간다. 자료들이 아예
1596
+ * 빈 경우(바로 위)는 이미 까닭을 적고 있었다. 같은 정직이 여기만 빠져 있었다.
1597
+ */
1356
1598
  const 그림있나 = 뽑은것.그림낸적;
1599
+ const 까닭들 = [
1600
+ 막힌걸개,
1601
+ 뽑은것.못읽은코드 ? '글꼴에 /ToUnicode 표가 없어 글자를 되돌릴 수 없습니다' : null,
1602
+ ].filter(Boolean);
1357
1603
  못읽은쪽.push({
1358
1604
  번호,
1359
- 왜: 뽑은것.못읽은코드
1360
- ? '글꼴에 /ToUnicode 표가 없어 글자를 되돌릴 수 없습니다'
1605
+ 왜: 까닭들.length
1606
+ ? 까닭들.join(' · ')
1361
1607
  : (그림있나 ? '글이 없는 쪽입니다 (스캔한 사진일 수 있습니다 — OCR 이 필요합니다)' : '글이 없는 쪽입니다'),
1362
1608
  });
1363
1609
  덩이들.push({ 이름: `${번호}쪽`, 문단들: [] });
1364
1610
  return;
1365
1611
  }
1366
1612
 
1367
- if (뽑은것.못읽은코드 > 0) {
1368
- 못읽은쪽.push({
1369
- 번호,
1370
- 왜: `${뽑은것.못읽은코드}자를 못 되돌렸습니다 (글꼴 표에 없는 글리프) — 이 쪽은 일부만 읽었습니다`,
1371
- 일부: true,
1372
- });
1613
+ // 막힌 흐름과 못 되돌린 글리프는 둘 다 「일부」다. 한 쪽에 한 줄로 적는다 —
1614
+ // 같은 번호를 두 번 올리면 「몇 쪽이 성한가」 를 세는 쪽에서 셈이 어긋난다.
1615
+ if (막힌걸개 || 뽑은것.못읽은코드 > 0) {
1616
+ const 까닭 = [
1617
+ 막힌걸개,
1618
+ 뽑은것.못읽은코드 > 0 ? `${뽑은것.못읽은코드}자를 못 되돌렸습니다 (글꼴 표에 없는 글리프)` : null,
1619
+ ].filter(Boolean).join(' · ');
1620
+ // 「일부만 읽었다」 는 말은 여기 적지 않는다 — 일부:true 를 보고 말하는
1621
+ // 자리(toText·못읽은말·summarize)가 저마다 제 말투로 붙인다. 여기까지
1622
+ // 적어 두었더니 toText 에서 앞뒤로 두 번 붙어 한 줄에 같은 말이 겹쳤다.
1623
+ 못읽은쪽.push({ 번호, 왜: 까닭, 일부: true });
1373
1624
  }
1374
1625
  덩이들.push({ 이름: `${번호}쪽`, 문단들: 뽑은것.줄들 });
1375
1626
  });
1376
1627
 
1377
- return { ok: true, 갈래: 'pdf', 판, 쪽수: 쪽들.length, 덩이들, 못읽은쪽 };
1628
+ return { ok: true, 갈래: 'pdf', 판, 쪽수: 쪽들.length, 덩이들, 못읽은쪽, 쪽차례모름, 못건진쪽, 적힌쪽수: Number.isFinite(적힌쪽수) ? 적힌쪽수 : null };
1378
1629
  }
1379
1630
 
1380
1631
  /**
@@ -1399,6 +1650,12 @@ export function toText(r, { maxChars = 최대글자 } = {}) {
1399
1650
  }
1400
1651
  for (const 문단 of d.문단들) {
1401
1652
  if (셈 + 문단.length > maxChars) {
1653
+ // 남은 만큼은 싣는다 (2.0.0 6회차 문서6). 통째로 버렸더니 첫 문단이 긴 쪽은 「N자에서 잘랐습니다」 와
1654
+ // 머리말만 나가고 본문이 한 자도 없었다. 끝이 서로게이트 앞쪽이면 그 한 자는 뗀다.
1655
+ let 앞 = 문단.slice(0, Math.max(0, maxChars - 셈));
1656
+ const 끝코드 = 앞.charCodeAt(앞.length - 1);
1657
+ if (끝코드 >= 0xd800 && 끝코드 <= 0xdbff) 앞 = 앞.slice(0, -1);
1658
+ if (앞) 조각.push(앞);
1402
1659
  잘림.push(`${maxChars.toLocaleString('en-US')}자에서 잘랐습니다 — 뒷부분은 안 실렸습니다`);
1403
1660
  return { text: 조각.join('\n'), 잘림 };
1404
1661
  }
@@ -1413,8 +1670,23 @@ export function toText(r, { maxChars = 최대글자 } = {}) {
1413
1670
  /** 한 줄 요약. Read 의 summary 자리로 간다. */
1414
1671
  export function summarize(r) {
1415
1672
  if (!r?.ok) return '';
1673
+ // 쪽 차례를 못 세운 것은 「못 읽은 쪽」과 다른 종류의 흠이라 따로 붙인다.
1674
+ // 글은 다 나왔는데 번호만 못 믿는 경우가 있어서, 못 읽은 쪽이 없어도 떠야 한다.
1675
+ const 차례 = r.쪽차례모름 ? ' · 쪽 차례 확인 못 함' : '';
1676
+ // 문서가 적어 둔 쪽 수(/Count)보다 적게 건졌으면 그 차이를 요약에 올린다.
1677
+ // 「pdf · 1쪽」 은 온전한 한 쪽짜리 문서와 글자 하나 다르지 않다.
1678
+ const 못건짐 = r.못건진쪽 ? ` · ${r.못건진쪽}쪽 못 건짐` : '';
1416
1679
  const 통째로못읽음 = (r.못읽은쪽 ?? []).filter((x) => !x.일부).length;
1417
- if (!통째로못읽음) return `pdf · ${r.쪽수}쪽`;
1680
+ /*
1681
+ * 일부만 읽은 쪽도 화면에 올린다.
1682
+ *
1683
+ * 표 절반이 빠진 쪽만 있는 문서는 여태 `pdf · 8쪽` 으로만 찍혔다 — 화면만 보는
1684
+ * 사람에게는 온전히 읽은 문서와 글자 하나 다르지 않았다. 본문 알림에는 적혀
1685
+ * 있었지만, 요약 한 줄이 「다 읽었다」고 말해 버리면 본문은 안 읽는다.
1686
+ */
1687
+ const 일부수 = (r.못읽은쪽 ?? []).filter((x) => x.일부).length;
1688
+ const 일부말 = 일부수 ? ` · ${일부수}쪽 일부만 읽음` : '';
1689
+ if (!통째로못읽음) return `pdf · ${r.쪽수}쪽${못건짐}${일부말}${차례}`;
1418
1690
  /*
1419
1691
  * **왜** 못 읽었는지를 화면 요약에 올린다.
1420
1692
  *
@@ -1425,7 +1697,7 @@ export function summarize(r) {
1425
1697
  */
1426
1698
  const 까닭들 = new Set((r.못읽은쪽 ?? []).filter((x) => !x.일부).map((x) => 짧은까닭(x.왜)));
1427
1699
  const 왜 = 까닭들.size === 1 ? ` (${[...까닭들][0]})` : '';
1428
- return `pdf · ${r.쪽수}쪽 · ${통째로못읽음}쪽 못 읽음${왜}`;
1700
+ return `pdf · ${r.쪽수}쪽${못건짐} · ${통째로못읽음}쪽 못 읽음${왜}${일부말}${차례}`;
1429
1701
  }
1430
1702
 
1431
1703
  /** 화면 한 줄에 들어갈 만큼 줄인 까닭. */
@@ -1449,12 +1721,26 @@ export function 한쪽도못읽음말(r, 보인이름) {
1449
1721
  const 까닭들 = [...new Set((r?.못읽은쪽 ?? []).map((x) => String(x.왜)))];
1450
1722
  const 줄 = [`${r?.쪽수 ?? 0}쪽을 전부 글로 못 읽었습니다: ${보인이름}`];
1451
1723
  for (const 왜 of 까닭들.slice(0, 3)) 줄.push(` · ${왜}`);
1724
+ /*
1725
+ * 길은 **왜 못 읽었는지에 따라 다르다.** 여태는 글꼴 표가 없는 것만 갈라 놓고
1726
+ * 나머지는 전부 「그림으로만 들어 있습니다 — OCR 이 필요」 로 보냈다. 그래서
1727
+ * 빈 쪽뿐인 문서에도, 흐름이 막혀 못 푼 문서에도 OCR 을 찾으러 가게 했다.
1728
+ * 위 1459 에서 「빈 쪽이다 → OCR 은 할 일이 없다」 고 이미 갈라 놨는데,
1729
+ * 그 갈림이 사람에게 나가는 말에는 안 닿아 있었다.
1730
+ */
1452
1731
  if (까닭들.some((x) => /ToUnicode/.test(x))) {
1453
1732
  줄.push(' 글꼴이 글자표를 안 싣고 있어 되돌릴 수 없습니다. 이 PDF 를 만든 원본');
1454
1733
  줄.push(' (pptx·docx 등)이 있으면 그쪽을 읽으세요. 훨씬 정확합니다.');
1455
- } else {
1734
+ } else if (까닭들.some((x) => /스캔|OCR/.test(x))) {
1456
1735
  줄.push(' 글이 그림으로만 들어 있습니다. 이 도구로는 더 해 볼 것이 없습니다 —');
1457
1736
  줄.push(' OCR 이 필요하거나, 이 PDF 를 만든 원본 파일을 읽어야 합니다.');
1737
+ } else if (까닭들.length && 까닭들.every((x) => /빈 쪽|글이 없는 쪽/.test(x))) {
1738
+ 줄.push(' 쪽에 그린 것이 아예 없습니다 — 글도 그림도 없는 빈 쪽입니다.');
1739
+ 줄.push(' OCR 도 할 일이 없습니다. 이 PDF 를 만든 원본 파일을 찾으세요.');
1740
+ } else {
1741
+ 줄.push(' 글이 없어서가 아니라 **내용 흐름을 못 풀어서** 못 읽었습니다 (위 까닭).');
1742
+ 줄.push(' 파일이 깨졌거나 이 도구가 못 푸는 압축입니다. 원본 파일이 있으면 그쪽을,');
1743
+ 줄.push(' 없으면 이 PDF 를 다시 내려받거나 다시 뽑아 보세요.');
1458
1744
  }
1459
1745
  줄.push(' **같은 파일을 다시 Read 하지 마세요. 결과는 같습니다.**');
1460
1746
  return 줄.join('\n');
@@ -1468,10 +1754,32 @@ export function 한쪽도못읽음말(r, 보인이름) {
1468
1754
  */
1469
1755
  export function 못읽은말(r) {
1470
1756
  const 것들 = r?.못읽은쪽 ?? [];
1471
- if (!것들.length) return '';
1472
1757
  const 통째 = 것들.filter((x) => !x.일부);
1473
1758
  const 일부 = 것들.filter((x) => x.일부);
1474
1759
  const 줄 = [];
1760
+ /*
1761
+ * 쪽 번호를 못 믿는다는 말이 **본문과 같이** 가야 한다.
1762
+ *
1763
+ * 글은 다 나왔으니 못 읽은 쪽이 하나도 없을 수 있다 — 그래도 여기서
1764
+ * 말해야 한다. 모델이 근거로 대는 것이 「N쪽」 이라서, 이 한 줄이 없으면
1765
+ * 주워 세운 차례가 문서의 차례로 그대로 답에 실린다.
1766
+ */
1767
+ /*
1768
+ * 쪽 나무가 반만 걸린 것도 **본문에** 적는다. 요약만 보고 넘어가는 자리가 아니라,
1769
+ * 모델이 「문서에 그런 조항은 없습니다」 라고 답하는 자리가 여기다.
1770
+ */
1771
+ if (r?.못건진쪽) {
1772
+ 줄.push(`이 문서는 스스로 ${r.적힌쪽수}쪽이라고 적어 두었는데 ${r.쪽수}쪽만 건졌습니다 (${r.못건진쪽}쪽 못 건짐).`);
1773
+ 줄.push(' 쪽 나무(/Kids)의 일부가 끊겨 있습니다 — 덧붙여 저장하다 끊긴 파일에 흔합니다.');
1774
+ 줄.push(' 못 건진 쪽의 내용은 여기 없습니다. 「문서에 없다」 고 답하지 마세요.');
1775
+ }
1776
+ if (r?.쪽차례모름) {
1777
+ 줄.push('쪽 차례를 확인 못 했습니다 — 나온 차례가 문서 차례가 아닐 수 있습니다.');
1778
+ 줄.push(' 쪽 나무(/Root → /Pages)가 깨져 있어, 파일에 있는 쪽을 객체 번호 순으로 주웠습니다.');
1779
+ 줄.push(' 객체 번호는 쪽 차례와 상관이 없습니다. 「N쪽」 은 나온 차례일 뿐이니,');
1780
+ 줄.push(' 답에 쪽 번호를 근거로 쓰지 마세요.');
1781
+ }
1782
+ if (!것들.length) return 줄.join('\n');
1475
1783
  if (통째.length) {
1476
1784
  const 쪽말 = 통째.map((x) => x.번호).join(' · ');
1477
1785
  줄.push(`${r.쪽수}쪽 중 ${통째.length}쪽을 글로 못 읽었습니다 (${쪽말}쪽).`);