deel-local-cli 1.4.0 → 1.4.2

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.
@@ -0,0 +1,252 @@
1
+ /**
2
+ * 사내 문서 읽기 — hwpx · docx · pptx → 글.
3
+ *
4
+ * ── 왜 필요한가 ─────────────────────────────────────────────────────────
5
+ *
6
+ * 사내망의 스펙·공문·회의록이 이 세 형식이다. 지금까지는 Read 가 "바이너리라
7
+ * 못 읽음" 으로 끝났고, 그 끝이 사고를 낳았다 — hwp 를 정리해 달라고 했더니
8
+ * Read 가 실패하자 모델이 Write 로 새로 써서 원본이 죽었다. 덮어쓰기는 이제
9
+ * 막혀 있지만, **읽기가 되면 그 길 자체가 없어진다.** 문서를 주고 "이대로
10
+ * 만들어" 라고 시키는 일이 비로소 된다.
11
+ *
12
+ * 해외 CLI 어느 것도 hwp 계열을 못 읽는다. 이 파일이 있는 자리(한국 사내망)가
13
+ * 이 프로그램이 도는 자리다.
14
+ *
15
+ * ── 왜 의존성 없이 되나 ─────────────────────────────────────────────────
16
+ *
17
+ * 셋 다 속은 ZIP + XML 이다(hwpx 는 한컴의 공개 규격 OWPML).
18
+ * ZIP 은 우리 zip 읽개(pack/zip.js)로, XML 은 xlsx 가 쓰는 작은 읽기(tags)로
19
+ * 푼다. 새로 들이는 것이 없다.
20
+ *
21
+ * hwpx Contents/section*.xml 글은 <hp:t>, 문단은 <hp:p>, 표는 <hp:tbl>
22
+ * docx word/document.xml 글은 <w:t>, 문단은 <w:p>, 표는 <w:tbl>
23
+ * pptx ppt/slides/slide*.xml 글은 <a:t>, 문단은 <a:p>, 장이 구획
24
+ *
25
+ * ── 읽기만 한다 ─────────────────────────────────────────────────────────
26
+ *
27
+ * 엑셀과 같은 이유로 고치기는 안 한다. 서식·그림·양식이 든 문서를 글로
28
+ * 왕복시키면 반드시 뭔가 잃는다. 잃은 채로 저장된 문서는 겉보기에 멀쩡해서,
29
+ * 잃은 것을 알아차렸을 때는 원본이 없다.
30
+ *
31
+ * 구형 hwp(OLE 복합문서)는 아예 다른 물건이라 여기서 안 다룬다. 대신 못
32
+ * 읽는다고 말할 때 **어떻게 하면 되는지**(한글에서 hwpx 로 저장)를 같이 준다 —
33
+ * 길 없는 거절은 모델을 우회로(새로 쓰기)로 몬다.
34
+ */
35
+ import { readFileSync } from 'node:fs';
36
+ import { extname, basename } from 'node:path';
37
+ import { readZip, looksZip } from '../pack/zip.js';
38
+ import { tags, unescapeXml } from './xlsx.js';
39
+
40
+ const 확장자 = { '.hwpx': 'hwpx', '.docx': 'docx', '.pptx': 'pptx' };
41
+
42
+ /** 이 길로 읽는 파일인가. 구형 .hwp 는 아니다 — 그건 별도 안내 대상이다. */
43
+ export function isDocPath(p) {
44
+ if (typeof p !== 'string' || !p) return false;
45
+ return extname(p).toLowerCase() in 확장자;
46
+ }
47
+
48
+ /** 어떤 문서인가. */
49
+ export function 종류(p) {
50
+ return 확장자[extname(String(p ?? '')).toLowerCase()] ?? null;
51
+ }
52
+
53
+ /** 구형 hwp 인가 (OLE 복합문서 서명). Read 가 안내문을 고를 때 쓴다. */
54
+ export function looksOldHwp(경로, buf) {
55
+ if (extname(String(경로 ?? '')).toLowerCase() !== '.hwp') return false;
56
+ return buf.length >= 8 && buf.readUInt32LE(0) === 0xe011cfd0 && buf.readUInt32LE(4) === 0xe11ab1a1;
57
+ }
58
+
59
+ /** 구형 hwp 를 만났을 때 하는 말. 길을 같이 준다. */
60
+ export function 옛hwp안내(보인이름) {
61
+ return `구형 hwp 형식이라 읽지 못합니다: ${보인이름}\n`
62
+ + ' 한글(한컴오피스)에서 이 파일을 열어 **hwpx 로 저장**하면 그대로 읽을 수 있습니다.\n'
63
+ + ' (다른 이름으로 저장 → 파일 형식에서 hwpx 선택)\n'
64
+ + ' 이 파일을 Write 로 새로 만들면 안 됩니다 — 원본이 사라집니다.';
65
+ }
66
+
67
+ /*
68
+ * ── XML → 문단들 ────────────────────────────────────────────────────────
69
+ *
70
+ * 형식마다 태그 이름만 다르고 뼈대는 같다: 문단 태그 사이의 글 태그를 모으고,
71
+ * 표 칸은 | 로 잇는다. 그래서 이름표만 형식별로 두고 걷는 것은 하나다.
72
+ *
73
+ * 이름 앞머리(hp: · w: · a:)는 문서마다 접두어가 다를 수 있어 **끝 이름**으로
74
+ * 견준다. 접두어는 XML 선언부가 정하는 별명일 뿐이라 값이 고정이 아니다.
75
+ */
76
+ const 이름표 = {
77
+ hwpx: { 글: 't', 문단: 'p', 표: 'tbl', 행: 'tr', 칸: 'tc', 줄바꿈: null, 탭: null },
78
+ docx: { 글: 't', 문단: 'p', 표: 'tbl', 행: 'tr', 칸: 'tc', 줄바꿈: 'br', 탭: 'tab' },
79
+ pptx: { 글: 't', 문단: 'p', 표: 'tbl', 행: 'tr', 칸: 'tc', 줄바꿈: 'br', 탭: null },
80
+ };
81
+
82
+ const 끝이름 = (name) => {
83
+ const i = name.indexOf(':');
84
+ return i < 0 ? name : name.slice(i + 1);
85
+ };
86
+
87
+ /**
88
+ * XML 한 장을 문단 목록으로.
89
+ *
90
+ * 표는 행마다 한 줄로 펴고 칸을 ` | ` 로 잇는다. 칸을 그냥 이어 붙이면
91
+ * "이름값가1" 같은 덩어리가 되는데, 그건 뜻이 사라진 글이라 안 읽은 것보다
92
+ * 나쁘다 — 모델이 그걸 근거로 답한다.
93
+ */
94
+ export function 문단뽑기(xml, 갈래) {
95
+ const 표기 = 이름표[갈래];
96
+ const 문단들 = [];
97
+
98
+ let 글모음 = []; // 지금 문단의 글 조각들
99
+ let 표깊이 = 0;
100
+ let 행칸들 = null; // 표 행 안일 때: 칸 글들
101
+ let 칸글 = null; // 표 칸 안일 때: 그 칸의 글 조각들
102
+ let 글안 = false; // <t> 안인가 — 글 태그 밖의 지시문·수식 글을 안 줍기 위해
103
+
104
+ const 문단닫기 = () => {
105
+ const 글 = 글모음.join('').trimEnd();
106
+ 글모음 = [];
107
+ if (글.trim()) 문단들.push(글);
108
+ };
109
+
110
+ for (const t of tags(xml)) {
111
+ if (t.text !== undefined) {
112
+ if (!글안 || t.blank) continue;
113
+ const 글 = unescapeXml(t.text);
114
+ if (칸글) 칸글.push(글);
115
+ else 글모음.push(글);
116
+ continue;
117
+ }
118
+ const 이름 = 끝이름(t.name);
119
+
120
+ if (이름 === 표기.글) { 글안 = !t.closing && !t.selfClosing; continue; }
121
+ if (표기.탭 && 이름 === 표기.탭 && !t.closing) { (칸글 ?? 글모음).push('\t'); continue; }
122
+ if (표기.줄바꿈 && 이름 === 표기.줄바꿈 && !t.closing) { (칸글 ?? 글모음).push('\n'); continue; }
123
+
124
+ if (이름 === 표기.표) { 표깊이 += t.closing ? -1 : (t.selfClosing ? 0 : 1); continue; }
125
+ if (표깊이 > 0 && 이름 === 표기.행) {
126
+ if (t.closing) {
127
+ if (행칸들) 문단들.push(행칸들.map((x) => x.trim()).join(' | '));
128
+ 행칸들 = null;
129
+ } else {
130
+ 행칸들 = [];
131
+ }
132
+ continue;
133
+ }
134
+ if (표깊이 > 0 && 이름 === 표기.칸) {
135
+ if (t.closing) {
136
+ if (행칸들 && 칸글) 행칸들.push(칸글.join(''));
137
+ 칸글 = null;
138
+ } else {
139
+ 칸글 = [];
140
+ }
141
+ continue;
142
+ }
143
+
144
+ if (이름 === 표기.문단 && t.closing && !칸글) 문단닫기();
145
+ }
146
+ 문단닫기(); // 안 닫힌 채 끝나는 문서도 있다. 마지막 글을 버리지 않는다.
147
+ return 문단들;
148
+ }
149
+
150
+ /*
151
+ * 형식별로 어느 파일이 알맹이인가.
152
+ *
153
+ * 이름을 **숫자로** 세운다. 글자로 세우면 slide10 이 slide2 앞에 온다 —
154
+ * 장 차례가 뒤섞인 발표 자료는 안 읽은 것보다 나쁘다.
155
+ */
156
+ const 알맹이 = {
157
+ hwpx: { 골라 : /^Contents\/section(\d+)\.xml$/i, 구획이름: (n) => `구획 ${n + 1}` },
158
+ docx: { 골라 : /^word\/document\.xml$/i, 구획이름: () => '본문' },
159
+ pptx: { 골라 : /^ppt\/slides\/slide(\d+)\.xml$/i, 구획이름: (n, 번호) => `${번호}장` },
160
+ };
161
+
162
+ /**
163
+ * 문서 하나를 읽는다.
164
+ *
165
+ * @returns {{ok:true, 갈래, 덩이들:[{이름, 문단들}]} | {ok:false, error}}
166
+ * 던지지 않는다 — 깨진 파일은 도구 실행 한가운데서 만나는 것이라,
167
+ * 예외가 나면 "문서가 깨졌다" 가 "도구가 터졌다" 로 보고된다.
168
+ */
169
+ export function readDoc(경로또는버퍼) {
170
+ const 갈래 = Buffer.isBuffer(경로또는버퍼) ? null : 종류(경로또는버퍼);
171
+ let buf;
172
+ try {
173
+ buf = Buffer.isBuffer(경로또는버퍼) ? 경로또는버퍼 : readFileSync(경로또는버퍼);
174
+ } catch (err) {
175
+ return { ok: false, error: `못 읽었습니다: ${err.message}` };
176
+ }
177
+ if (!갈래) return { ok: false, error: '어떤 문서인지 모르는 경로입니다' };
178
+ if (!looksZip(buf)) {
179
+ return { ok: false, error: `${갈래} 모양이 아닙니다 — 겉은 ${갈래} 인데 속이 zip 꾸러미가 아닙니다. 깨졌거나 다른 형식입니다.` };
180
+ }
181
+
182
+ let 꾸러미;
183
+ try {
184
+ 꾸러미 = readZip(buf);
185
+ } catch (err) {
186
+ return { ok: false, error: `꾸러미를 풀지 못했습니다 — ${err.message}` };
187
+ }
188
+
189
+ const { 골라, 구획이름 } = 알맹이[갈래];
190
+ const 찾은 = [];
191
+ for (const [이름, 몸] of 꾸러미.files) {
192
+ const m = 골라.exec(이름.replace(/\\/g, '/'));
193
+ if (m) 찾은.push({ 번호: m[1] ? Number(m[1]) : 0, 몸 });
194
+ }
195
+ if (!찾은.length) {
196
+ return { ok: false, error: `${갈래} 꾸러미인데 본문을 찾지 못했습니다. 깨졌거나 비정상 파일입니다.` };
197
+ }
198
+ 찾은.sort((a, b) => a.번호 - b.번호);
199
+
200
+ const 덩이들 = 찾은.map((s, i) => ({
201
+ 이름: 구획이름(i, s.번호),
202
+ 문단들: 문단뽑기(s.몸.toString('utf8'), 갈래),
203
+ }));
204
+ return { ok: true, 갈래, 덩이들 };
205
+ }
206
+
207
+ // 다 실어 봐야 창만 찬다. 엑셀(toText)과 같은 상한을 쓴다.
208
+ const 최대글자 = 60000;
209
+
210
+ /**
211
+ * 덩이들을 한 장의 글로.
212
+ *
213
+ * 자르면 잘랐다고 말한다 — 조용히 자르면 모델은 그게 전부인 줄 알고
214
+ * "문서에 그런 내용 없다" 고 답한다.
215
+ */
216
+ export function toText(덩이들, { maxChars = 최대글자 } = {}) {
217
+ const 잘림 = [];
218
+ const 조각 = [];
219
+ let 셈 = 0;
220
+ let 여럿 = (덩이들?.length ?? 0) > 1;
221
+
222
+ for (const d of 덩이들 ?? []) {
223
+ if (여럿) 조각.push(`--- ${d.이름} ---`);
224
+ for (const 문단 of d.문단들) {
225
+ if (셈 + 문단.length > maxChars) {
226
+ 잘림.push(`${maxChars.toLocaleString()}자에서 잘랐습니다 — 뒷부분은 안 실렸습니다`);
227
+ return { text: 조각.join('\n'), 잘림 };
228
+ }
229
+ 조각.push(문단);
230
+ 셈 += 문단.length + 1;
231
+ }
232
+ }
233
+ return { text: 조각.join('\n'), 잘림 };
234
+ }
235
+
236
+ /** 한 줄 요약. Read 의 summary 자리로 간다. */
237
+ export function summarize(r) {
238
+ if (!r?.ok) return '';
239
+ const 문단수 = r.덩이들.reduce((n, d) => n + d.문단들.length, 0);
240
+ const 구획 = r.덩이들.length > 1 ? ` · ${r.갈래 === 'pptx' ? `${r.덩이들.length}장` : `구획 ${r.덩이들.length}개`}` : '';
241
+ return `${r.갈래}${구획} · ${문단수}문단`;
242
+ }
243
+
244
+ /** 고치려 들 때 하는 말. 엑셀과 같은 꼴 — 왜 안 되는지와 그럼 어떻게 하는지. */
245
+ export function 문서는못고침(보인이름) {
246
+ const 갈 = 종류(보인이름) ?? '문서';
247
+ return `${갈} 문서는 이 도구로 고칠 수 없습니다: ${보인이름}\n`
248
+ + ' 읽기만 됩니다 (글로 바꿔서 보여줍니다). 서식·그림·양식이 든 문서를\n'
249
+ + ' 글로 왕복시키면 반드시 뭔가 잃기 때문입니다.\n'
250
+ + ` 내용을 바꿔야 한다면 ${basename(String(보인이름))} 은 그대로 두고, 바뀐 내용을\n`
251
+ + ' 글 파일(.md 등)로 따로 만들어 사용자에게 건네세요.';
252
+ }
@@ -20,6 +20,7 @@ import { 프로젝트갈래 } from '../lsp/servers.js';
20
20
  import { allow as allowedIn } from '../agent/modes.js';
21
21
  import { 도구정의, 이름풀기 } from '../backend/mcp.js';
22
22
  import { isExcelPath, readExcel, toText as excelText, summarize as excelSummary } from './excel.js';
23
+ import { isDocPath, readDoc, toText as docText, summarize as docSummary, looksOldHwp, 옛hwp안내, 문서는못고침 } from './docs.js';
23
24
  import { diffLines } from '../ui/diff.js';
24
25
  import { 읽을줄수, 찾을개수, 찾을줄수, 설명길이 } from '../agent/budget.js';
25
26
  import { 도구설명EN } from './desc.en.js';
@@ -235,6 +236,28 @@ async function 엑셀읽기(abs, args, ctx) {
235
236
  }
236
237
 
237
238
 
239
+ /**
240
+ * 문서(hwpx·docx·pptx)를 글로 읽어 돌려준다.
241
+ *
242
+ * 엑셀읽기와 같은 규칙 — ctx.seen 에 안 넣는다. 넣으면 Edit 이 '이 파일 고칠
243
+ * 수 있다' 고 오해한다. 문서는 이 도구로 고치는 물건이 아니다.
244
+ */
245
+ function 문서읽기(abs, ctx) {
246
+ const r = readDoc(abs);
247
+ if (!r.ok) return { error: r.error };
248
+ const { text, 잘림 } = docText(r.덩이들);
249
+ return {
250
+ content: clip(
251
+ `${text || '(빈 문서입니다 — 글이 없습니다.)'}
252
+
253
+ (${r.갈래} 문서를 글로 바꿔서 보여준 것입니다. 이 파일은 Edit/Write 로 고칠 수 없습니다.)`
254
+ + (잘림.length ? `
255
+ (${잘림.join(' · ')})` : ''),
256
+ ),
257
+ summary: docSummary(r) + (잘림.length ? ' · 일부만' : ''),
258
+ };
259
+ }
260
+
238
261
  /**
239
262
  * 파일 하나를 쓴다 — Write 의 알맹이.
240
263
  *
@@ -252,6 +275,9 @@ function 한파일쓰기(args, ctx) {
252
275
  // 엑셀 파일을 통째로 덮어쓰면 xlsx 가 아니라 그냥 글 파일이 된다.
253
276
  // 열리지도 않는 파일이 되고, 원본은 이미 없다. 아예 막는다.
254
277
  if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
278
+ // 문서(hwpx·docx·pptx)도 같은 이유로 또렷하게 거절한다. 일반 '바이너리'
279
+ // 오류로 넘기면 왜 안 되는지가 안 실려서, 모델이 우회로를 찾는다.
280
+ if (isDocPath(abs)) return { error: 문서는못고침(args.file_path) };
255
281
  // 엑셀만 막아서는 모자란다. hwp·pdf·png·zip 도 똑같이 그 순간 끝난다.
256
282
  // 게다가 이런 파일은 되돌리기가 내용을 떠 놓지 못하는 종류라 되살릴 길이 없다.
257
283
  // 확장자로 고르지 않고 실제 내용으로 본다 — 사내 파일은 확장자가 제각각이다.
@@ -349,6 +375,7 @@ function 한군데고치기(args, ctx) {
349
375
  // 엑셀 파일은 Read 로 읽히긴 하지만 고칠 수 있는 물건이 아니다.
350
376
  // '먼저 Read 로 읽어야 합니다' 라고만 하면 이미 읽은 쪽은 계속 헛돈다.
351
377
  if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
378
+ if (isDocPath(abs)) return { error: 문서는못고침(args.file_path) };
352
379
  if (!ctx.seen.has(abs)) return { error: `먼저 Read 로 읽어야 합니다: ${args.file_path}` };
353
380
  if (args.old_string === args.new_string) return { error: 'old_string 과 new_string 이 같습니다' };
354
381
 
@@ -468,7 +495,8 @@ export const TOOLS = {
468
495
  name: 'Read',
469
496
  description: '파일 하나를 읽는다. 줄 번호가 붙어 돌아온다. 고치기 전에는 반드시 먼저 읽어야 한다.'
470
497
  + ' 엑셀 파일(.xlsx/.xlsm/.xls)도 그대로 읽을 수 있다 — 시트별 CSV 로 바꿔서 돌려준다.'
471
- + ' 사용자에게 CSV 내보내 달라고 필요가 없다. 다만 엑셀 파일은 읽기만 되고 고칠 수는 없다.',
498
+ + ' 한글·워드·파워포인트 문서(.hwpx/.docx/.pptx)도 그대로 읽는다 글로 바꿔서 돌려준다.'
499
+ + ' 사용자에게 다른 형식으로 내보내 달라고 할 필요가 없다. 다만 이런 파일들은 읽기만 되고 고칠 수는 없다.',
472
500
  parameters: {
473
501
  type: 'object',
474
502
  properties: {
@@ -490,6 +518,19 @@ export const TOOLS = {
490
518
  // 엑셀 파일은 글이 아니라 압축 꾸러미다. 그냥 읽으면 '바이너리' 로 끝난다.
491
519
  // 여기서 표로 바꿔 돌려준다 — 사람이 손으로 CSV 로 내보낼 일이 없게.
492
520
  if (isExcelPath(abs)) return 엑셀읽기(abs, args, ctx);
521
+ // hwpx·docx·pptx 도 같다 — 속이 ZIP+XML 이라 글로 바꿔 돌려준다 (docs.js).
522
+ if (isDocPath(abs)) return 문서읽기(abs, ctx);
523
+ /*
524
+ * 구형 hwp 는 '바이너리' 로 끝내지 않는다. 그 오류에는 길이 없어서
525
+ * 모델이 우회로(새로 쓰기)를 찾는다 — 실제로 그렇게 원본이 죽은 적이
526
+ * 있다. 여기서는 hwpx 로 저장하면 읽힌다는 길을 같이 준다.
527
+ */
528
+ if (abs.toLowerCase().endsWith('.hwp')) {
529
+ try {
530
+ const 머리 = readFileSync(abs);
531
+ if (looksOldHwp(abs, 머리)) return { error: 옛hwp안내(ctx.scope.show(abs)) };
532
+ } catch { /* 아래 일반 읽기가 제 오류를 낸다 */ }
533
+ }
493
534
 
494
535
  const 읽음 = readTextFull(abs);
495
536
  // 무엇으로 읽었는지 기억해 둔다. 나중에 고칠 때 같은 것으로 되돌려 써야 한다.
@@ -91,15 +91,29 @@ function 얼마나쉬라나(머리, 회차) {
91
91
  }
92
92
 
93
93
  function 태그벗기기(html) {
94
- return html
95
- .replace(/<script[\s\S]*?<\/script>/gi, ' ')
96
- .replace(/<style[\s\S]*?<\/style>/gi, ' ')
97
- .replace(/<!--[\s\S]*?-->/g, ' ')
94
+ let 글 = String(html);
95
+ // script·style·주석을 겹쳐 쓰거나 안 닫은 모양으로 흘려 보내는 페이지가
96
+ // 있다 — 정규식 한 번으로는 다 못 걷어 낸다(파서가 아니라서). 더 지울 것이
97
+ // 없어질 때까지 반복한다. 그래도 이 글은 모델에게 주는 참고용 글일 뿐
98
+ // 화면에 그리지 않으니, 여기서 다 못 걸러도 실행되는 것은 아니다.
99
+ for (let i = 0; i < 5; i += 1) {
100
+ const 전 = 글;
101
+ 글 = 글
102
+ .replace(/<script[\s\S]*?<\/script\s*>/gi, ' ')
103
+ .replace(/<style[\s\S]*?<\/style\s*>/gi, ' ')
104
+ .replace(/<!--[\s\S]*?-->/g, ' ');
105
+ if (글 === 전) break;
106
+ }
107
+ return 글
98
108
  .replace(/<\/(p|div|section|article|li|tr|h[1-6]|br)>/gi, '\n')
99
109
  .replace(/<br\s*\/?>/gi, '\n')
100
110
  .replace(/<[^>]+>/g, ' ')
101
- .replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<')
102
- .replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
111
+ // 번에 찾아서 한 번에 바꾼다 — 차례로 바꾸면(`&amp;` 먼저 `&` 로
112
+ // 풀고 나서 `&lt;` 를 다시 찾는 식) `&amp;lt;` 처럼 두 겹 씌운 것이
113
+ // 두 번 풀려서 `<` 로 튀어나온다(글자로 남아야 하는데 태그처럼 보이게 됨).
114
+ .replace(/&(nbsp|amp|lt|gt|quot|#39);/g, (_, 이름) => ({
115
+ nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'",
116
+ }[이름]))
103
117
  .replace(/[ \t]+/g, ' ')
104
118
  .replace(/\n\s*\n\s*\n+/g, '\n\n')
105
119
  .trim();
package/src/ui/ansi.js CHANGED
@@ -49,6 +49,15 @@ export const c = {
49
49
  bgGray: E(100),
50
50
  };
51
51
 
52
+ /**
53
+ * 256색 한 칸. 큰 글자(intro.js)의 깊이를 낼 때 쓴다.
54
+ *
55
+ * 옛 콘솔(TERM=dumb)은 256색을 모른다. 거기서는 번호가 글에 그대로 찍히므로
56
+ * 아무 색도 안 입힌다 — 색이 없는 것보다 숫자가 새는 것이 훨씬 나쁘다.
57
+ */
58
+ export const 색256가능 = () => process.env.TERM !== 'dumb';
59
+ export const 번호색 = (n) => (s) => (ON && 색256가능() ? `[38;5;${n}m${s}` : String(s));
60
+
52
61
  export const cursor = {
53
62
  hide: () => ON && process.stdout.write('\x1b[?25l'),
54
63
  show: () => ON && process.stdout.write('\x1b[?25h'),
@@ -0,0 +1,245 @@
1
+ /**
2
+ * 켤 때 뜨는 큰 이름.
3
+ *
4
+ * ── 왜 남들처럼 안 만들었나 ─────────────────────────────────────────────
5
+ *
6
+ * 요즘 CLI 는 켤 때 큼직한 글자를 띄운다. 무지개 그러데이션이 흔하다.
7
+ * 예쁘긴 한데 그건 **아무 말도 안 한다** — 파랑에서 분홍으로 가는 데 뜻이 없다.
8
+ *
9
+ * 여기서는 그 자리를 뜻으로 채운다. 이 프로그램이 하는 말이 딱 하나인데,
10
+ * **네 소스가 이 컴퓨터 밖으로 안 나간다** 는 것이다. 그래서 —
11
+ *
12
+ * · 글자 밑으로 **선이 닫힌다.** 그 선이 경계다.
13
+ * · 선의 **색이 지금 어디까지 나가는지**를 말한다.
14
+ * 초록 ⌂ 이 컴퓨터 안에서만
15
+ * 노랑 ↗ 사내 게이트웨이로 나간다
16
+ * · 글자의 밝기는 왼쪽이 어둡고 오른쪽이 밝다. 이름이 왼쪽부터 한 자씩
17
+ * 들어오는 것과 같은 방향이라, 자라는 것으로 보인다.
18
+ *
19
+ * 표시(⌂ · ↗)는 상태줄이 쓰는 것과 **같은 것**이다(ui/status.js 의 경계표).
20
+ * 켤 때 본 것이 그대로 아래 줄에 남아 있어야, 그게 장식이 아니라 계기판이 된다.
21
+ *
22
+ * ── 왜 소문자 `deel` 인가 ───────────────────────────────────────────────
23
+ *
24
+ * 사람이 치는 글자가 그것이다. 큰 대문자로 바꾸면 화면에 뜨는 이름과 손이
25
+ * 치는 이름이 달라진다. 게다가 대문자 배너는 어느 CLI 나 하는 것이라,
26
+ * 소문자 쪽이 오히려 이 프로그램의 얼굴이 된다.
27
+ *
28
+ * ── 폭 ─────────────────────────────────────────────────────────────────
29
+ *
30
+ * 두 크기로 그린다. 넓으면 가로로 두 배 늘려 큼직하게, 좁으면 한 배로.
31
+ * 더 좁으면 아예 안 쓴다 — 그때는 작은 모션(intro.js)이 대신 돈다.
32
+ * 글자가 접히면 무슨 그림인지 알아볼 수가 없어서, 큰 것을 억지로 밀어 넣는
33
+ * 것보다 작은 것을 제대로 보이는 편이 낫다.
34
+ *
35
+ * ── 왜 이 글자들만 쓰나 ─────────────────────────────────────────────────
36
+ *
37
+ * 채움은 █ 하나, 테는 ╭╮╰╯─ 뿐이다. 이모지와 기호는 터미널마다 한 칸이
38
+ * 되기도 두 칸이 되기도 해서, 다시 그릴 때 지울 폭이 어긋나고 앞 그림이
39
+ * 조각으로 남는다. 여기 쓰는 것들은 입력 상자가 이미 쓰고 있는 것이라
40
+ * 이 프로그램이 도는 자리에서는 이미 한 번 검증된 셈이다.
41
+ */
42
+ import { c, 번호색, 색256가능, width } from './ansi.js';
43
+
44
+ /*
45
+ * 글꼴. 한 글자가 일곱 줄이다.
46
+ *
47
+ * 값으로 적어 둔다. 그려 놓고 눈으로만 맞추면 나중에 아무도 못 고치고,
48
+ * 검사도 '무엇이 보여야 하나' 를 말할 수가 없다.
49
+ *
50
+ * 소문자라 글자마다 높이가 다르다 — d 와 l 은 위로 솟고(r0 부터), e 는
51
+ * 허리 높이에서 시작한다(r2 부터). 그 들쭉날쭉함이 소문자를 소문자로 보이게
52
+ * 하는 것이라, 높이를 맞추려고 e 를 끌어올리면 안 된다.
53
+ */
54
+ export const 글꼴 = {
55
+ d: [
56
+ '......##',
57
+ '......##',
58
+ '.#######',
59
+ '##....##',
60
+ '##....##',
61
+ '##....##',
62
+ '.#######',
63
+ ],
64
+ e: [
65
+ '........',
66
+ '........',
67
+ '.######.',
68
+ '##....##',
69
+ '########',
70
+ '##......',
71
+ '.######.',
72
+ ],
73
+ l: [
74
+ '##...',
75
+ '##...',
76
+ '##...',
77
+ '##...',
78
+ '##...',
79
+ '##...',
80
+ '.####',
81
+ ],
82
+ };
83
+
84
+ export const 이름 = 'deel';
85
+ export const 높이 = 7;
86
+ /*
87
+ * 글자 사이. 한 칸이다.
88
+ *
89
+ * 두 칸으로 뒀더니 `d e e l` 로 읽혔다 — 글꼴 자체가 둥근 획 쪽을 한 칸 안으로
90
+ * 들여 그리기 때문에, 거기에 두 칸을 더 주면 실제로는 네댓 칸이 벌어진다.
91
+ * 큰 글자에서는 이게 낱자 넷으로 보이지 한 낱말로 안 보인다.
92
+ */
93
+ const 사이 = 1;
94
+ const 들여 = ' ';
95
+ const 채움 = '█';
96
+
97
+ /** 글자 하나가 몇 칸인가 (한 배 기준). */
98
+ export const 글자폭 = (ch) => (글꼴[ch]?.[0]?.length ?? 0);
99
+
100
+ /** 이름 전체가 몇 칸인가. @param 배 가로 배율(1 또는 2) */
101
+ export function 이름폭(배 = 1, 글자수 = 이름.length) {
102
+ const 글자들 = [...이름].slice(0, 글자수);
103
+ if (!글자들.length) return 0;
104
+ const 속 = 글자들.reduce((n, ch) => n + 글자폭(ch), 0) + 사이 * (글자들.length - 1);
105
+ return 속 * 배;
106
+ }
107
+
108
+ /*
109
+ * 색.
110
+ *
111
+ * 왼쪽이 어둡고 오른쪽이 밝다. 글자가 왼쪽부터 들어오는 것과 같은 방향이라
112
+ * 이름이 **자라는** 것으로 보인다. 무지개를 안 쓰는 이유는 위 머리말에 있다 —
113
+ * 여기 네 칸은 다 같은 색이고 밝기만 다르다. 한 가지 색이라야 그 색이
114
+ * 무언가를 뜻할 수 있다.
115
+ */
116
+ // 어두운 배경에서 첫 글자도 읽혀야 한다. 어두운 데서 시작하는 폭넓은 층은
117
+ // 그림으로는 예쁜데 왼쪽 글자가 배경에 묻힌다 — 이름이 세 글자로 보인다.
118
+ const 안색 = [34, 40, 46, 82]; // 초록 — 이 컴퓨터 안
119
+ const 밖색 = [172, 214, 220, 226]; // 노랑 — 밖으로 나간다
120
+
121
+ /** 경계 표시. 상태줄(ui/status.js 의 경계표)과 같은 것을 쓴다. */
122
+ export const 경계글 = (바깥) => (바깥 ? '↗' : '⌂');
123
+
124
+ /**
125
+ * 이름을 그린다.
126
+ *
127
+ * @param {number} 글자수 왼쪽부터 몇 글자까지 보일지 (모션이 여기를 늘린다)
128
+ * @param {object} o
129
+ * @param {boolean} [o.바깥]
130
+ * @param {number} [o.배] 가로 배율
131
+ * @param {number} [o.밝힐] 이 번째 글자만 굵게 (방금 들어온 글자)
132
+ * @returns {string[]} 언제나 높이 줄
133
+ */
134
+ export function 이름줄들(글자수, { 바깥 = false, 배 = 2, 밝힐 = -1 } = {}) {
135
+ const 층 = 바깥 ? 밖색 : 안색;
136
+ const 낱개 = [...이름];
137
+ const 줄들 = [];
138
+
139
+ for (let r = 0; r < 높이; r++) {
140
+ let 줄 = 들여;
141
+ for (let i = 0; i < 낱개.length; i++) {
142
+ const ch = 낱개[i];
143
+ const 폭 = 글자폭(ch) * 배;
144
+ if (i > 0) 줄 += ' '.repeat(사이 * 배);
145
+ if (i >= 글자수) { 줄 += ' '.repeat(폭); continue; }
146
+
147
+ // 한 칸을 배만큼 늘린다. 터미널 한 칸은 세로로 길어서, 가로로 늘려야
148
+ // 글자가 눌린 것처럼 안 보인다.
149
+ const 그림 = [...글꼴[ch][r]].map((x) => (x === '#' ? 채움.repeat(배) : ' '.repeat(배))).join('');
150
+ const 칠 = 색256가능() ? 번호색(층[Math.min(i, 층.length - 1)]) : (바깥 ? c.hyellow : c.hgreen);
151
+ 줄 += i === 밝힐 ? c.bold(칠(그림)) : 칠(그림);
152
+ }
153
+ 줄들.push(줄);
154
+ }
155
+ return 줄들;
156
+ }
157
+
158
+ /**
159
+ * 이름 밑에서 닫히는 선.
160
+ *
161
+ * 왼쪽 끝에서 시작해 오른쪽으로 자란다. 다 자라면 양끝이 위로 꺾여 **닫힌다** —
162
+ * 그 꺾임이 이 그림의 전부다. 아직 자라는 중에는 오른쪽 끝을 안 꺾는다.
163
+ * 안 닫힌 것을 닫힌 것처럼 그리면 그 순간만 거짓말이 된다.
164
+ *
165
+ * @param {number} 몫 0~1. 얼마나 자랐나
166
+ */
167
+ export function 선줄(몫, { 바깥 = false, 배 = 2, 곁말 = '' } = {}) {
168
+ const 색 = 바깥 ? c.hyellow : c.hgreen;
169
+ const 끝까지 = 이름폭(배);
170
+ const 잰것 = Math.max(0, Math.min(끝까지, Math.round(끝까지 * Math.max(0, Math.min(1, 몫)))));
171
+ if (잰것 <= 0) return '';
172
+
173
+ const 다자람 = 잰것 >= 끝까지;
174
+ // 양끝 꺾임까지 해서 전체 폭을 지킨다 — 꺾임이 붙으면서 줄이 길어지면
175
+ // 다시 그릴 때 지울 폭이 어긋나고 앞 그림이 조각으로 남는다.
176
+ const 속 = '─'.repeat(Math.max(0, 잰것 - 2));
177
+ const 선 = 다자람 ? `╰${속}╯` : `╰${속}${'─'.repeat(Math.min(2, 잰것 - 속.length))}`;
178
+
179
+ const 곁 = 다자람 && 곁말
180
+ ? ` ${색(경계글(바깥))} ${c.gray(곁말)}`
181
+ : '';
182
+ return `${들여}${색(선)}${곁}`;
183
+ }
184
+
185
+ /**
186
+ * 한 틀. 언제나 **높이 + 1 줄**이다.
187
+ *
188
+ * 줄 수가 틀마다 달라지면 다시 그릴 때 몇 줄을 올려야 하는지가 틀마다 달라지고,
189
+ * 한 번 어긋나면 앞 그림이 화면에 조각으로 남는다. 그래서 아직 안 그린 선도
190
+ * 빈 줄로 자리를 잡아 둔다. (intro.js 의 작은 모션과 같은 규칙이다)
191
+ */
192
+ export function 틀(n, { 바깥 = false, 배 = 2, 곁말 = '' } = {}) {
193
+ const f = 차례[Math.max(0, Math.min(차례.length - 1, Math.trunc(Number(n) || 0)))];
194
+ return [
195
+ ...이름줄들(f.글자, { 바깥, 배, 밝힐: f.밝힐 }),
196
+ 선줄(f.선, { 바깥, 배, 곁말 }),
197
+ ];
198
+ }
199
+
200
+ /*
201
+ * 틀 차례.
202
+ *
203
+ * 1) 이름이 한 자씩 들어온다. 방금 들어온 글자만 굵다 — 다 같은 밝기로 그리면
204
+ * 글자가 '길어지는' 것으로만 보이고, 한 자가 한 틀 동안 도드라졌다
205
+ * 가라앉아야 **찍히는** 것으로 보인다.
206
+ * 2) 한 박자 쉰다. 이 쉼이 다음에 그어지는 선을 눈에 띄게 한다.
207
+ * 3) 선이 그어진다.
208
+ * 4) 닫히고 나서야 곁말이 붙는다. 순서가 뜻이다 — 닫힌 다음에 하는 말이라야
209
+ * 믿을 만하다.
210
+ */
211
+ function 차례짓기() {
212
+ const out = [];
213
+ for (let i = 1; i <= 이름.length; i++) out.push({ 글자: i, 밝힐: i - 1, 선: 0 });
214
+ out.push({ 글자: 이름.length, 밝힐: -1, 선: 0 });
215
+ for (const 몫 of [0.2, 0.45, 0.7, 0.9]) out.push({ 글자: 이름.length, 밝힐: -1, 선: 몫 });
216
+ out.push({ 글자: 이름.length, 밝힐: -1, 선: 1 });
217
+ return out;
218
+ }
219
+
220
+ const 차례 = 차례짓기();
221
+
222
+ export const 틀수 = 차례.length;
223
+ export const 줄수 = 높이 + 1;
224
+
225
+ /** 다 돈 마지막 모습. 모션을 껐을 때 이것만 찍는다. */
226
+ export function 마지막틀(opts = {}) { return 틀(틀수 - 1, opts); }
227
+
228
+ /**
229
+ * 이 폭에서 큰 이름을 쓸 수 있나. 못 쓰면 0.
230
+ *
231
+ * 억지로 밀어 넣지 않는다. 글자가 접히면 무슨 그림인지 알아볼 수가 없고,
232
+ * 그건 아무것도 안 그린 것보다 나쁘다 — 화면이 깨진 것으로 보인다.
233
+ *
234
+ * @returns {number} 쓸 수 있는 가로 배율 (2 · 1), 못 쓰면 0
235
+ */
236
+ export function 쓸배율(칸) {
237
+ const w = Number.isFinite(칸) ? 칸 : (process.stdout.columns ?? 80);
238
+ // 들여쓰기 두 칸 + 오른쪽 한 칸 여유.
239
+ if (w >= 이름폭(2) + 4) return 2;
240
+ if (w >= 이름폭(1) + 4) return 1;
241
+ return 0;
242
+ }
243
+
244
+ // 폭 계산이 어긋나지 않는지 부르는 쪽에서 재 볼 수 있게 열어 둔다.
245
+ export const 잰너비 = (배 = 2) => width(이름줄들(이름.length, { 배 })[3].replace(/\x1b\[[0-9;]*m/g, ''));