deel-local-cli 1.20.8 → 1.20.9

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/src/i18n/ko.js CHANGED
@@ -31,6 +31,7 @@ export const ko = {
31
31
  // 단계 이름·배분 이름은 agent/effort.js 가 들고 있다 — modes.js 와 같은 방식.
32
32
  'run.noTools': '도구 호출이 확인되지 않았습니다 — deel diagnose 로 점검하세요',
33
33
  'think.effort': '추론 강도',
34
+ 'think.unknown': '그런 강도는 없습니다: {값}',
34
35
  'think.profile': '배분',
35
36
  'think.detail': '자세히',
36
37
  'think.harder': '더 세게',
@@ -87,7 +88,7 @@ export const ko = {
87
88
  'cmd.commit.desc': '커밋 — 이번 대화가 바꾼 것만 담고, diff 와 증거로 메시지를 짓는다',
88
89
  'cmd.review.desc': '바꾼 것을 새 창에서 한 번 더 본다 — 찾은 것만 말하고 아무것도 안 고친다',
89
90
  'cmd.paste.desc': '클립보드에 든 화면 캡처를 붙인다 (캡처 → /paste)',
90
- 'cmd.commit.arg': '[전부|미리보기|제목]',
91
+ 'cmd.commit.arg': '[전부] [제목|미리보기]',
91
92
  'cmd.sessions.desc': '이 폴더의 지난 대화 목록',
92
93
  'cmd.recall.desc': '지난 대화에서 찾기 — 목록 말고 내용으로',
93
94
  'cmd.recall.arg': '<찾을 말>',
package/src/repl.js CHANGED
@@ -1188,6 +1188,16 @@ export async function chatLoop(opts = {}) {
1188
1188
  loadedSkills: new Set(),
1189
1189
  // 고친 뒤 진단을 볼지. /lsp off 로 끈다 (lsp/diag.js).
1190
1190
  lsp: { 켬: true },
1191
+ /*
1192
+ * 종을 울릴지. `/bell on|off` 가 **이 그릇**을 고친다.
1193
+ *
1194
+ * 앞서는 명령이 설정 파일만 고쳤다. 울릴지 말지는 켤 때 한 번 읽어
1195
+ * 둔 값(알림.켬)이 정하므로, `/bell off` 를 쳐도 그 세션 내내 계속
1196
+ * 울렸다 — 화면은 「이 세션에도 바로 먹습니다」 라고 적으면서.
1197
+ * `/bell on` 은 더 나빴다: 그 자리에서 한 번 울려 켜졌다는 증거까지
1198
+ * 보여 준 다음, 다음 턴부터 안 울렸다.
1199
+ */
1200
+ 종알림: 알림,
1191
1201
  ask,
1192
1202
  // 암호는 여기서만 받는다. 받은 값은 도구가 쓰고 버린다 —
1193
1203
  // 설정에도, 세션 기록에도, 감사기록에도, 명령줄에도 안 남는다.
package/src/tools/pdf.js CHANGED
@@ -87,8 +87,10 @@ function 이름읽기(b, i) {
87
87
  const 조각 = [];
88
88
  while (i < b.length && !끝인가(b[i])) {
89
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; }
90
+ // 자리 다 16진수여야 한다. `parseInt('1/',16)` 1 돌려주므로,
91
+ // 그것만 보면 뒤따라온 구분자 `/` 까지 삼켜 다음 낱말이 사라진다.
92
+ const 두자리 = String.fromCharCode(b[i + 1], b[i + 2]);
93
+ if (/^[0-9A-Fa-f]{2}$/.test(두자리)) { 조각.push(parseInt(두자리, 16)); i += 3; continue; }
92
94
  }
93
95
  조각.push(b[i]); i += 1;
94
96
  }
@@ -423,6 +425,7 @@ class 문서 {
423
425
  this.자리 = new Map(); // 객체번호 → 파일 위치
424
426
  this.푼것 = new Map(); // 객체번호 → 읽어 둔 값
425
427
  this.묶음속 = new Map(); // 객체번호 → ObjStm 안에서 읽은 값
428
+ this.xref정함 = new Set(); // 최신 xref 가 자리를 정해 준 객체 (옛 판이 못 덮는다)
426
429
  this.trailer = {};
427
430
  this.훑기();
428
431
  this.xref읽기();
@@ -435,11 +438,39 @@ class 문서 {
435
438
  const 무늬 = /(?:^|[\s>\]])(\d+)\s+(\d+)\s+obj\b/g;
436
439
  let m;
437
440
  while ((m = 무늬.exec(s)) !== null) {
438
- const 자리 = m.index + m[0].length - `${m[1]} ${m[2]} obj`.length;
439
- this.자리.set(Number(m[1]), 자리);
441
+ /*
442
+ * 무늬가 앞 글자 한 칸을 같이 물었는지만 보면 된다.
443
+ *
444
+ * 여기서 `${m[1]} ${m[2]} obj`.length 를 빼는 셈을 하면 **공백이 한 칸일
445
+ * 때만** 맞는다. `4 0\r\nobj` 처럼 줄바꿈이 끼면 자리가 한 칸 밀려
446
+ * 「0\r\nobj」 를 가리키고, 그러면 그 객체를 못 읽는다. xref 까지 틀린
447
+ * 파일에서는 이 훑기가 마지막 안전망인데 그 안전망에 구멍이 났다.
448
+ */
449
+ this.자리.set(Number(m[1]), m.index + (/^\d/.test(m[0]) ? 0 : 1));
440
450
  }
441
451
  }
442
452
 
453
+ /**
454
+ * xref 가 말하는 자리를 받아 적는다.
455
+ *
456
+ * xref 는 **최신 판부터** 거슬러 읽는다(startxref → /Prev). 그래서 한 번
457
+ * 정해진 객체를 나중에(= 더 옛 판에서) 다시 적으면 안 된다. 증분 저장한
458
+ * PDF 는 고친 객체가 파일 뒤에 새로 붙고 옛것이 그대로 남아 있는데, 옛
459
+ * 자리로 덮으면 **고치기 전 값을 읽는다** — 폼에 채운 값이 빈칸으로,
460
+ * 고친 금액이 옛 금액으로 돌아간다. 빈 글은 보이지만 옛 글은 안 보인다.
461
+ */
462
+ 자리정하기(번호, 자리) {
463
+ if (this.xref정함.has(번호)) return;
464
+ // 훑기가 찾은 자리를 못 믿을 때만 xref 를 쓴다. 둘 다 있으면 그 자리에
465
+ // 진짜 `N obj` 가 있는 쪽을 고른다.
466
+ if (this.진짜객체인가(자리, 번호)) {
467
+ this.자리.set(번호, 자리);
468
+ this.xref정함.add(번호);
469
+ return;
470
+ }
471
+ if (!this.자리.has(번호)) this.자리.set(번호, 자리);
472
+ }
473
+
443
474
  /** ② xref 를 따라가며 trailer 를 모은다. */
444
475
  xref읽기() {
445
476
  const s = this.b.toString('latin1');
@@ -460,7 +491,10 @@ class 문서 {
460
491
  }
461
492
  if (!this.trailer.Root) {
462
493
  // trailer 를 못 찾았으면 카탈로그를 직접 찾는다.
463
- const m = /(\d+)\s+\d+\s+obj\s*<<[^>]{0,400}?\/Type\s*\/Catalog/.exec(this.b.toString('latin1'));
494
+ // `[^>]` 로 막으면 카탈로그 앞에 속사전(`<< … >>`)이 하나라도 있으면
495
+ // 못 찾는다. endobj 만 넘지 않도록 해서 그 안쪽까지 본다.
496
+ const m = /(\d+)\s+\d+\s+obj\s*<<(?:(?!endobj)[\s\S]){0,400}?\/Type\s*\/Catalog/
497
+ .exec(this.b.toString('latin1'));
464
498
  if (m) this.trailer.Root = { 참조: Number(m[1]), 세대: 0 };
465
499
  }
466
500
  }
@@ -482,23 +516,25 @@ class 문서 {
482
516
  let 번호 = Number(m[1]);
483
517
  const 수 = Number(m[2]);
484
518
  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);
519
+ /*
520
+ * 줄은 20자라고 규격에 적혀 있지만 19자로 쓰는 프로그램이 있다
521
+ * (줄 끝을 `\n` 하나로 적는다). 그래서 20 씩 더하지 않고 **줄을 읽은
522
+ * 만큼** 나아간다. 앞서 20 더하던 코드는 19자 파일에서 둘째 줄부터
523
+ * 자씩 밀려 표 전체를 잃었고, 뒤에 붙어 있던 「줄을 맞춘다」 는
524
+ * 자리에서 곧바로 빠져나가 번도 맞춰 적이 없다.
525
+ */
526
+ for (let k = 0; k < 수 && i < this.b.length; k += 1) {
527
+ while (i < this.b.length && 공백인가(this.b[i])) i += 1;
528
+ const mm = /^(\d{10})\s(\d{5})\s([nf])/.exec(this.b.toString('latin1', i, i + 20));
529
+ if (!mm) {
530
+ // 규격에서 벗어난 줄 — 다음 줄머리로 넘긴다.
531
+ while (i < this.b.length && this.b[i] !== 0x0a && this.b[i] !== 0x0d) i += 1;
532
+ 번호 += 1;
533
+ continue;
494
534
  }
535
+ if (mm[3] === 'n') this.자리정하기(번호, Number(mm[1]));
495
536
  번호 += 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
- }
537
+ i += mm[0].length;
502
538
  }
503
539
  }
504
540
  }
@@ -539,9 +575,12 @@ class 문서 {
539
575
  밭.push(w === 0 ? null : v);
540
576
  }
541
577
  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]);
578
+ 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]);
583
+ }
545
584
  번호 += 1;
546
585
  }
547
586
  }
@@ -578,6 +617,16 @@ class 문서 {
578
617
  const 안자리 = 수들[k * 2 + 1];
579
618
  if (!Number.isInteger(객체번호) || !Number.isInteger(안자리)) continue;
580
619
  if (this.묶음속.has(객체번호)) continue;
620
+ /*
621
+ * 최신 xref 가 「이 객체는 파일에 낱개로 있다」 고 정해 준 것은
622
+ * 묶음이 덮지 못한다.
623
+ *
624
+ * 눌린 PDF(1.5+)를 고쳐 저장하면 고친 객체만 **낱개로** 뒤에
625
+ * 덧붙는다. 그때 옛 묶음도 파일에 그대로 남아 있어서, 묶음을 펼쳐
626
+ * 덮어 버리면 아래 풀기() 가 묶음 쪽을 먼저 보므로 **고치기 전 값을
627
+ * 읽는다.** 자리만 지켜 놓고 여기를 안 지키면 반쪽만 고친 것이다.
628
+ */
629
+ if (this.xref정함.has(객체번호)) continue;
581
630
  const [값] = 값읽기(r.자료, 첫 + 안자리, (x) => this.풀기(x));
582
631
  this.묶음속.set(객체번호, 값);
583
632
  }
@@ -708,13 +757,29 @@ function 열여섯값(글) {
708
757
  return s ? parseInt(s, 16) : null;
709
758
  }
710
759
 
711
- /** `<0041>` 을 유니코드 글자로 (UTF-16BE 여러 글자일 수 있다). */
760
+ /**
761
+ * `<0041>` 을 유니코드 글자로 (UTF-16BE 여러 글자일 수 있다).
762
+ *
763
+ * 넷씩 끊어 읽고, 모자란 자리는 **맨 앞**을 0 으로 메운다. 뒤를 메우면
764
+ * `<41>`('A') 가 `4100` — 곧 「䄀」 이라는 엉뚱한 한자가 된다. 두 자리로 적는
765
+ * 파일이 규격 위반이긴 하지만, 위반을 만났을 때 없는 한자를 만들어 내는 것과
766
+ * 「A」 를 돌려주는 것 중에 무엇이 나은지는 분명하다.
767
+ *
768
+ * 메우는 자리가 **맨 앞 한 곳**이어야 한다. 뒤쪽 토막만 메우면 `<410042>` 가
769
+ * `4100`·`0042` 로 끊겨 첫 글자가 다시 「䄀」 이 된다 — 고친 자리가 도로
770
+ * 살아난다.
771
+ */
712
772
  function 열여섯글자(글) {
713
773
  const s = String(글 ?? '').replace(/[^0-9A-Fa-f]/g, '');
714
774
  if (!s) return '';
715
- const = s.length % 4 ? s.padEnd(Math.ceil(s.length / 4) * 4, '0') : s;
775
+ const = s.length % 4;
776
+ const 짝 = 남 ? s.padStart(s.length + (4 - 남), '0') : s;
716
777
  let 나온것 = '';
717
- for (let i = 0; i < 짝.length; i += 4) 나온것 += String.fromCharCode(parseInt(짝.slice(i, i + 4), 16));
778
+ for (let i = 0; i < 짝.length; i += 4) {
779
+ const v = parseInt(짝.slice(i, i + 4), 16);
780
+ // `<0000>` 은 「글자 없음」 이다. 글에 NUL 을 섞으면 화면과 모델 쪽이 같이 깨진다.
781
+ if (v) 나온것 += String.fromCharCode(v);
782
+ }
718
783
  return 나온것;
719
784
  }
720
785
 
@@ -742,14 +807,24 @@ export function 유니코드표읽기(글) {
742
807
  }
743
808
  }
744
809
  for (const m of s.matchAll(/beginbfrange([\s\S]*?)endbfrange/g)) {
745
- const = m[1];
810
+ const 배열꼴 = /<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*\[([\s\S]*?)\]/g;
746
811
  // `<시작> <끝> [<a> <b> …]` 꼴
747
- for (const p of 몸.matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*\[([\s\S]*?)\]/g)) {
812
+ for (const p of m[1].matchAll(배열꼴)) {
748
813
  const 시작 = 열여섯값(p[1]);
749
814
  코드폭.add(Math.ceil(p[1].length / 2));
750
815
  const 것들 = [...p[3].matchAll(/<([0-9A-Fa-f]+)>/g)].map((x) => 열여섯글자(x[1]));
751
816
  것들.forEach((글자, k) => 표.set(시작 + k, 글자));
752
817
  }
818
+ /*
819
+ * 배열 꼴을 읽은 자리는 지우고 넘긴다.
820
+ *
821
+ * 남겨 두면 아래 무늬가 **대괄호 안의 글자 셋을 또 하나의 범위로** 읽는다.
822
+ * `<0020> <0022> [<0041> <0042> <0043>]` 에서 `<0041> <0042> <0043>` 이
823
+ * 다시 걸려, 코드 0x41·0x42 에 「C」「D」 라는 없는 매핑이 생긴다.
824
+ * Identity-H 문서에서 0x41 은 글리프 번호이므로 — 못 읽은 글자가 되는
825
+ * 대신 **엉뚱한 글자로 읽힌다.** 빠진 글자는 보이지만 틀린 글자는 안 보인다.
826
+ */
827
+ const 몸 = m[1].replace(배열꼴, ' ');
753
828
  // `<시작> <끝> <첫글자>` 꼴
754
829
  for (const p of 몸.matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g)) {
755
830
  const 시작 = 열여섯값(p[1]);
@@ -757,6 +832,7 @@ export function 유니코드표읽기(글) {
757
832
  const 첫 = 열여섯글자(p[3]);
758
833
  코드폭.add(Math.ceil(p[1].length / 2));
759
834
  if (시작 === null || 끝 === null || 끝 < 시작 || 끝 - 시작 > 65535) continue;
835
+ if (!첫) continue; // `<0000>` 에서 시작하는 범위 — 셀 밑자리가 없다
760
836
  const 밑 = 첫.charCodeAt(첫.length - 1);
761
837
  for (let k = 0; k <= 끝 - 시작; k += 1) {
762
838
  if (표.has(시작 + k)) continue;
@@ -906,11 +982,8 @@ function 글자로(글꼴, 바이트들) {
906
982
  let 글 = '';
907
983
  let 못읽음 = 0;
908
984
  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;
985
+ for (let i = 0; i + 폭 <= 바이트들.length; i += 폭) {
986
+ const 코드 = 폭 === 2 ? ((바이트들[i] << 8) | 바이트들[i + 1]) : 바이트들[i];
914
987
  const g = 글꼴?.표?.get(코드);
915
988
  let 찍은것 = null;
916
989
  if (g !== undefined && g !== null && g !== '') { 글 += g; 찍은것 = g; }
@@ -922,6 +995,14 @@ function 글자로(글꼴, 바이트들) {
922
995
  else if (typeof 너비기본 === 'number') 나아감 += 너비기본;
923
996
  else 나아감 += 찍은것 ? 글자너비비율(찍은것) : 넓은글자;
924
997
  }
998
+ /*
999
+ * 반 토막 난 마지막 코드.
1000
+ *
1001
+ * 두 바이트 글꼴에 홀수 길이 글자열이 오면 규격 위반이다. 여기에 0 을 붙여
1002
+ * 두 바이트로 만들면 **없는 글리프 번호**가 생기고, 그것이 어쩌다 표에
1003
+ * 있으면 엉뚱한 글자가 글에 섞인다. 못 읽은 것으로 세는 쪽이 정직하다.
1004
+ */
1005
+ if (바이트들.length % 폭) 못읽음 += 1;
925
1006
  return { 글, 못읽음, 나아감 };
926
1007
  }
927
1008
 
@@ -960,6 +1041,27 @@ function 곱하기(m, n) {
960
1041
  ];
961
1042
  }
962
1043
 
1044
+ /**
1045
+ * 붙박이 그림(BI … ID … EI)이 끝나는 자리를 찾는다.
1046
+ *
1047
+ * 그림 자료는 눌린 바이트라 그 안에 우연히 `EI` 두 글자가 들어 있을 수 있다.
1048
+ * 그 자리에서 끊으면 그림 한가운데부터 낱말을 읽기 시작하고, 자료 안의 `(`
1049
+ * 를 글자 시작으로 잘못 읽어 **그 쪽에 남은 글을 통째로 삼킨다.** 그러면서
1050
+ * 앞부분은 읽혔으니 「못 읽은 쪽」에도 안 들어간다 — 아무 말 없이 사라진다.
1051
+ *
1052
+ * 규격대로 앞이 공백이고 뒤가 공백·구분자인 자리만 끝으로 본다. 그런 자리가
1053
+ * 없으면 여태처럼 첫 `EI` 로 물러선다(끊긴 그림이 있는 파일).
1054
+ */
1055
+ function 그림끝(b, i) {
1056
+ let k = i;
1057
+ for (;;) {
1058
+ k = b.indexOf('EI', k, 'latin1');
1059
+ if (k < 0) return -1;
1060
+ if (공백인가(b[k - 1]) && 끝인가(b[k + 2])) return k + 2;
1061
+ k += 2;
1062
+ }
1063
+ }
1064
+
963
1065
  /**
964
1066
  * 내용 흐름 하나에서 글을 뽑는다.
965
1067
  *
@@ -975,6 +1077,7 @@ function 흐름에서글(문, 자료, 자원, 깊이 = 0) {
975
1077
  let 펜x = null; // 붓이 지금 어디까지 갔다고 보는가 (자리바뀜 참고)
976
1078
  let 못읽은코드 = 0;
977
1079
  let 글낸적 = false;
1080
+ let 그림낸적 = false;
978
1081
  const 글꼴통 = new Map();
979
1082
 
980
1083
  let Tm = [1, 0, 0, 1, 0, 0];
@@ -1029,9 +1132,10 @@ function 흐름에서글(문, 자료, 자원, 깊이 = 0) {
1029
1132
 
1030
1133
  // 붙박이 그림은 통째로 건너뛴다. 안 그러면 그림 자료를 낱말로 읽는다.
1031
1134
  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;
1135
+ const 끝 = 그림끝(b, i);
1136
+ const 물러선것 = 끝 < 0 ? b.indexOf('EI', i, 'latin1') : -1;
1137
+ i = >= 0 ? 끝 : (물러선것 < 0 ? b.length : 물러선것 + 2);
1138
+ 그림낸적 = true;
1035
1139
  continue;
1036
1140
  }
1037
1141
 
@@ -1046,8 +1150,17 @@ function 흐름에서글(문, 자료, 자원, 깊이 = 0) {
1046
1150
  쌓임.length = 0;
1047
1151
 
1048
1152
  switch (op) {
1049
- case 'BT': Tm = [1, 0, 0, 1, 0, 0]; Tlm = Tm.slice(); 앞y = null; 앞x = null; break;
1050
- case 'ET': 줄맺기(); break;
1153
+ /*
1154
+ * BT·ET 줄이 아니다.
1155
+ *
1156
+ * 한 줄 안에서 낱말 하나만 굵게 하거나 색을 바꾸려면 그 낱말만 BT…ET
1157
+ * 로 따로 싣는 조판이 흔하다. 그때 ET 마다 줄을 맺으면
1158
+ * 「Total 1,000,000 won」 이 세 줄로 쪼개진다. 줄이 바뀌었는지는 ET 가
1159
+ * 아니라 **세로로 움직였는지**가 말해 준다 — 그래서 앞자리를 그대로
1160
+ * 들고 다음 덩이의 첫 자리와 견준다.
1161
+ */
1162
+ case 'BT': Tm = [1, 0, 0, 1, 0, 0]; Tlm = Tm.slice(); break;
1163
+ case 'ET': break;
1051
1164
  case 'TL': TL = Number(인자[0]) || 0; break;
1052
1165
  case 'Td': {
1053
1166
  const tx = Number(인자[0]) || 0; const ty = Number(인자[1]) || 0;
@@ -1092,14 +1205,17 @@ function 흐름에서글(문, 자료, 자원, 깊이 = 0) {
1092
1205
  break;
1093
1206
  }
1094
1207
  case 'Do': {
1095
- // 폼 XObject 안에도 글이 있다 (머리말·표·도장). 깊이는 막아 둔다.
1096
- if (깊이 >= 4) break;
1097
1208
  const 이름 = 인자[0]?.이름;
1098
1209
  if (!이름) break;
1099
1210
  const 것들 = 문.꺼내기(자원, 'XObject');
1100
1211
  const 하나 = 문.풀기((것들?.사전 ?? 것들)?.[이름]);
1212
+ // 폼이 아니면 그림(또는 알 수 없는 것)을 그린 것이다. 그리기를 본
1213
+ // 이 자리가 「스캔본인가」 를 아는 유일한 자리다 — 자원에 /XObject 가
1214
+ // 있다는 것만으로는 **그렸는지**를 알 수 없다.
1215
+ if (문.풀기(하나?.사전?.Subtype)?.이름 !== 'Form') { 그림낸적 = true; break; }
1216
+ // 폼 XObject 안에도 글이 있다 (머리말·표·도장). 깊이는 막아 둔다.
1217
+ if (깊이 >= 4) break;
1101
1218
  if (!하나?.날것) break;
1102
- if (문.풀기(하나.사전?.Subtype)?.이름 !== 'Form') break;
1103
1219
  const r = 흐름풀기(하나, (x) => 문.풀기(x));
1104
1220
  if (!r.ok) break;
1105
1221
  const 속자원 = 문.풀기(하나.사전?.Resources) ?? 자원;
@@ -1108,13 +1224,14 @@ function 흐름에서글(문, 자료, 자원, 깊이 = 0) {
1108
1224
  줄들.push(...안것.줄들);
1109
1225
  못읽은코드 += 안것.못읽은코드;
1110
1226
  글낸적 = 글낸적 || 안것.글낸적;
1227
+ 그림낸적 = 그림낸적 || 안것.그림낸적;
1111
1228
  break;
1112
1229
  }
1113
1230
  default: break;
1114
1231
  }
1115
1232
  }
1116
1233
  줄맺기();
1117
- return { 줄들, 못읽은코드, 글낸적 };
1234
+ return { 줄들, 못읽은코드, 글낸적, 그림낸적 };
1118
1235
  }
1119
1236
 
1120
1237
  /* ────────────────────────────────────────────────────────────────────────
@@ -1228,8 +1345,15 @@ export function readPdf(경로또는버퍼) {
1228
1345
  * 글이 하나도 안 나온 쪽. 왜인지 갈라서 말한다 — 스캔본과 글꼴 문제는
1229
1346
  * 사람이 할 일이 다르다(전자는 OCR, 후자는 다시 뽑기).
1230
1347
  */
1231
- const 그림있나 = /\/Subtype\s*\/Image|\bDo\b/.test(Buffer.concat(자료들).toString('latin1').slice(0, 20000))
1232
- || !!문.꺼내기(자원, 'XObject');
1348
+ /*
1349
+ * 「그림은 그렸고 글은 없다」 = 스캔본일 수 있다 → OCR.
1350
+ * 「아무것도 안 그렸다」 = 그냥 빈 쪽이다 → OCR 은 할 일이 없다.
1351
+ *
1352
+ * 앞서는 자원 사전에 /XObject 가 **있기만 해도** 스캔본으로 적었다.
1353
+ * 머리말 로고가 위쪽 자원에 물려 있으면 정말 빈 쪽도 「OCR 이 필요」
1354
+ * 가 됐다. 그린 것을 본 쪽은 파서다.
1355
+ */
1356
+ const 그림있나 = 뽑은것.그림낸적;
1233
1357
  못읽은쪽.push({
1234
1358
  번호,
1235
1359
  왜: 뽑은것.못읽은코드
package/src/ui/ansi.js CHANGED
@@ -126,19 +126,41 @@ export function pad(str, target, align = 'left') {
126
126
  return str + ' '.repeat(gap);
127
127
  }
128
128
 
129
- // 색 코드를 건드리지 않고 보이는 폭 기준으로 자른다.
129
+ /**
130
+ * 색 코드를 건드리지 않고 보이는 폭 기준으로 자른다.
131
+ *
132
+ * 색 코드는 화면에서 자리를 차지하지 않으므로 폭에서 빼야 하고, 그 가운데를
133
+ * 자르면 안 된다. 한 글자씩 셈에 넣으면 둘 다 어긋난다 — 회색 한 줄
134
+ * `\x1b[38;5;245m…\x1b[0m` 은 색 코드만으로 열두 칸을 먹어서, 폭 12 로
135
+ * 자르면 보이는 글자가 한 칸만 남고 잘린 자리가 `\x1b[38;5;` 라는 토막이
136
+ * 된다. 터미널은 그 토막을 명령의 시작으로 보고 뒤따르는 글자를 먹는다.
137
+ *
138
+ * 그래서 색 코드는 통째로 넘기고 폭 0 으로 센다. 자른 자리에서 색이 켜져
139
+ * 있으면 되돌림을 붙인다 — 안 붙이면 그 색이 뒤에 찍는 모든 줄로 번진다.
140
+ */
130
141
  export function clip(str, max, tail = '…') {
131
142
  if (width(str) <= max) return str;
143
+ const budget = max - width(tail);
132
144
  let out = '';
133
145
  let w = 0;
134
- const budget = max - width(tail);
135
- for (const ch of String(str)) {
136
- const cw = width(ch);
137
- if (w + cw > budget) break;
138
- out += ch;
139
- w += cw;
146
+ let 색켜짐 = false;
147
+ let 찼다 = false;
148
+ for (const 조각 of String(str).split(/(\x1b\[[0-9;]*m)/)) {
149
+ if (!조각) continue;
150
+ if (찼다) break;
151
+ if (조각.charCodeAt(0) === 0x1b) {
152
+ out += 조각;
153
+ 색켜짐 = !/^\x1b\[0?m$/.test(조각);
154
+ continue;
155
+ }
156
+ for (const ch of 조각) {
157
+ const cw = width(ch);
158
+ if (w + cw > budget) { 찼다 = true; break; }
159
+ out += ch;
160
+ w += cw;
161
+ }
140
162
  }
141
- return out + tail;
163
+ return out + (색켜짐 ? '\x1b[0m' : '') + tail;
142
164
  }
143
165
 
144
166
  // 터미널 가로 폭. 파이프로 넘어가면 알 수 없으니 넉넉히 잡는다.