deel-local-cli 1.15.1 → 1.17.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.
- package/README.ko.md +118 -17
- package/README.md +119 -16
- package/bin/deel.js +430 -6
- package/package.json +1 -1
- package/src/acp/serve.js +39 -1
- package/src/agent/agents.js +203 -0
- package/src/agent/asks.js +58 -2
- package/src/agent/budget.js +33 -0
- package/src/agent/effort.js +69 -0
- package/src/agent/loop.js +219 -19
- package/src/agent/models.js +4 -0
- package/src/agent/modes.js +179 -17
- package/src/agent/outschema.js +351 -0
- package/src/agent/route.js +119 -8
- package/src/agent/session.js +13 -2
- package/src/backend/adapter.js +94 -7
- package/src/backend/clientcert.js +164 -0
- package/src/backend/http.js +232 -26
- package/src/backend/mcp.js +194 -30
- package/src/backend/wire.js +10 -0
- package/src/cmdnames.js +3 -0
- package/src/commands.js +158 -15
- package/src/completion.js +35 -4
- package/src/config.js +86 -9
- package/src/configexplain.js +177 -0
- package/src/doctor.js +195 -0
- package/src/i18n/en.js +50 -0
- package/src/i18n/ja.js +50 -0
- package/src/i18n/ko.js +50 -0
- package/src/i18n/zh.js +50 -0
- package/src/lsp/client.js +2 -2
- package/src/oneshot.js +181 -4
- package/src/pack/sbom.js +77 -3
- package/src/pack/selfpack.js +1 -1
- package/src/pack/sheet.en.js +1 -1
- package/src/repl.js +74 -3
- package/src/safety/hooks.js +389 -0
- package/src/safety/policy.js +74 -0
- package/src/safety/shellenv.js +116 -0
- package/src/safety/trust.js +230 -0
- package/src/setup.js +9 -0
- package/src/stats.js +162 -0
- package/src/tools/doc2md.js +266 -0
- package/src/tools/docs.js +26 -6
- package/src/tools/fig.js +393 -0
- package/src/tools/hwpxwrite.js +268 -0
- package/src/tools/index.js +176 -16
- package/src/tools/jobs.js +11 -6
- package/src/tools/kiwi.js +293 -0
- package/src/tools/spawn.js +19 -1
- package/src/tools/task.js +8 -2
package/src/tools/fig.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figma `.fig` 읽기 — 화면 하나가 어떤 조각으로 짜였는지.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 읽나 ────────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 「이 시안대로 만들어 줘」 하면서 `.fig` 를 건네는 일이 실제로 잦다. 그런데
|
|
7
|
+
* 지금까지 그 파일은 여기서 끝났다 — 「이진 파일이라 못 읽습니다」. 그러면
|
|
8
|
+
* 사람이 화면을 캡처해서 다시 붙이거나, 글자를 하나씩 옮겨 적는다.
|
|
9
|
+
*
|
|
10
|
+
* 안에 있는 것은 그림만이 아니다. **틀 이름 · 글 · 크기 · 겹친 차례**가 다
|
|
11
|
+
* 글자로 들어 있다. 「로그인」 이라는 틀 밑에 「이메일」 「비밀번호」 라는
|
|
12
|
+
* 글상자가 있고 폭이 375 라는 것 — 코드를 짜는 데 필요한 것은 대부분 그거다.
|
|
13
|
+
*
|
|
14
|
+
* ── 무엇을 안 하나 ─────────────────────────────────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* 그림은 안 그린다. 색·그림자·글꼴 굵기도 안 낸다. 낼 수야 있지만 그걸 다
|
|
17
|
+
* 실으면 창이 그것만으로 찬다. **이름과 글과 짜임**까지가 이 읽개의 몫이다.
|
|
18
|
+
*
|
|
19
|
+
* 쓰기는 아예 없다. 글로 왕복시킨 시안은 시안이 아니다.
|
|
20
|
+
*
|
|
21
|
+
* ── 어떻게 생겼나 ──────────────────────────────────────────────────────
|
|
22
|
+
*
|
|
23
|
+
* .fig = zip
|
|
24
|
+
* ├ canvas.fig ← 알맹이. 이게 Kiwi 다
|
|
25
|
+
* ├ meta.json ← 파일 이름 · 만든 판
|
|
26
|
+
* ├ thumbnail.png ← 미리보기
|
|
27
|
+
* └ images/… ← 쓰인 그림들
|
|
28
|
+
*
|
|
29
|
+
* canvas.fig = "fig-kiwi"(8바이트) 판:u32
|
|
30
|
+
* 덩이길이:u32 덩이 ← 스키마
|
|
31
|
+
* 덩이길이:u32 덩이 ← 알맹이
|
|
32
|
+
*
|
|
33
|
+
* 두 덩이는 눌려 있다. 예전 파일은 deflate, 요즘 파일은 zstd 다. 앞 네
|
|
34
|
+
* 바이트를 보고 가른다 — 확장자나 판 번호로 짐작하지 않는다. 짐작이 틀리면
|
|
35
|
+
* 「파일이 깨졌다」 는 엉뚱한 말이 나간다.
|
|
36
|
+
*
|
|
37
|
+
* ── zstd 와 Node 판 ────────────────────────────────────────────────────
|
|
38
|
+
*
|
|
39
|
+
* Node 가 zstd 를 들고 온 것은 22.15 부터다. 이 프로그램의 바닥은 20 이다.
|
|
40
|
+
* 그래서 zstd 로 눌린 파일을 Node 20 에서 열면 **못 연다.** 그때 「깨진
|
|
41
|
+
* 파일입니다」 라고 하면 사람은 파일을 의심하며 시간을 버린다. 무엇이
|
|
42
|
+
* 없어서 못 하는지, 그럼 무엇을 하면 되는지까지 말한다.
|
|
43
|
+
*/
|
|
44
|
+
import { readFileSync } from 'node:fs';
|
|
45
|
+
import { basename, extname } from 'node:path';
|
|
46
|
+
import { inflateRawSync, inflateSync } from 'node:zlib';
|
|
47
|
+
import * as zlib from 'node:zlib';
|
|
48
|
+
|
|
49
|
+
import { readZip, looksZip } from '../pack/zip.js';
|
|
50
|
+
import { 스키마읽기, 알맹이읽기 } from './kiwi.js';
|
|
51
|
+
|
|
52
|
+
const 머리들 = ['fig-kiwi', 'fig-jam.'];
|
|
53
|
+
|
|
54
|
+
/** 이 파일이 `.fig` 인가. */
|
|
55
|
+
export function isFigPath(p) {
|
|
56
|
+
return extname(String(p ?? '')).toLowerCase() === '.fig';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 우리가 담아 두는 칸 이름.
|
|
61
|
+
*
|
|
62
|
+
* Figma 스키마에는 칸이 수백 개 있고 그중 대부분이 도형 좌표다. 전부
|
|
63
|
+
* 객체로 만들면 큰 파일 하나에 몇백 MB 가 된다. 바이트는 그대로 지나가되
|
|
64
|
+
* **담기는 것만 고른다** — 이름으로 고르므로 새 판이 칸을 늘려도 안 깨진다.
|
|
65
|
+
*/
|
|
66
|
+
const 담을칸 = new Set([
|
|
67
|
+
'guid', 'sessionID', 'localID',
|
|
68
|
+
'parentIndex', 'position',
|
|
69
|
+
'type', 'name', 'visible',
|
|
70
|
+
'size', 'x', 'y',
|
|
71
|
+
'textData', 'characters',
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 뿌리에서 「도형 목록」 칸을 찾는다.
|
|
76
|
+
*
|
|
77
|
+
* 이름으로 `nodeChanges` 를 찾되, **못 찾으면 생김새로 찾는다** — 배열이고,
|
|
78
|
+
* 그 알맹이 정의에 `guid` 칸이 있는 것. 이름은 Figma 가 바꿀 수 있지만
|
|
79
|
+
* 「도형마다 고유 번호가 있다」 는 것은 이 형식의 뼈대라 안 바뀐다.
|
|
80
|
+
*
|
|
81
|
+
* 이름만 믿었다가 못 찾으면 「도형이 하나도 없습니다」 라는 거짓말이 나간다.
|
|
82
|
+
*/
|
|
83
|
+
function 도형칸찾기(스키마, 뿌리이름) {
|
|
84
|
+
const d = 스키마.정의들[스키마.이름표.get(뿌리이름)];
|
|
85
|
+
const 배열칸 = (d?.칸들 ?? []).filter((f) => f.배열 && f.형 >= 0);
|
|
86
|
+
const 이름난것 = 배열칸.find((f) => f.이름 === 'nodeChanges');
|
|
87
|
+
if (이름난것) return 이름난것.이름;
|
|
88
|
+
const 닮은것 = 배열칸.find((f) => 스키마.정의들[f.형]?.칸들?.some((x) => x.이름 === 'guid'));
|
|
89
|
+
return 닮은것?.이름 ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* 이보다 많으면 그림에 다 못 싣는다. 자르되 잘랐다고 말한다. */
|
|
93
|
+
const 노드최대 = 20000;
|
|
94
|
+
|
|
95
|
+
/** 앞 네 바이트로 무엇에 눌렸는지 본다. 판 번호로 짐작하지 않는다. */
|
|
96
|
+
function 눌린방식(buf) {
|
|
97
|
+
if (buf.length >= 4 && buf.readUInt32LE(0) === 0xfd2fb528) return 'zstd';
|
|
98
|
+
// zlib 은 첫 바이트가 0x78 이고 두 바이트를 31 로 나누면 떨어진다.
|
|
99
|
+
if (buf.length >= 2 && buf[0] === 0x78 && ((buf[0] << 8) + buf[1]) % 31 === 0) return 'zlib';
|
|
100
|
+
return 'deflate';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const zstd있나 = () => typeof zlib.zstdDecompressSync === 'function';
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* zstd 가 없을 때 하는 말.
|
|
107
|
+
*
|
|
108
|
+
* 「깨진 파일입니다」 라고 하면 사람은 파일을 의심하며 시간을 버린다. 파일은
|
|
109
|
+
* 멀쩡하고 **여기 Node 가 못 푸는 것**이다. 그 둘은 하는 일이 완전히 다르다.
|
|
110
|
+
*/
|
|
111
|
+
export function zstd안내() {
|
|
112
|
+
return 'zstd 로 눌린 .fig 입니다. 이 Node 에는 zstd 가 없습니다'
|
|
113
|
+
+ ` (지금 ${process.version} · Node 22.15 부터 들어왔습니다).\n`
|
|
114
|
+
+ ' 파일이 깨진 것이 아닙니다 — 여기서 못 푸는 것뿐입니다.\n'
|
|
115
|
+
+ ' 고치는 법: Node 를 22.15 이상으로 올리면 그대로 읽힙니다.';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 덩이 하나를 편다.
|
|
120
|
+
*
|
|
121
|
+
* 상한을 **펴기 전에** 건다. 다 펴 놓고 재면 이미 늦다 — 작은 조각이 몇 GB
|
|
122
|
+
* 로 부푸는 파일을 남이 만들어 줄 수 있다(zip.js 가 같은 이유로 같은 일을
|
|
123
|
+
* 한다).
|
|
124
|
+
*/
|
|
125
|
+
function 펴기(덩이, 상한) {
|
|
126
|
+
const 방식 = 눌린방식(덩이);
|
|
127
|
+
if (방식 === 'zstd') {
|
|
128
|
+
if (!zstd있나()) {
|
|
129
|
+
const err = new Error(zstd안내());
|
|
130
|
+
err.끝났다 = true;
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
return zlib.zstdDecompressSync(덩이, { maxOutputLength: 상한 });
|
|
134
|
+
}
|
|
135
|
+
if (방식 === 'zlib') return inflateSync(덩이, { maxOutputLength: 상한 });
|
|
136
|
+
return inflateRawSync(덩이, { maxOutputLength: 상한 });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `canvas.fig` 를 덩이 목록으로 가른다.
|
|
141
|
+
*
|
|
142
|
+
* @returns {{판:number, 덩이들:Buffer[]}}
|
|
143
|
+
*/
|
|
144
|
+
export function 통가르기(buf) {
|
|
145
|
+
if (buf.length < 12) throw new Error('canvas.fig 가 너무 짧습니다');
|
|
146
|
+
const 머리 = buf.toString('latin1', 0, 8);
|
|
147
|
+
if (!머리들.includes(머리)) {
|
|
148
|
+
throw new Error(`Figma 알맹이가 아닙니다 — 앞머리가 ${JSON.stringify(머리)} 입니다`);
|
|
149
|
+
}
|
|
150
|
+
const 판 = buf.readUInt32LE(8);
|
|
151
|
+
|
|
152
|
+
const 덩이들 = [];
|
|
153
|
+
let p = 12;
|
|
154
|
+
while (p + 4 <= buf.length) {
|
|
155
|
+
const 길이 = buf.readUInt32LE(p);
|
|
156
|
+
p += 4;
|
|
157
|
+
// 남은 것보다 길다고 적혀 있으면 그 자리에서 거짓이다. 그대로 잘라
|
|
158
|
+
// 가면 뒤 덩이가 통째로 헛것이 된다.
|
|
159
|
+
if (길이 > buf.length - p) throw new Error(`덩이 길이가 파일보다 깁니다 (${길이})`);
|
|
160
|
+
덩이들.push(buf.subarray(p, p + 길이));
|
|
161
|
+
p += 길이;
|
|
162
|
+
}
|
|
163
|
+
if (덩이들.length < 2) throw new Error(`덩이가 ${덩이들.length}개뿐입니다 — 스키마와 알맹이가 둘 다 있어야 합니다`);
|
|
164
|
+
return { 판, 덩이들 };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** guid 를 한 줄 열쇠로. 없으면 null — 뿌리 노드는 부모가 없다. */
|
|
168
|
+
function 열쇠(g) {
|
|
169
|
+
if (!g || typeof g !== 'object') return null;
|
|
170
|
+
return `${g.sessionID ?? 0}:${g.localID ?? 0}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 크기를 `375×812` 로. 소수점은 버린다 — 시안 치수는 정수로 읽는 게 맞다. */
|
|
174
|
+
function 크기말(size) {
|
|
175
|
+
if (!size || typeof size !== 'object') return '';
|
|
176
|
+
const w = Number(size.x);
|
|
177
|
+
const h = Number(size.y);
|
|
178
|
+
if (!Number.isFinite(w) || !Number.isFinite(h) || (!w && !h)) return '';
|
|
179
|
+
return ` ${Math.round(w)}×${Math.round(h)}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 글은 한 줄로 접는다. 시안의 글상자에는 줄바꿈이 흔하다. */
|
|
183
|
+
function 글말(node) {
|
|
184
|
+
const s = node?.textData?.characters;
|
|
185
|
+
if (typeof s !== 'string' || !s.trim()) return '';
|
|
186
|
+
const 한줄 = s.replace(/\s+/g, ' ').trim();
|
|
187
|
+
return ` "${한줄.length > 120 ? `${한줄.slice(0, 120)}…` : 한줄}"`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 노드 목록을 부모-자식으로 엮는다.
|
|
192
|
+
*
|
|
193
|
+
* 차례는 `position` 이라는 글자열이 정한다 — 사이에 끼워 넣을 수 있게 만든
|
|
194
|
+
* 값이라 **글자 순서가 곧 화면 순서**다. 숫자로 바꾸려 들면 안 된다.
|
|
195
|
+
*/
|
|
196
|
+
function 나무엮기(노드들) {
|
|
197
|
+
const 자리표 = new Map();
|
|
198
|
+
for (const n of 노드들) {
|
|
199
|
+
const k = 열쇠(n.guid);
|
|
200
|
+
if (k && !자리표.has(k)) 자리표.set(k, n);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const 자식표 = new Map();
|
|
204
|
+
const 뿌리들 = [];
|
|
205
|
+
for (const n of 노드들) {
|
|
206
|
+
const 부모 = 열쇠(n.parentIndex?.guid);
|
|
207
|
+
if (부모 && 자리표.has(부모)) {
|
|
208
|
+
if (!자식표.has(부모)) 자식표.set(부모, []);
|
|
209
|
+
자식표.get(부모).push(n);
|
|
210
|
+
} else {
|
|
211
|
+
뿌리들.push(n);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
for (const 목록 of 자식표.values()) {
|
|
215
|
+
목록.sort((a, b) => String(a.parentIndex?.position ?? '').localeCompare(String(b.parentIndex?.position ?? '')));
|
|
216
|
+
}
|
|
217
|
+
return { 자리표, 자식표, 뿌리들 };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 한 갈래를 줄들로 편다. 깊이는 두 칸씩. */
|
|
221
|
+
function 가지풀기(뿌리, 자식표, 줄들, 깊이, 셈) {
|
|
222
|
+
if (셈.남은 <= 0) return;
|
|
223
|
+
const 들여 = ' '.repeat(Math.min(깊이, 12));
|
|
224
|
+
const 갈래 = typeof 뿌리.type === 'string' ? 뿌리.type : String(뿌리.type ?? '?');
|
|
225
|
+
const 이름 = String(뿌리.name ?? '').trim();
|
|
226
|
+
const 숨김 = 뿌리.visible === false ? ' (숨김)' : '';
|
|
227
|
+
줄들.push(`${들여}- ${갈래}${이름 ? ` · ${이름}` : ''}${크기말(뿌리.size)}${글말(뿌리)}${숨김}`);
|
|
228
|
+
셈.남은 -= 1;
|
|
229
|
+
for (const 아이 of 자식표.get(열쇠(뿌리.guid)) ?? []) {
|
|
230
|
+
가지풀기(아이, 자식표, 줄들, 깊이 + 1, 셈);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* `.fig` 하나를 읽는다.
|
|
236
|
+
*
|
|
237
|
+
* 던지지 않는다 — 깨진 파일은 도구 실행 한가운데서 만나는 것이라, 예외가
|
|
238
|
+
* 나면 「파일이 깨졌다」 가 「도구가 터졌다」 로 보고된다. 다른 읽개들과
|
|
239
|
+
* 같은 규칙이고, 돌려주는 모양도 같다(`덩이들`).
|
|
240
|
+
*
|
|
241
|
+
* @returns {{ok:true, 갈래:'fig', 덩이들:[{이름,문단들}], 말:string[], 노드수:number}
|
|
242
|
+
* |{ok:false, error:string, 끝났다?:boolean}}
|
|
243
|
+
*/
|
|
244
|
+
export function readFig(경로또는버퍼) {
|
|
245
|
+
let buf;
|
|
246
|
+
try {
|
|
247
|
+
buf = Buffer.isBuffer(경로또는버퍼) ? 경로또는버퍼 : readFileSync(경로또는버퍼);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
return { ok: false, error: `못 읽었습니다: ${err.message}` };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const 말 = [];
|
|
253
|
+
let 알맹이;
|
|
254
|
+
let 메타 = null;
|
|
255
|
+
let 그림수 = 0;
|
|
256
|
+
let 미리보기 = false;
|
|
257
|
+
|
|
258
|
+
// 꾸러미로 온 것이 보통이지만, `canvas.fig` 만 따로 떨어져 오기도 한다.
|
|
259
|
+
if (looksZip(buf)) {
|
|
260
|
+
let 꾸러미;
|
|
261
|
+
try {
|
|
262
|
+
꾸러미 = readZip(buf);
|
|
263
|
+
} catch (err) {
|
|
264
|
+
return { ok: false, error: `.fig 꾸러미를 풀지 못했습니다 — ${err.message}` };
|
|
265
|
+
}
|
|
266
|
+
for (const [이름] of 꾸러미.files) {
|
|
267
|
+
if (/^images\//i.test(이름)) 그림수 += 1;
|
|
268
|
+
if (/^thumbnail\.png$/i.test(이름)) 미리보기 = true;
|
|
269
|
+
}
|
|
270
|
+
const 메타몸 = 꾸러미.files.get('meta.json');
|
|
271
|
+
if (메타몸) { try { 메타 = JSON.parse(메타몸.toString('utf8')); } catch { 메타 = null; } }
|
|
272
|
+
알맹이 = 꾸러미.files.get('canvas.fig');
|
|
273
|
+
if (!알맹이) {
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
끝났다: true,
|
|
277
|
+
error: '.fig 꾸러미 안에 canvas.fig 가 없습니다 — '
|
|
278
|
+
+ `든 것: ${[...꾸러미.files.keys()].slice(0, 8).join(' · ') || '(빈 꾸러미)'}`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
알맹이 = buf;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let 통;
|
|
286
|
+
try {
|
|
287
|
+
통 = 통가르기(알맹이);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
return { ok: false, error: err.message, 끝났다: true };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const 상한 = 256 * 1024 * 1024;
|
|
293
|
+
let 스키마;
|
|
294
|
+
let 몸;
|
|
295
|
+
try {
|
|
296
|
+
스키마 = 스키마읽기(펴기(통.덩이들[0], 16 * 1024 * 1024));
|
|
297
|
+
몸 = 펴기(통.덩이들[1], 상한);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
return { ok: false, error: `.fig 알맹이를 풀지 못했습니다 — ${err.message}`, 끝났다: !!err.끝났다 };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const 뿌리이름 = 스키마.이름표.has('Message')
|
|
303
|
+
? 'Message'
|
|
304
|
+
: 스키마.정의들.find((d) => d.갈래 === 'MESSAGE')?.이름;
|
|
305
|
+
if (!뿌리이름) return { ok: false, error: '.fig 스키마에 읽을 시작점이 없습니다', 끝났다: true };
|
|
306
|
+
|
|
307
|
+
const 도형칸 = 도형칸찾기(스키마, 뿌리이름);
|
|
308
|
+
if (!도형칸) {
|
|
309
|
+
const 있는칸 = (스키마.정의들[스키마.이름표.get(뿌리이름)]?.칸들 ?? [])
|
|
310
|
+
.map((f) => f.이름).slice(0, 10).join(' · ');
|
|
311
|
+
return {
|
|
312
|
+
ok: false,
|
|
313
|
+
끝났다: true,
|
|
314
|
+
error: `.fig 를 열었는데 도형 목록 칸을 못 찾았습니다. ${뿌리이름} 에 있는 칸: ${있는칸 || '(없음)'}`,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let 것;
|
|
319
|
+
try {
|
|
320
|
+
것 = 알맹이읽기(스키마, 몸, 뿌리이름, (_정의, 칸) => 칸 === 도형칸 || 담을칸.has(칸));
|
|
321
|
+
} catch (err) {
|
|
322
|
+
// 여기까지 왔는데 못 읽었으면 스키마와 알맹이가 안 맞는 것이다. 반쯤
|
|
323
|
+
// 읽은 것을 내놓지 않는다 — 반만 맞는 시안은 틀린 시안보다 나쁘다.
|
|
324
|
+
return { ok: false, error: `.fig 를 스키마대로 읽지 못했습니다 — ${err.message}`, 끝났다: true };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const 노드들 = Array.isArray(것?.[도형칸]) ? 것[도형칸].filter(Boolean) : [];
|
|
328
|
+
if (!노드들.length) {
|
|
329
|
+
return { ok: false, error: '.fig 를 열었는데 안에 도형이 하나도 없습니다', 끝났다: true };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const { 자식표, 뿌리들 } = 나무엮기(노드들);
|
|
333
|
+
|
|
334
|
+
/*
|
|
335
|
+
* 쪽은 CANVAS 다.
|
|
336
|
+
*
|
|
337
|
+
* Figma 에서 「페이지」 라고 부르는 것이 파일 안에서는 CANVAS 이고,
|
|
338
|
+
* 그 위가 DOCUMENT 하나다. 쪽마다 한 덩이로 내면 마크다운에서 `## 쪽이름`
|
|
339
|
+
* 이 되고, 글로 낼 때도 `--- 쪽이름 ---` 으로 갈린다.
|
|
340
|
+
*/
|
|
341
|
+
const 문서 = 뿌리들.find((n) => n.type === 'DOCUMENT') ?? 뿌리들[0];
|
|
342
|
+
const 쪽들 = (자식표.get(열쇠(문서?.guid)) ?? []).filter((n) => n.type === 'CANVAS');
|
|
343
|
+
|
|
344
|
+
const 셈 = { 남은: 노드최대 };
|
|
345
|
+
const 덩이들 = [];
|
|
346
|
+
|
|
347
|
+
const 개요 = [];
|
|
348
|
+
const 파일이름 = 메타?.file_name ?? 메타?.fileName ?? 메타?.name;
|
|
349
|
+
if (파일이름) 개요.push(`파일 이름: ${파일이름}`);
|
|
350
|
+
개요.push(`쪽 ${쪽들.length || 1}개 · 도형 ${노드들.length.toLocaleString('en-US')}개`);
|
|
351
|
+
if (그림수) 개요.push(`쓰인 그림 ${그림수}개 (images/ 안에 있습니다 — 이 읽개는 그림을 안 풉니다)`);
|
|
352
|
+
if (미리보기) 개요.push('미리보기 그림 thumbnail.png 가 들어 있습니다');
|
|
353
|
+
개요.push('색·그림자·글꼴은 안 냅니다. 이름 · 글 · 크기 · 짜임까지입니다.');
|
|
354
|
+
덩이들.push({ 이름: '개요', 문단들: 개요 });
|
|
355
|
+
|
|
356
|
+
if (쪽들.length) {
|
|
357
|
+
for (const 쪽 of 쪽들) {
|
|
358
|
+
const 줄들 = [];
|
|
359
|
+
for (const 아이 of 자식표.get(열쇠(쪽.guid)) ?? []) 가지풀기(아이, 자식표, 줄들, 0, 셈);
|
|
360
|
+
덩이들.push({
|
|
361
|
+
이름: String(쪽.name ?? '').trim() || '이름 없는 쪽',
|
|
362
|
+
문단들: 줄들.length ? 줄들 : ['(이 쪽은 비어 있습니다.)'],
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
// DOCUMENT/CANVAS 짜임이 아닌 파일도 있다 — 그때는 있는 대로 편다.
|
|
367
|
+
const 줄들 = [];
|
|
368
|
+
for (const n of 뿌리들) 가지풀기(n, 자식표, 줄들, 0, 셈);
|
|
369
|
+
덩이들.push({ 이름: '도형', 문단들: 줄들 });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (셈.남은 <= 0) {
|
|
373
|
+
말.push(`도형이 ${노드최대.toLocaleString('en-US')}개를 넘어 거기까지만 폈습니다 — 뒷부분은 안 실렸습니다`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return { ok: true, 갈래: 'fig', 덩이들, 말, 노드수: 노드들.length };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** 한 줄 요약. Read 의 summary 자리로 간다. */
|
|
380
|
+
export function summarize(r) {
|
|
381
|
+
if (!r?.ok) return '';
|
|
382
|
+
const 쪽 = Math.max(0, r.덩이들.length - 1);
|
|
383
|
+
return `fig · 쪽 ${쪽}개 · 도형 ${r.노드수.toLocaleString('en-US')}개`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** 고치려 들 때 하는 말. 문서·PDF 와 같은 꼴 — 왜 안 되는지와 그럼 어떻게 하는지. */
|
|
387
|
+
export function fig는못고침(보인이름) {
|
|
388
|
+
return `.fig 시안은 이 도구로 고칠 수 없습니다: ${보인이름}\n`
|
|
389
|
+
+ ' 읽기만 됩니다 (이름 · 글 · 크기 · 짜임을 글로 냅니다). 시안을 글로\n'
|
|
390
|
+
+ ' 왕복시키면 그 자리에서 시안이 아니게 되기 때문입니다.\n'
|
|
391
|
+
+ ` 고칠 것이 있으면 Figma 에서 고치고 다시 내보내 주세요.\n`
|
|
392
|
+
+ ` ${basename(보인이름)} 자체는 안 건드렸습니다.`;
|
|
393
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hwpx 만들기 — 글 → 한글 문서.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 만드나 ───────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 읽기(tools/docs.js)를 넣고 나서 남은 말이 늘 같았다. 「읽어 주는 건 알겠는데,
|
|
7
|
+
* 결과를 그 서식으로 주면 안 되나요.」
|
|
8
|
+
*
|
|
9
|
+
* 사내에서 오가는 것은 md 가 아니다. 주간보고도 회의록도 검토 의견도 한글
|
|
10
|
+
* 문서다. 지금까지는 deel 이 md 를 내놓고 사람이 그걸 한글에 붙여 넣어 서식을
|
|
11
|
+
* 다시 잡았다. 그 붙여넣기가 하루에 몇 번씩이다.
|
|
12
|
+
*
|
|
13
|
+
* ── 고치기는 여전히 안 한다 ────────────────────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* docs.js 가 「문서는 못 고침」 이라고 못 박은 것은 그대로다. 서식·그림·양식이
|
|
16
|
+
* 든 문서를 글로 왕복시키면 반드시 뭔가 잃고, 잃은 채로 저장된 문서는 겉보기에
|
|
17
|
+
* 멀쩡해서 알아차렸을 때는 원본이 없다.
|
|
18
|
+
*
|
|
19
|
+
* **새로 만드는 것은 다르다.** 잃을 원본이 없다. 그래서 여기서 하는 일은 하나뿐
|
|
20
|
+
* 이다 — 빈 종이에 글을 적어 hwpx 로 내놓는다. 있는 파일을 덮어쓰지도 않는다.
|
|
21
|
+
*
|
|
22
|
+
* ── 무엇을 옮기고 무엇을 안 옮기나 ─────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* 옮긴다 제목 세 단계(#·##·###) · 문단 · 글머리표(-·*·1.) · 빈 줄
|
|
25
|
+
* 안 옮긴다 표 · 그림 · 굵게·기울임 · 링크
|
|
26
|
+
*
|
|
27
|
+
* 표를 안 넣는 것은 **못 해서가 아니라 확인할 수 없어서**다. OWPML 의 표는 칸
|
|
28
|
+
* 크기·여백·병합을 다 적어야 하고, 그 중 하나가 틀리면 한글이 **파일 전체**를
|
|
29
|
+
* 안 연다. 이 자리에는 한글이 없어서 「열린다」 를 잴 방법이 없다. 문단만 있는
|
|
30
|
+
* 문서는 구조가 단순해 어긋날 자리가 거의 없고, 표는 그 반대다. 표는 줄 글로
|
|
31
|
+
* 적어 내보내고 **그렇게 했다고 말한다** — 조용히 빼면 사람은 표가 사라진 것을
|
|
32
|
+
* 나중에야 안다.
|
|
33
|
+
*
|
|
34
|
+
* ── 규격에 대해 ────────────────────────────────────────────────────────
|
|
35
|
+
*
|
|
36
|
+
* hwpx 는 ZIP + XML 이다(한컴 공개 규격 OWPML). 우리 zip 쓰개(pack/zip.js)와
|
|
37
|
+
* 글자 이어붙이기면 되고, 새로 들이는 것이 없다 — 읽기와 같은 자세다.
|
|
38
|
+
*
|
|
39
|
+
* `mimetype` 은 **맨 앞에, 안 눌러서** 담는다. ZIP 을 안 풀고도 앞머리만 보고
|
|
40
|
+
* 무슨 파일인지 알게 하려는 규약이다. pack/zip.js 는 눌러서 커지면 그냥 담으므로
|
|
41
|
+
* (20바이트짜리는 늘 그렇다) 차례만 맞추면 된다.
|
|
42
|
+
*/
|
|
43
|
+
import { makeZip } from '../pack/zip.js';
|
|
44
|
+
|
|
45
|
+
/** 1pt = 100 HWPUNIT. 규격이 그렇다. */
|
|
46
|
+
const PT = 100;
|
|
47
|
+
/** 한 문단이 이보다 길면 자른다. 모델이 통째로 쏟아붓는 것을 막는다. */
|
|
48
|
+
export const 문단최대 = 20000;
|
|
49
|
+
/** 문단 수 상한. 넘으면 자르고 **잘랐다고 말한다.** */
|
|
50
|
+
export const 문단수최대 = 5000;
|
|
51
|
+
|
|
52
|
+
const 갈래 = { 제목1: 1, 제목2: 2, 제목3: 3, 본문: 0, 글머리: 0 };
|
|
53
|
+
|
|
54
|
+
const esc = (s) => String(s ?? '')
|
|
55
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
56
|
+
.replace(/"/g, '"')
|
|
57
|
+
/*
|
|
58
|
+
* XML 1.0 이 아예 못 담는 제어문자를 걷어낸다.
|
|
59
|
+
*
|
|
60
|
+
* 모델이 낸 글에 이런 것이 섞이는 일이 실제로 있다 — 터미널 출력을 그대로
|
|
61
|
+
* 옮겨 적으면 ESC(0x1B)가 딸려 온다. 남겨 두면 한글이 **파일 전체**를 안 여는데,
|
|
62
|
+
* 화면에는 「손상된 문서」 한 줄만 뜼다. 글자 몇 개를 잃는 쪽이 문서를 통째로
|
|
63
|
+
* 잃는 쪽보다 낫다.
|
|
64
|
+
*/
|
|
65
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 글을 문단 목록으로 읽는다.
|
|
69
|
+
*
|
|
70
|
+
* 마크다운을 다 아는 척하지 않는다. 우리가 내놓는 글에 실제로 들어 있는 것만
|
|
71
|
+
* 본다 — 제목, 글머리표, 문단. 굵게·링크 같은 것은 **글자 그대로** 남긴다.
|
|
72
|
+
* 지우면 `**중요**` 가 `중요` 가 되어 강조가 사라지고, 옮기려 들면 서식 조합이
|
|
73
|
+
* 폭발한다. 남기면 적어도 사람이 무엇을 뜻했는지 읽을 수 있다.
|
|
74
|
+
*
|
|
75
|
+
* @returns {{문단들: Array<{갈래:string, 글:string}>, 잘림: number, 표몇개: number}}
|
|
76
|
+
*/
|
|
77
|
+
export function 글읽기(글) {
|
|
78
|
+
const 줄들 = String(글 ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
79
|
+
const 문단들 = [];
|
|
80
|
+
let 표몇개 = 0;
|
|
81
|
+
let 이어붙일것 = null;
|
|
82
|
+
|
|
83
|
+
const 닫기 = () => {
|
|
84
|
+
if (이어붙일것 && 이어붙일것.글.trim()) 문단들.push(이어붙일것);
|
|
85
|
+
이어붙일것 = null;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
for (const 날것 of 줄들) {
|
|
89
|
+
const 줄 = 날것.replace(/\s+$/, '');
|
|
90
|
+
if (!줄.trim()) { 닫기(); continue; }
|
|
91
|
+
|
|
92
|
+
const 제목 = /^(#{1,3})\s+(.*)$/.exec(줄);
|
|
93
|
+
if (제목) {
|
|
94
|
+
닫기();
|
|
95
|
+
문단들.push({ 갈래: `제목${제목[1].length}`, 글: 제목[2].trim() });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/*
|
|
100
|
+
* 표는 줄 글로 내보낸다. 그리고 **몇 개였는지 센다** — 부르는 쪽이
|
|
101
|
+
* 그 수를 사람에게 말한다. 조용히 바꿔치기하면 사람은 표가 글이 된 것을
|
|
102
|
+
* 문서를 열어 보고서야 안다.
|
|
103
|
+
*/
|
|
104
|
+
if (/^\s*\|.*\|\s*$/.test(줄)) {
|
|
105
|
+
// 표의 구분선(|---|---|)은 글로 옮길 것이 없다.
|
|
106
|
+
if (/^\s*\|[\s:|-]+\|\s*$/.test(줄)) { 표몇개 += 1; continue; }
|
|
107
|
+
닫기();
|
|
108
|
+
문단들.push({ 갈래: '본문', 글: 줄.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((x) => x.trim()).join(' · ') });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const 글머리 = /^\s*([-*+]|\d+[.)])\s+(.*)$/.exec(줄);
|
|
113
|
+
if (글머리) {
|
|
114
|
+
닫기();
|
|
115
|
+
// 한글의 글머리표 규격을 안 쓴다. 문자 하나로 적으면 어느 한글 판에서도
|
|
116
|
+
// 똑같이 보이고, 서식이 어긋날 자리가 없다.
|
|
117
|
+
const 표시 = /^\d/.test(글머리[1]) ? `${글머리[1]} ` : '· ';
|
|
118
|
+
문단들.push({ 갈래: '글머리', 글: `${표시}${글머리[2].trim()}` });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 이어지는 줄은 한 문단으로 붙인다. 마크다운의 규칙이고, 한글에서도
|
|
123
|
+
// 줄마다 문단을 끊으면 줄 간격이 통째로 어색해진다.
|
|
124
|
+
if (이어붙일것) 이어붙일것.글 += ` ${줄.trim()}`;
|
|
125
|
+
else 이어붙일것 = { 갈래: '본문', 글: 줄.trim() };
|
|
126
|
+
}
|
|
127
|
+
닫기();
|
|
128
|
+
|
|
129
|
+
let 잘림 = 0;
|
|
130
|
+
if (문단들.length > 문단수최대) { 잘림 = 문단들.length - 문단수최대; 문단들.length = 문단수최대; }
|
|
131
|
+
for (const p of 문단들) if (p.글.length > 문단최대) p.글 = `${p.글.slice(0, 문단최대)} …(잘림)`;
|
|
132
|
+
return { 문단들, 잘림, 표몇개 };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── 이하 규격 조각들 ────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
const 머리 = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
|
|
138
|
+
|
|
139
|
+
const VERSION_XML = `${머리}
|
|
140
|
+
<hv:HCFVersion xmlns:hv="http://www.hancom.co.kr/hwpml/2011/version" tagetApplication="WORDPROCESSOR" major="5" minor="1" micro="1" buildNumber="0" os="1" xmlVersion="1.4" application="deel" appVersion="1.0"/>`;
|
|
141
|
+
|
|
142
|
+
const CONTAINER_XML = `${머리}
|
|
143
|
+
<ocf:container xmlns:ocf="urn:oasis:names:tc:opendocument:xmlns:container" xmlns:hpf="http://www.hancom.co.kr/schema/2011/hpf">
|
|
144
|
+
<ocf:rootfiles><ocf:rootfile full-path="Contents/content.hpf" media-type="application/hwpml-package+xml"/></ocf:rootfiles>
|
|
145
|
+
</ocf:container>`;
|
|
146
|
+
|
|
147
|
+
const MANIFEST_XML = `${머리}
|
|
148
|
+
<odf:manifest xmlns:odf="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" odf:version="1.2">
|
|
149
|
+
<odf:file-entry odf:full-path="/" odf:media-type="application/hwp+zip"/>
|
|
150
|
+
</odf:manifest>`;
|
|
151
|
+
|
|
152
|
+
const SETTINGS_XML = `${머리}
|
|
153
|
+
<ha:HWPApplicationSetting xmlns:ha="http://www.hancom.co.kr/hwpml/2011/app" xmlns:config="http://www.hancom.co.kr/hwpml/2011/configuration">
|
|
154
|
+
<ha:CaretPosition listIDRef="0" paraIDRef="0" pos="0"/>
|
|
155
|
+
</ha:HWPApplicationSetting>`;
|
|
156
|
+
|
|
157
|
+
const contentHpf = (제목) => `${머리}
|
|
158
|
+
<opf:package xmlns:opf="http://www.idpf.org/2007/opf/" xmlns:dc="http://purl.org/dc/elements/1.1/" version="" unique-identifier="" id="">
|
|
159
|
+
<opf:metadata>
|
|
160
|
+
<opf:title>${esc(제목)}</opf:title>
|
|
161
|
+
<opf:language>ko</opf:language>
|
|
162
|
+
<opf:meta name="creator" content="deel"/>
|
|
163
|
+
</opf:metadata>
|
|
164
|
+
<opf:manifest>
|
|
165
|
+
<opf:item id="header" href="Contents/header.xml" media-type="application/xml"/>
|
|
166
|
+
<opf:item id="section0" href="Contents/section0.xml" media-type="application/xml"/>
|
|
167
|
+
<opf:item id="settings" href="settings.xml" media-type="application/xml"/>
|
|
168
|
+
</opf:manifest>
|
|
169
|
+
<opf:spine><opf:itemref idref="header" linear="yes"/><opf:itemref idref="section0" linear="yes"/></opf:spine>
|
|
170
|
+
</opf:package>`;
|
|
171
|
+
|
|
172
|
+
/*
|
|
173
|
+
* 글자·문단 모양표.
|
|
174
|
+
*
|
|
175
|
+
* 네 벌만 둔다 — 본문과 제목 셋. 더 두면 그만큼 어긋날 자리가 늘고, 우리가
|
|
176
|
+
* 내놓는 글에는 그 이상이 없다.
|
|
177
|
+
*
|
|
178
|
+
* 글꼴은 **이름만 적고 안 싣는다.** 사내 PC 에 함초롬바탕이 없는 일은 거의
|
|
179
|
+
* 없고, 없더라도 한글이 알아서 대체한다. 글꼴 파일을 담기 시작하면 그건
|
|
180
|
+
* 저작권을 우리가 지고 가는 일이다.
|
|
181
|
+
*/
|
|
182
|
+
const HEADER_XML = `${머리}
|
|
183
|
+
<hh:head xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head" xmlns:hc="http://www.hancom.co.kr/hwpml/2011/core" version="1.4" secCnt="1">
|
|
184
|
+
<hh:beginNum page="1" footnote="1" endnote="1" pic="1" tbl="1" equation="1"/>
|
|
185
|
+
<hh:refList>
|
|
186
|
+
<hh:fontfaces itemCnt="1">
|
|
187
|
+
<hh:fontface lang="HANGUL" fontCnt="1"><hh:font id="0" face="함초롬바탕" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="0" proportion="0" contrast="0" strokeVariation="0" armStyle="0" letterform="0" midline="0" xHeight="0"/></hh:font></hh:fontface>
|
|
188
|
+
</hh:fontfaces>
|
|
189
|
+
<hh:borderFills itemCnt="1"><hh:borderFill id="1" threeD="0" shadow="0" centerLine="NONE" breakCellSeparateLine="0"><hh:slash type="NONE" Crooked="0" isCounter="0"/><hh:backSlash type="NONE" Crooked="0" isCounter="0"/><hh:leftBorder type="NONE" width="0.1 mm" color="#000000"/><hh:rightBorder type="NONE" width="0.1 mm" color="#000000"/><hh:topBorder type="NONE" width="0.1 mm" color="#000000"/><hh:bottomBorder type="NONE" width="0.1 mm" color="#000000"/><hh:diagonal type="SOLID" width="0.1 mm" color="#000000"/></hh:borderFill></hh:borderFills>
|
|
190
|
+
<hh:charProperties itemCnt="4">
|
|
191
|
+
${[[0, 10], [1, 16], [2, 14], [3, 12]].map(([id, pt]) => `<hh:charPr id="${id}" height="${pt * PT}" textColor="#000000" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="1"><hh:fontRef hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/><hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/><hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/><hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/><hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>${id === 0 ? '' : '<hh:bold/>'}</hh:charPr>`).join('\n')}
|
|
192
|
+
</hh:charProperties>
|
|
193
|
+
<hh:paraProperties itemCnt="4">
|
|
194
|
+
${[0, 1, 2, 3].map((id) => `<hh:paraPr id="${id}" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="1" suppressLineNumbers="0" checked="0"><hh:align horizontal="JUSTIFY" vertical="BASELINE"/><hh:heading type="NONE" idRef="0" level="0"/><hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="0" keepWithNext="${id === 0 ? '0' : '1'}" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/><hh:margin><hc:intent value="0" unit="HWPUNIT"/><hc:left value="0" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="${id === 0 ? 0 : 800}" unit="HWPUNIT"/><hc:next value="${id === 0 ? 0 : 300}" unit="HWPUNIT"/></hh:margin><hh:lineSpacing type="PERCENT" value="160" unit="HWPUNIT"/><hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/></hh:paraPr>`).join('\n')}
|
|
195
|
+
</hh:paraProperties>
|
|
196
|
+
<hh:styles itemCnt="4">
|
|
197
|
+
${[['바탕글', 'Normal', 0], ['개요 1', 'Outline 1', 1], ['개요 2', 'Outline 2', 2], ['개요 3', 'Outline 3', 3]].map(([이름, 영, id]) => `<hh:style id="${id}" type="PARA" name="${이름}" engName="${영}" paraPrIDRef="${id}" charPrIDRef="${id}" nextStyleIDRef="0" langID="1042" lockForm="0"/>`).join('\n')}
|
|
198
|
+
</hh:styles>
|
|
199
|
+
</hh:refList>
|
|
200
|
+
</hh:head>`;
|
|
201
|
+
|
|
202
|
+
const 문단XML = (p, 번호) => {
|
|
203
|
+
const id = 갈래[p.갈래] ?? 0;
|
|
204
|
+
return `<hp:p id="${번호}" paraPrIDRef="${id}" styleIDRef="${id}" pageBreak="0" columnBreak="0" merged="0">`
|
|
205
|
+
+ `<hp:run charPrIDRef="${id}"><hp:t>${esc(p.글)}</hp:t></hp:run>`
|
|
206
|
+
+ `<hp:linesegarray><hp:lineseg textpos="0" vertpos="0" vertsize="1000" textheight="1000" baseline="850" spacing="600" horzpos="0" horzsize="42520" flags="393216"/></hp:linesegarray>`
|
|
207
|
+
+ '</hp:p>';
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const sectionXML = (문단들) => `${머리}
|
|
211
|
+
<hs:sec xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section" xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph" xmlns:hc="http://www.hancom.co.kr/hwpml/2011/core">
|
|
212
|
+
${(문단들.length ? 문단들 : [{ 갈래: '본문', 글: '' }]).map(문단XML).join('\n')}
|
|
213
|
+
</hs:sec>`;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 글을 hwpx 한 덩이(Buffer)로 만든다.
|
|
217
|
+
*
|
|
218
|
+
* 파일로 쓰지 않는다 — 쓰는 자리는 울타리(safety/guard.js)를 지나야 하고,
|
|
219
|
+
* 그 판단은 도구 쪽이 한다. 여기는 바이트만 만든다.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} 글 마크다운스러운 글
|
|
222
|
+
* @param {object} [o]
|
|
223
|
+
* @param {string} [o.제목] 문서 속성의 제목. 안 주면 첫 제목 줄, 그것도 없으면 '문서'
|
|
224
|
+
* @returns {{buf: Buffer, 문단수: number, 잘림: number, 표몇개: number, 제목: string}}
|
|
225
|
+
*/
|
|
226
|
+
export function hwpx만들기(글, { 제목 = null } = {}) {
|
|
227
|
+
const { 문단들, 잘림, 표몇개 } = 글읽기(글);
|
|
228
|
+
const 쓸제목 = String(제목 ?? '').trim()
|
|
229
|
+
|| 문단들.find((p) => p.갈래.startsWith('제목'))?.글
|
|
230
|
+
|| '문서';
|
|
231
|
+
|
|
232
|
+
/*
|
|
233
|
+
* 미리보기 글을 같이 담는다.
|
|
234
|
+
*
|
|
235
|
+
* 한글은 이걸로 파일 목록의 미리보기를 그리고, 윈도우 탐색기의 내용 검색도
|
|
236
|
+
* 이 파일을 본다. 없다고 안 열리지는 않지만, 없으면 **사내 공유폴더에서
|
|
237
|
+
* 검색이 안 걸린다** — 문서를 만들어 주는 값의 절반이 거기 있다.
|
|
238
|
+
*/
|
|
239
|
+
const 미리보기 = 문단들.map((p) => p.글).join('\n').slice(0, 3000);
|
|
240
|
+
|
|
241
|
+
const 담을것 = [
|
|
242
|
+
// mimetype 은 맨 앞에. 20바이트라 눌러도 커지므로 그냥 담긴다 (pack/zip.js).
|
|
243
|
+
{ name: 'mimetype', data: Buffer.from('application/hwp+zip', 'utf8') },
|
|
244
|
+
{ name: 'version.xml', data: Buffer.from(VERSION_XML, 'utf8') },
|
|
245
|
+
{ name: 'META-INF/container.xml', data: Buffer.from(CONTAINER_XML, 'utf8') },
|
|
246
|
+
{ name: 'META-INF/manifest.xml', data: Buffer.from(MANIFEST_XML, 'utf8') },
|
|
247
|
+
{ name: 'Contents/content.hpf', data: Buffer.from(contentHpf(쓸제목), 'utf8') },
|
|
248
|
+
{ name: 'Contents/header.xml', data: Buffer.from(HEADER_XML, 'utf8') },
|
|
249
|
+
{ name: 'Contents/section0.xml', data: Buffer.from(sectionXML(문단들), 'utf8') },
|
|
250
|
+
{ name: 'Preview/PrvText.txt', data: Buffer.from(미리보기, 'utf8') },
|
|
251
|
+
{ name: 'settings.xml', data: Buffer.from(SETTINGS_XML, 'utf8') },
|
|
252
|
+
];
|
|
253
|
+
return { buf: makeZip(담을것), 문단수: 문단들.length, 잘림, 표몇개, 제목: 쓸제목 };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 만들고 나서 사람에게 할 말.
|
|
258
|
+
*
|
|
259
|
+
* **바꿔치기한 것을 반드시 적는다.** 표를 줄 글로 폈으면 그렇다고 적고, 문단을
|
|
260
|
+
* 잘랐으면 몇 개를 잘랐는지 적는다. 조용히 넘기면 사람은 문서를 열어 보고서야
|
|
261
|
+
* 알고, 그때는 이미 그 문서를 남에게 보낸 뒤다.
|
|
262
|
+
*/
|
|
263
|
+
export function 만든말(r, 보인이름) {
|
|
264
|
+
const 줄 = [`${보인이름} · ${r.문단수}문단`];
|
|
265
|
+
if (r.표몇개) 줄.push(`표 ${r.표몇개}개는 줄 글로 폈습니다 (표 서식은 안 만듭니다)`);
|
|
266
|
+
if (r.잘림) 줄.push(`문단 ${r.잘림}개는 넘쳐서 잘랐습니다`);
|
|
267
|
+
return 줄.join(' · ');
|
|
268
|
+
}
|