deel-local-cli 1.20.7 → 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/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
  // 터미널 가로 폭. 파이프로 넘어가면 알 수 없으니 넉넉히 잡는다.
@@ -72,6 +72,8 @@ const 자가치유주기 = 4000;
72
72
  const 앞머리 = 5;
73
73
  // 상자가 차지할 최대 줄 수. 긴 글을 붙여넣어도 화면을 다 먹지 않게.
74
74
  export const 안쪽최대 = 8;
75
+ // 일하는 중에 미리 쳐 두는 글은 이보다 좁게 보인다 — 위쪽 '일하는 중' 줄을 안 밀어내게.
76
+ export const 대기최대 = 3;
75
77
 
76
78
  /**
77
79
  * 상자를 이룰 줄들과 커서 자리를 값으로 내놓는다.
@@ -117,8 +119,23 @@ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상
117
119
  */
118
120
  if (사무실?.length) 줄들.push(...사무실);
119
121
 
120
- if (상태) 줄들.push(상태);
121
- if (경고) 줄들.push(` ${경고}`);
122
+ /*
123
+ * ── 여기 얹는 줄은 **반드시 폭 안에 넣는다** ─────────────────────────
124
+ *
125
+ * 상자는 아래 몇 줄을 붙박아 두고 그 자리에서 다시 그린다 — 그 셈이
126
+ * `줄들.length` 다. 한 줄이 터미널보다 넓으면 터미널은 그것을 두 줄로
127
+ * 접는데 상자는 한 줄로 세고, 그만큼 커서를 덜 올린다. 그러면 키를 칠
128
+ * 때마다 위쪽 대화가 한 줄씩 갉여 나가고 테두리 잔상이 쌓인다.
129
+ *
130
+ * 실제로 재 본 자리: 폭 80 에서 할당량 경고가 101칸이었다. 상자는 5줄로
131
+ * 셌고 터미널은 7줄을 썼다.
132
+ *
133
+ * 상태줄은 자기 자리에서 이미 줄이지만(ui/status.js), 덩이 하나가 폭보다
134
+ * 넓으면 그대로 나온다 — 그래서 여기서 한 번 더 자른다. 경고는 뜻이
135
+ * 뒤쪽에 있는 문장이라 자르지 않고 **접는다.**
136
+ */
137
+ if (상태) 줄들.push(clip(상태, 칸));
138
+ if (경고) for (const 줄 of 접어쓰기(` ${경고}`, 칸)) 줄들.push(줄);
122
139
 
123
140
  const 가로 = '─'.repeat(칸 - 3);
124
141
  줄들.push(테(` ╭${가로}╮`));
@@ -144,8 +161,16 @@ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상
144
161
  const 왼쪽 = `${c.hcyan(일감.돌림 ?? '⠋')} ${c.white(일감.말 ?? '')}${c.gray('…')}`;
145
162
  const 오른쪽 = c.gray(일감.곁 ?? '');
146
163
  const 남 = 일안쪽 - width(왼쪽) - width(오른쪽);
147
- // 자리가 모자라면 오른쪽(걸린 시간·중단 안내)을 버린다. 왼쪽이 본문이다.
148
- const = >= 2 ? `${왼쪽}${' '.repeat(남)}${오른쪽}` : 왼쪽;
164
+ /*
165
+ * 자리가 모자라면 오른쪽(걸린 시간·중단 안내)을 버린다. 왼쪽이 본문이다.
166
+ *
167
+ * 오른쪽을 버려도 **왼쪽 하나가 폭보다 넓을 수 있다.** 일감의 말은 부르는
168
+ * 쪽에서 오는 글이라(하위 작업 목적·도구 이름·파일 경로) 길이를 여기서
169
+ * 정하지 못한다. 안 자르고 내보내면 위 머리글이 적어 둔 그 손해가 그대로
170
+ * 난다 — 터미널은 두 줄로 접고 상자는 한 줄로 세서, 키를 칠 때마다
171
+ * 위쪽 대화가 한 줄씩 갉여 나간다.
172
+ */
173
+ const 안 = 남 >= 2 ? `${왼쪽}${' '.repeat(남)}${오른쪽}` : clip(왼쪽, 일안쪽);
149
174
  줄들.push(` ${테('│')} ${안}${' '.repeat(Math.max(0, 일안쪽 - width(안)))} ${테('│')}`);
150
175
 
151
176
  /*
@@ -157,22 +182,31 @@ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상
157
182
  * 아무것도 안 보여서, 쳐도 안 먹는 줄 알았을 뿐이다.
158
183
  *
159
184
  * 그래서 보여만 준다. 새로 받는 게 아니라 **이미 받고 있던 것**이다.
185
+ *
186
+ * 세 줄을 넘으면 **뒤에서 세 줄**을 보인다.
187
+ *
188
+ * 앞서는 앞에서 세 줄을 보였다. 사람은 끝에서 치고 있는데 화면에는 처음
189
+ * 세 줄이 붙어 있으니, 네 줄째부터는 친 글자가 아무 데도 안 나타났다 —
190
+ * 그리고 커서는 셋째 줄 끝에 서 있었다. 키가 안 먹는 것처럼 보이는데
191
+ * 실은 다 받고 있었다. 가린 줄이 있으면 ❯ 를 … 로 바꿔 알린다
192
+ * (아래 본 상자가 쓰는 것과 같은 표시다).
160
193
  */
161
- if (일감.대기) {
162
- const 대기줄들 = 접어쓰기(일감.대기, 안쪽).slice(0, 3);
163
- for (const [i, 한줄] of 대기줄들.entries()) {
164
- const 표 = i === 0 ? c.hcyan('❯') : ' ';
165
- 줄들.push(` ${테('│')} ${} ${한줄}${' '.repeat(Math.max(0, 안쪽 - width(한줄)))} ${테('')}`);
166
- }
194
+ const 대기줄들 = 일감.대기 ? 접어쓰기(일감.대기, 안쪽) : [];
195
+ const 대기보일 = 대기줄들.slice(-대기최대);
196
+ const 대기넘침 = 대기줄들.length > 대기최대;
197
+ for (const [i, 한줄] of 대기보일.entries()) {
198
+ const= i === 0 ? (대기넘침 ? c.gray('…') : c.hcyan('')) : ' ';
199
+ 줄들.push(` ${테('│')} ${표} ${한줄}${' '.repeat(Math.max(0, 안쪽 - width(한줄)))} ${테('│')}`);
167
200
  }
168
201
 
169
202
  줄들.push(테(` ╰${가로}╯`));
170
- if (일감.대기) 줄들.push(` ${c.gray('Enter 치면 지금 일이 끝난 뒤에 보냅니다')}`);
171
- else if (곁말) 줄들.push(` ${c.gray(곁말)}`);
203
+ // 아래 줄도 안에 넣는다 안내 문구는 43칸이라 좁은 창에서 접힌다.
204
+ if (일감.대기) 줄들.push(clip(` ${c.gray('Enter 를 치면 지금 일이 끝난 뒤에 보냅니다')}`, 칸));
205
+ else if (곁말) 줄들.push(clip(` ${c.gray(곁말)}`, 칸));
172
206
 
173
207
  // 커서는 미리 치는 중일 때만 보여 준다. 아니면 칠 자리가 아니다.
174
208
  if (일감.대기) {
175
- const 마지막 = 접어쓰기(일감.대기, 안쪽).slice(0, 3).at(-1) ?? '';
209
+ const 마지막 = 대기보일.at(-1) ?? '';
176
210
  return { 줄들, 커서: { 위: 2, 열: Math.min(앞머리 + 1 + width(마지막), 칸 - 1) } };
177
211
  }
178
212
  return { 줄들, 커서: { 위: (곁말 ? 1 : 0), 열: 1 } };
@@ -184,7 +218,24 @@ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상
184
218
  */
185
219
  const 안쪽줄 = 글 ? 접어쓰기(글, 안쪽) : [''];
186
220
  const 넘침 = 안쪽줄.length > 안쪽최대;
187
- const 보일줄 = 넘침 ? 안쪽줄.slice(안쪽줄.length - 안쪽최대) : 안쪽줄;
221
+ /*
222
+ * 보일 자리는 **커서를 따라간다.**
223
+ *
224
+ * 앞서는 언제나 마지막 여덟 줄만 보여 줬다. 그래서 열두 줄짜리 글을
225
+ * 붙여넣고 Ctrl+A 로 맨 앞에 가면, 커서가 든 줄이 화면에 없었다 — 그때
226
+ * 커서 줄 번호는 0 으로 깎이므로 커서는 **보이는 첫 줄**에 그려지고,
227
+ * 치는 글자는 안 보이는 줄에 들어간다. 커서 자리에 아무것도 안 나타나니
228
+ * 키가 안 먹는 것처럼 보인다.
229
+ *
230
+ * 끝에서 치는 보통 경우에는 예전과 똑같이 마지막 여덟 줄이 보인다.
231
+ */
232
+ const 커서앞줄수 = (커서자리 == null ? 글 : String(글).slice(0, 커서자리))
233
+ ? 접어쓰기(커서자리 == null ? 글 : String(글).slice(0, 커서자리), 안쪽).length
234
+ : 1;
235
+ const 첫줄 = 넘침
236
+ ? Math.min(Math.max(0, 커서앞줄수 - 안쪽최대), 안쪽줄.length - 안쪽최대)
237
+ : 0;
238
+ const 보일줄 = 안쪽줄.slice(첫줄, 첫줄 + 안쪽최대);
188
239
 
189
240
  for (const [i, 한줄] of 보일줄.entries()) {
190
241
  const 표 = i === 0 ? (넘침 ? c.gray('…') : c.hcyan('❯')) : ' ';
@@ -227,7 +278,8 @@ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상
227
278
  }
228
279
  }
229
280
 
230
- if (곁말) 줄들.push(` ${c.gray(곁말)}`);
281
+ // 곁말도 폭 안에 넣는다 — 위 머리글의 그 셈( )이 어긋난다.
282
+ if (곁말) 줄들.push(clip(` ${c.gray(곁말)}`, 칸));
231
283
 
232
284
  /*
233
285
  * 커서 자리.
@@ -239,9 +291,8 @@ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상
239
291
  const 앞글 = 커서자리 == null ? 글 : String(글).slice(0, 커서자리);
240
292
  const 앞줄들 = 앞글 ? 접어쓰기(앞글, 안쪽) : [''];
241
293
 
242
- // 넘쳐서 앞부분을 잘라 냈으면 그만큼 줄 번호가 밀린다.
243
- const 숨긴줄 = 안쪽줄.length - 보일줄.length;
244
- const 커서줄 = Math.min(Math.max(0, 앞줄들.length - 1 - 숨긴줄), 보일줄.length - 1);
294
+ // 위로 접어 줄만큼 줄 번호가 밀린다 (보일 자리는 커서를 따라 옮긴다).
295
+ const 커서줄 = Math.min(Math.max(0, 앞줄들.length - 1 - 첫줄), 보일줄.length - 1);
245
296
 
246
297
  // 커서 아래에 몇 줄이 더 있나 — 닫는 테두리 + 추천 목록 + 곁말.
247
298
  const 아래 = 1 + 추천줄수 + (곁말 ? 1 : 0);
@@ -400,7 +451,7 @@ export class InputBox {
400
451
  일시작(session, 갈래 = '기본') {
401
452
  this.session = session ?? this.session;
402
453
  // 대기 = 일하는 도중에 미리 치고 있는 글, 예약수 = 이미 Enter 를 친 것들.
403
- this.일감 = { 갈래, 시작: Date.now(), 곁정보: '', 대기: '', 예약수: 0 };
454
+ this.일감 = { 갈래, 시작: Date.now(), 바뀐때: Date.now(), 곁정보: '', 대기: '', 예약수: 0 };
404
455
  this.틱 = 0;
405
456
  this.일그리기();
406
457
  if (this.박자) clearInterval(this.박자);
@@ -414,7 +465,7 @@ export class InputBox {
414
465
  if (!this.일감) return;
415
466
  // 갈래가 바뀌면 문구를 그 자리에서 갈아 준다. 4초를 기다리면 이미 다음
416
467
  // 일로 넘어가 있어서, 화면이 늘 한 걸음 늦은 말을 하게 된다.
417
- if (갈래 && 갈래 !== this.일감.갈래) { this.일감.갈래 = 갈래; this.틱 = 0; }
468
+ if (갈래 && 갈래 !== this.일감.갈래) { this.일감.갈래 = 갈래; this.일감.바뀐때 = Date.now(); this.틱 = 0; }
418
469
  if (곁정보 !== null) this.일감.곁정보 = 곁정보;
419
470
  this.일그리기();
420
471
  }
@@ -452,7 +503,18 @@ export class InputBox {
452
503
  * 「멈추는 중」이 화면에 영영 안 뜨고, 사람은 안 먹었다고 여겨 또 누른다.
453
504
  */
454
505
  const 멈추나 = this.일감.갈래 === '멈춤';
455
- const 갈래 = (!멈추나 && 지난 >= 느긋해질때) ? '느긋' : this.일감.갈래;
506
+ /*
507
+ * 느긋으로 넘어가는 셈은 **턴이 시작된 뒤**가 아니라 **갈래가 바뀐 뒤**로
508
+ * 잰다.
509
+ *
510
+ * 시작부터 재면 45초가 지난 순간부터 턴이 끝날 때까지 갈래가 영영
511
+ * 느긋에 붙박인다 — 그 뒤로 파일을 다섯 개 읽고 세 군데를 고쳐도 화면은
512
+ * 계속 '아직 하는 중' 만 돈다. 로컬 모델은 한 턴이 몇 분이라 사실상 늘
513
+ * 그 상태다. 이 문구를 넣은 뜻이 「아까 하던 말을 계속 돌리면 붙박이처럼
514
+ * 보인다」 였는데, 시작부터 재면 그 붙박이를 이 문구가 만든다.
515
+ */
516
+ const 멎은지 = Date.now() - (this.일감.바뀐때 ?? this.일감.시작);
517
+ const 갈래 = (!멈추나 && 멎은지 >= 느긋해질때) ? '느긋' : this.일감.갈래;
456
518
  const 회차 = Math.floor((this.틱 * 돌림주기) / 문구주기);
457
519
  this.그리기(this.session, '', 0, {
458
520
  // 갈래에 맞는 작은 그림. 무엇을 하는 중인지 글자와 그림이 같이 말한다.
@@ -551,16 +613,34 @@ export class InputBox {
551
613
  *
552
614
  * 높이만 바뀌었다 — 줄 접힘은 폭이 정한다. 폭이 그대로면 접힘도 그대로라
553
615
  * 그린줄·커서위 가 아직 맞다. 평소처럼 제대로 지운다.
554
- * 폭이 바뀌었다 — 옛 수는 다 틀어졌다. 대신 커서 아래를 통째로 쓸어낸다.
555
- * 상자는 늘 맨 아래에 있어서 그 아래에는 아무것도 없다.
556
- * 위로는 올라가니 대화를 건드릴 일이 없다.
616
+ * 폭이 바뀌었다 — 옛 수는 다 틀어졌다. 대신 상자 윗줄로 올라가
617
+ * 거기서 아래를 통째로 쓸어낸다. 상자는 늘 맨 아래에
618
+ * 있어서 아래에는 아무것도 없고, 윗줄보다 위로는
619
+ * 안 올라가니 대화를 건드릴 일이 없다.
557
620
  */
558
621
  const 높이 = process.stdout.rows ?? null;
559
622
  if (this.그린폭 !== 폭 || this.그린높이 !== 높이) {
560
623
  if (this.그린폭 === 폭) {
561
624
  this.지우기();
562
625
  } else {
563
- if (this.그린줄) process.stdout.write('\r\x1b[J');
626
+ /*
627
+ * 쓸어내기 전에 **상자 맨 윗줄로 올라간다.**
628
+ *
629
+ * 커서는 상자 한가운데(아래에서 커서위 번째 줄)에 서 있다. 거기서
630
+ * 곧바로 쓸어내면 커서 아래만 지워지고 **위쪽 절반이 그대로 남는다** —
631
+ * 사무실·상태줄·윗테두리·그 위 입력 줄이 유령으로 붙어 있고, 새 상자는
632
+ * 그 아래에 또 그려진다. 상자가 두 개로 보이고 유령은 대화와 함께 위로
633
+ * 밀려 올라가 영영 안 없어진다. 흉내 화면으로 재 보니 80→60 에서 네 줄
634
+ * (윗테두리·입력 줄이 접힌 것)이 남았다.
635
+ *
636
+ * 올라가는 거리는 **옛 폭에서 세던 그 거리**를 쓴다. 좁아지면 터미널이
637
+ * 다시 접어 상자가 더 여러 줄을 차지하므로 이 거리는 모자랄 수 있는데,
638
+ * 모자라면 유령이 몇 줄 남을 뿐이다. 넘치면 대화를 지운다 — 이 저장소가
639
+ * 제일 나쁜 것으로 꼽는 그 손해다. 그래서 모자라는 쪽으로 잡는다.
640
+ */
641
+ if (this.그린줄) {
642
+ process.stdout.write('\r' + 위로(Math.max(0, this.그린줄 - 1 - this.커서위)) + '\x1b[J');
643
+ }
564
644
  this.앞줄들 = null;
565
645
  this.그린줄 = 0;
566
646
  this.커서위 = 0;
package/src/ui/level.js CHANGED
@@ -58,7 +58,15 @@ export function normalize(v) {
58
58
  '쉬움': '쉬움', '초보': '쉬움', '초보자': '쉬움', 'beginner': '쉬움', 'easy': '쉬움', 'b': '쉬움',
59
59
  '개발자': '개발자', '고급': '개발자', 'developer': '개발자', 'dev': '개발자', 'advanced': '개발자', 'd': '개발자',
60
60
  };
61
- return 별명[s] ?? (LEVELS[v] ? v : null);
61
+ /*
62
+ * 표에 **제 것으로 있는** 열쇠만 본다.
63
+ *
64
+ * `별명[s]` 는 물려받은 열쇠에도 값을 준다. `/level constructor` 면 함수
65
+ * 하나가 수준 이름으로 통과해서, 그 뒤 shows() 가 `lv.show` 를 읽다가
66
+ * 터진다 — 명령 목록을 그리는 자리라 대화가 통째로 끝난다.
67
+ */
68
+ if (Object.hasOwn(별명, s)) return 별명[s];
69
+ return Object.hasOwn(LEVELS, v) ? v : null;
62
70
  }
63
71
 
64
72
  export function get(id) {