@kaydash9999/banker-plugins 0.11.0 → 0.13.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +52 -0
- package/README.md +28 -8
- package/codex/manifest.json +8 -1
- package/codex/transform-matrix.md +2 -2
- package/commands/setup.md +2 -0
- package/package.json +6 -1
- package/skills/3d-intro-build/SKILL.md +214 -0
- package/skills/3d-intro-build/references/LICENSE +21 -0
- package/skills/3d-intro-build/references/NOTICE.md +32 -0
- package/skills/3d-intro-build/references/assemble.mjs +252 -0
- package/skills/3d-intro-build/references/azure-adapter.mjs +495 -0
- package/skills/3d-intro-build/references/curate.mjs +301 -0
- package/skills/3d-intro-build/references/index-template.html +73 -0
- package/skills/3d-intro-build/references/prompts.md +171 -0
- package/skills/3d-intro-build/references/scrub-engine.js +448 -0
- package/skills/3d-intro-build/references/serve.mjs +123 -0
- package/skills/3d-intro-setup/SKILL.md +196 -0
- package/skills/3d-intro-setup/references/azure-adapter.mjs +495 -0
- package/skills/lineage/SKILL.md +126 -134
- package/skills/lineage/lineage.py +1041 -354
- package/skills/motion-graphic-make/SKILL.md +57 -0
- package/skills/motion-graphic-setup/SKILL.md +141 -0
- package/skills/ralph-qa/SKILL.md +253 -77
- package/skills/ralph-qa/references/verifier-probe.mjs +860 -0
- package/skills/refresh-readme/SKILL.md +3 -3
- package/skills/vertical-pptx/SKILL.md +409 -0
- package/skills/vertical-pptx/build_a4p.js +923 -0
- package/skills/vertical-pptx/build_a4p.py +916 -0
- package/skills/vertical-pptx/verify_a4p.mjs +2168 -0
- package/skills/vertical-pptx-setup/SKILL.md +223 -0
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/*
|
|
4
|
+
* vertical-pptx — A4 세로(210 x 297mm) PPTX node 빌더.
|
|
5
|
+
*
|
|
6
|
+
* 이 파일이 하는 일은 두 가지뿐이다: deck JSON 을 좌표로 바꾸고(layout), 그 좌표대로 도형을 찍는다(build).
|
|
7
|
+
* 마크다운 → deck JSON 변환기도 여기 한 벌만 있다(python 빌더는 deck JSON 만 먹는다).
|
|
8
|
+
*
|
|
9
|
+
* ★ INV-G (기하 불변식) — 이 파일의 존재 이유이자 절대 깨면 안 되는 규칙.
|
|
10
|
+
* 모든 도형의 x·y·cx·cy 는 다음 값들만의 순수 함수다:
|
|
11
|
+
* meta.marginMm · 페이지 인덱스 · 블록 순서 · 블록 type · bullets.items 배열 길이 ·
|
|
12
|
+
* 명시된 rows · 컬럼 스팬.
|
|
13
|
+
* 텍스트의 내용·길이·폰트·pt 는 어떤 좌표에도 들어가지 않는다.
|
|
14
|
+
* 이 불변식이 두 빌더(node·python)를 교체 가능하게 만들고, 폭 측정을 검증기 단독 소유로 만든다.
|
|
15
|
+
*
|
|
16
|
+
* ★ 측정은 여기서 하지 않는다.
|
|
17
|
+
* 폰트 바이너리 파싱·폭 합산·오버플로 판정은 verify_a4p.mjs 단독 소유다.
|
|
18
|
+
* 이 파일은 폰트 "이름" 을 고르고 그 파일이 존재하는지만 확인해서 경로를 보고한다.
|
|
19
|
+
*
|
|
20
|
+
* 기하 (단위 EMU, 1mm = 36000, m = meta.marginMm):
|
|
21
|
+
* 슬라이드 7560000 x 10692000 (고정)
|
|
22
|
+
* 여백 36000m · 본문 (7560000 - 72000m) x (10692000 - 72000m) · 거터 144000 (4mm 고정)
|
|
23
|
+
* 8열: 컬럼 폭 (182 - 2m) * 4500 · 20행: 행 높이 (221 - 2m) * 1800
|
|
24
|
+
* 컬럼 c 에서 k 열: x = 36000m + (컬럼폭 + 144000) * c · cx = 컬럼폭 * k + 144000 * (k - 1)
|
|
25
|
+
* 행 r 에서 n 행: y = 36000m + (행높이 + 144000) * r · cy = 행높이 * n + 144000 * (n - 1)
|
|
26
|
+
* m=10 검산: 컬럼 폭 729000 · 행 높이 361800 · 피치 873000 / 505800
|
|
27
|
+
*
|
|
28
|
+
* 행 수 함수 (측정 없음):
|
|
29
|
+
* rows(block) = min(18, block.rows ?? default(type))
|
|
30
|
+
* default: head=1 · body=2 · bullets=max(1, items.length) · 페이지 제목 = 2행 고정(모든 슬라이드 상단 예약)
|
|
31
|
+
* ★ 클램프 발동 조건은 block.rows 가 아니라 block.rows ?? default(type) 다.
|
|
32
|
+
* 항목 19개짜리 불릿에 rows 를 안 적어도 클램프가 걸리고, 그때 경고가 없으면
|
|
33
|
+
* 항목 17·18 이 조용히 겹친 채 출하된다.
|
|
34
|
+
*
|
|
35
|
+
* 페이지 채우기(세는 규칙만): avail = 20 - 2 = 18. used + rows(block) > 18 이면 새 슬라이드.
|
|
36
|
+
* 블록은 쪼개지 않는다(쪼개려면 측정이 필요하다).
|
|
37
|
+
*
|
|
38
|
+
* ── python 빌더와 맞춰야 하는 항목 (E1 교차 동등성) ─────────────────────────────
|
|
39
|
+
* a:bodyPr wrap="square" lIns=tIns=rIns=bIns="0" rtlCol="0" anchor="t" · autofit 요소 없음
|
|
40
|
+
* 3슬롯 a:latin / a:ea / a:cs 에 같은 typeface (pptxgenjs 는 fontFace 하나로 셋 다 emit)
|
|
41
|
+
* 줄간 1.2 배수 (오버플로 견적이 이 값을 가정한다)
|
|
42
|
+
* 불릿 글리프 항목 텍스트 앞에 '• ' 를 직접 붙인다 (buChar 를 쓰지 않는다 — 이식이 쉬운 쪽)
|
|
43
|
+
* pt 제목 = bodyPt + 6 · head = bodyPt + 2 · body/bullets = bodyPt
|
|
44
|
+
* 색 제목/head 1A1A1A · body/bullets 222222
|
|
45
|
+
* warnings {code, page, block, detail} · detail 은 숫자만 담는 ASCII (로케일 문자열 금지)
|
|
46
|
+
*
|
|
47
|
+
* 종료 코드: 0 통과 · 1 검증 실패 · 2 환경 미비 · 3 사용법 오류.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
const fs = require('fs');
|
|
51
|
+
const os = require('os');
|
|
52
|
+
const path = require('path');
|
|
53
|
+
const cp = require('child_process');
|
|
54
|
+
|
|
55
|
+
// ── 상수 ────────────────────────────────────────────────────────────────────
|
|
56
|
+
const EMU_PER_MM = 36000;
|
|
57
|
+
const SLIDE_W = 210 * EMU_PER_MM; // 7560000
|
|
58
|
+
const SLIDE_H = 297 * EMU_PER_MM; // 10692000
|
|
59
|
+
const GUTTER = 4 * EMU_PER_MM; // 144000
|
|
60
|
+
const COLS = 8;
|
|
61
|
+
const ROWS = 20;
|
|
62
|
+
const TITLE_ROWS = 2;
|
|
63
|
+
const MAX_BLOCK_ROWS = ROWS - TITLE_ROWS; // 18
|
|
64
|
+
const MARGIN_MM_MIN = 10; // 동결 스펙의 "여백 10mm 이상"
|
|
65
|
+
const MARGIN_MM_MAX = 40;
|
|
66
|
+
const DEFAULT_MARGIN_MM = 10;
|
|
67
|
+
const DEFAULT_BODY_PT = 10.5; // 동결 범위 10~11pt
|
|
68
|
+
const LINE_SPACING = 1.2;
|
|
69
|
+
const LAYOUT_NAME = 'A4P';
|
|
70
|
+
const BULLET_PREFIX = '\u2022 ';
|
|
71
|
+
const COLOR_HEAD = '1A1A1A';
|
|
72
|
+
const COLOR_BODY = '222222';
|
|
73
|
+
const BLOCK_TYPES = ['head', 'body', 'bullets'];
|
|
74
|
+
const ORIENTATIONS = ['portrait', 'landscape'];
|
|
75
|
+
const EXIT = { OK: 0, VERIFY: 1, ENV: 2, USAGE: 3 };
|
|
76
|
+
|
|
77
|
+
// 폰트 체인. --font → BANKER_PPTX_FONT → meta.font → 아래 목록 → 시스템 한국어 sans.
|
|
78
|
+
// "Noto Sans KR" 은 "Noto Sans CJK KR" 의 별칭이 아니라 **다른 패밀리**다. 그래서 별도 항목이다.
|
|
79
|
+
// Windows 실측(2026-08-09): C:\Windows\Fonts\NotoSansKR-VF.ttf 의 name 테이블 nameID 1·4 가
|
|
80
|
+
// "Noto Sans KR" 이다. 이걸 CJK 항목의 힌트로 묶어 두면 파일은 찾되 pptx 에는 요청한 이름
|
|
81
|
+
// "Noto Sans CJK KR" 을 적게 되고, 그 이름은 그 머신에 없어서 뷰어가 대체 폰트로 치환한다.
|
|
82
|
+
// 그러면 구속 결정 3(체인의 존재 이유 = 실제로 있는 이름을 써서 줄바꿈을 고정한다)이 무너진다.
|
|
83
|
+
// AppleGothic 도 Apple SD Gothic Neo 의 줄임말이 아니라 **다른 폰트**다. macOS 실측(2026-08-09):
|
|
84
|
+
// /System/Library/Fonts/AppleSDGothicNeo.ttc 와 /System/Library/Fonts/Supplemental/AppleGothic.ttf
|
|
85
|
+
// 가 함께 설치돼 있다. 구형을 신형 이름으로 부르면 위와 같은 오명명이 난다.
|
|
86
|
+
const FONT_CHAIN = ['Noto Sans CJK KR', 'Noto Sans KR', 'Malgun Gothic', 'Apple SD Gothic Neo', 'AppleGothic', 'NanumGothic'];
|
|
87
|
+
// fc-match 가 없는 환경에서 파일명으로 찾기 위한 힌트(정규화된 소문자 조각).
|
|
88
|
+
// ★ 힌트는 그 항목의 패밀리보다 느슨하면 안 된다. 느슨한 힌트는 다른 패밀리의 파일을 잡고,
|
|
89
|
+
// glob 경로에는 fc-match 같은 패밀리 대조가 없어서 그 불일치가 그대로 pptx 에 적힌다.
|
|
90
|
+
const FONT_FILE_HINTS = {
|
|
91
|
+
'noto sans cjk kr': ['notosanscjkkr', 'notosanscjk'],
|
|
92
|
+
'noto sans kr': ['notosanskr'],
|
|
93
|
+
'malgun gothic': ['malgun'],
|
|
94
|
+
'apple sd gothic neo': ['applesdgothicneo'],
|
|
95
|
+
applegothic: ['applegothic'],
|
|
96
|
+
nanumgothic: ['nanumgothic'],
|
|
97
|
+
};
|
|
98
|
+
const FONT_EXT = new Set(['.ttf', '.ttc', '.otf', '.otc']);
|
|
99
|
+
|
|
100
|
+
// ── 오류 타입 ───────────────────────────────────────────────────────────────
|
|
101
|
+
class UsageError extends Error {
|
|
102
|
+
constructor(message) { super(message); this.name = 'UsageError'; this.exitCode = EXIT.USAGE; }
|
|
103
|
+
}
|
|
104
|
+
class EnvError extends Error {
|
|
105
|
+
constructor(message) { super(message); this.name = 'EnvError'; this.exitCode = EXIT.ENV; }
|
|
106
|
+
}
|
|
107
|
+
class VerifyError extends Error {
|
|
108
|
+
constructor(message) { super(message); this.name = 'VerifyError'; this.exitCode = EXIT.VERIFY; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── 그리드 ──────────────────────────────────────────────────────────────────
|
|
112
|
+
function gridFor(marginMm) {
|
|
113
|
+
const m = marginMm;
|
|
114
|
+
const colW = (182 - 2 * m) * 4500;
|
|
115
|
+
const rowH = (221 - 2 * m) * 1800;
|
|
116
|
+
return {
|
|
117
|
+
marginMm: m,
|
|
118
|
+
margin: EMU_PER_MM * m,
|
|
119
|
+
slideW: SLIDE_W,
|
|
120
|
+
slideH: SLIDE_H,
|
|
121
|
+
contentW: SLIDE_W - 2 * EMU_PER_MM * m,
|
|
122
|
+
contentH: SLIDE_H - 2 * EMU_PER_MM * m,
|
|
123
|
+
gutter: GUTTER,
|
|
124
|
+
cols: COLS,
|
|
125
|
+
rows: ROWS,
|
|
126
|
+
colW,
|
|
127
|
+
rowH,
|
|
128
|
+
colPitch: colW + GUTTER,
|
|
129
|
+
rowPitch: rowH + GUTTER,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const colX = (g, c) => g.margin + g.colPitch * c;
|
|
134
|
+
const colCx = (g, k) => g.colW * k + GUTTER * (k - 1);
|
|
135
|
+
const rowY = (g, r) => g.margin + g.rowPitch * r;
|
|
136
|
+
const rowCy = (g, n) => g.rowH * n + GUTTER * (n - 1);
|
|
137
|
+
|
|
138
|
+
function defaultRows(block) {
|
|
139
|
+
if (block.type === 'head') return 1;
|
|
140
|
+
if (block.type === 'body') return 2;
|
|
141
|
+
return Math.max(1, block.items.length); // bullets
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 클램프 이전의 요청 행 수. 클램프 발동 판정은 반드시 이 값으로 한다. */
|
|
145
|
+
const requestedRows = (block) => (block.rows === undefined ? defaultRows(block) : block.rows);
|
|
146
|
+
const rowsOf = (block) => Math.min(MAX_BLOCK_ROWS, requestedRows(block));
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 불릿 항목별 행 배분.
|
|
150
|
+
* 항목 i 는 min(i, n-1) 행에서 시작하고, 남는 행은 전부 마지막 항목이 가져간다.
|
|
151
|
+
* n < items.length 면 n-1 행부터 겹쳐 쌓인다 — 그 겹침을 보고하는 것은 검증기 몫이다.
|
|
152
|
+
*/
|
|
153
|
+
function bulletSpans(n, count) {
|
|
154
|
+
const spans = [];
|
|
155
|
+
for (let i = 0; i < count; i += 1) {
|
|
156
|
+
const start = Math.min(i, n - 1);
|
|
157
|
+
const end = i === count - 1 ? n - 1 : Math.min(i + 1, n - 1) - 1;
|
|
158
|
+
spans.push({ start, rows: Math.max(1, end - start + 1) });
|
|
159
|
+
}
|
|
160
|
+
return spans;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── deck 검증 (전부 exit 3) ─────────────────────────────────────────────────
|
|
164
|
+
function isPlainObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
|
|
165
|
+
|
|
166
|
+
function orientationOf(deck, page) {
|
|
167
|
+
const meta = deck.meta || {};
|
|
168
|
+
return page.orientation || meta.orientation || 'portrait';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function validateDeck(deck) {
|
|
172
|
+
if (!isPlainObject(deck)) throw new UsageError('deck JSON 최상위는 객체여야 한다.');
|
|
173
|
+
const meta = deck.meta === undefined ? {} : deck.meta;
|
|
174
|
+
if (!isPlainObject(meta)) throw new UsageError('meta 는 객체여야 한다.');
|
|
175
|
+
|
|
176
|
+
if (meta.marginMm !== undefined) {
|
|
177
|
+
const m = meta.marginMm;
|
|
178
|
+
if (!Number.isInteger(m) || m < MARGIN_MM_MIN || m > MARGIN_MM_MAX) {
|
|
179
|
+
throw new UsageError(
|
|
180
|
+
`meta.marginMm 은 ${MARGIN_MM_MIN} 이상 ${MARGIN_MM_MAX} 이하의 정수여야 한다 (받은 값: ${JSON.stringify(m)}).`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (meta.bodyPt !== undefined) {
|
|
185
|
+
const pt = meta.bodyPt;
|
|
186
|
+
if (typeof pt !== 'number' || !Number.isFinite(pt) || pt <= 0 || pt > 400) {
|
|
187
|
+
throw new UsageError(`meta.bodyPt 는 0 초과 400 이하의 수여야 한다 (받은 값: ${JSON.stringify(pt)}).`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (meta.title !== undefined && typeof meta.title !== 'string') throw new UsageError('meta.title 은 문자열이어야 한다.');
|
|
191
|
+
// 길이 상한은 여기서 거부하지 않는다. clampCoreTitle 이 두 빌더에서 똑같이 잘라내고 경고한다.
|
|
192
|
+
if (meta.font !== undefined && typeof meta.font !== 'string') throw new UsageError('meta.font 는 문자열이어야 한다.');
|
|
193
|
+
if (meta.orientation !== undefined && !ORIENTATIONS.includes(meta.orientation)) {
|
|
194
|
+
throw new UsageError(`meta.orientation 은 ${ORIENTATIONS.join(' 또는 ')} 여야 한다.`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!Array.isArray(deck.pages) || deck.pages.length === 0) throw new UsageError('pages 는 비어 있지 않은 배열이어야 한다.');
|
|
198
|
+
|
|
199
|
+
deck.pages.forEach((page, pi) => {
|
|
200
|
+
const at = `pages[${pi}]`;
|
|
201
|
+
if (!isPlainObject(page)) throw new UsageError(`${at} 는 객체여야 한다.`);
|
|
202
|
+
if (page.title !== undefined && typeof page.title !== 'string') throw new UsageError(`${at}.title 은 문자열이어야 한다.`);
|
|
203
|
+
if (page.orientation !== undefined && !ORIENTATIONS.includes(page.orientation)) {
|
|
204
|
+
throw new UsageError(`${at}.orientation 은 ${ORIENTATIONS.join(' 또는 ')} 여야 한다.`);
|
|
205
|
+
}
|
|
206
|
+
const blocks = page.blocks === undefined ? [] : page.blocks;
|
|
207
|
+
if (!Array.isArray(blocks)) throw new UsageError(`${at}.blocks 는 배열이어야 한다.`);
|
|
208
|
+
blocks.forEach((block, bi) => {
|
|
209
|
+
const bat = `${at}.blocks[${bi}]`;
|
|
210
|
+
if (!isPlainObject(block)) throw new UsageError(`${bat} 는 객체여야 한다.`);
|
|
211
|
+
if (!BLOCK_TYPES.includes(block.type)) {
|
|
212
|
+
throw new UsageError(`${bat}.type 은 ${BLOCK_TYPES.join(' / ')} 중 하나여야 한다 (받은 값: ${JSON.stringify(block.type)}).`);
|
|
213
|
+
}
|
|
214
|
+
if (block.type === 'bullets') {
|
|
215
|
+
if (!Array.isArray(block.items) || block.items.length === 0) throw new UsageError(`${bat}.items 는 비어 있지 않은 배열이어야 한다.`);
|
|
216
|
+
block.items.forEach((it, ii) => {
|
|
217
|
+
if (typeof it !== 'string') throw new UsageError(`${bat}.items[${ii}] 는 문자열이어야 한다.`);
|
|
218
|
+
});
|
|
219
|
+
} else if (typeof block.text !== 'string') {
|
|
220
|
+
throw new UsageError(`${bat}.text 는 문자열이어야 한다.`);
|
|
221
|
+
}
|
|
222
|
+
if (block.rows !== undefined && (!Number.isInteger(block.rows) || block.rows < 1)) {
|
|
223
|
+
throw new UsageError(`${bat}.rows 는 1 이상의 정수여야 한다 (받은 값: ${JSON.stringify(block.rows)}).`);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// 방향 혼합은 파일 형식이 허용하지 않는다 — 하나의 pptx 는 하나의 sldSz 만 갖는다.
|
|
229
|
+
const seen = new Set(deck.pages.map((p) => orientationOf(deck, p)));
|
|
230
|
+
if (seen.size > 1) {
|
|
231
|
+
throw new UsageError(
|
|
232
|
+
'한 pptx 는 세로와 가로를 함께 담을 수 없다(슬라이드 크기가 파일당 하나다). ' +
|
|
233
|
+
'방향별로 파일을 나누어 각각 빌드해라 — split the deck into separate files, one per orientation.'
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (seen.has('landscape')) {
|
|
237
|
+
throw new UsageError('이 스킬은 A4 세로(210x297mm) 전용이다. 가로 덱은 대상이 아니다.');
|
|
238
|
+
}
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── layout: deck JSON → 좌표 (INV-G 의 구현체) ──────────────────────────────
|
|
243
|
+
/**
|
|
244
|
+
* @param {object} deck deck JSON
|
|
245
|
+
* @returns {{grid:object, bodyPt:number, slides:Array, warnings:Array}}
|
|
246
|
+
* slides[i].shapes[j] 는 {role, pageIndex, blockIndex, itemIndex, rows, x, y, cx, cy, text, pt, bold, color}.
|
|
247
|
+
* pageIndex·blockIndex 가 있어야 검증기가 (슬라이드, 도형) 을 (페이지, 블록) 으로 되돌릴 수 있다.
|
|
248
|
+
*/
|
|
249
|
+
function layout(deck) {
|
|
250
|
+
validateDeck(deck);
|
|
251
|
+
const meta = deck.meta || {};
|
|
252
|
+
const grid = gridFor(meta.marginMm === undefined ? DEFAULT_MARGIN_MM : meta.marginMm);
|
|
253
|
+
const bodyPt = meta.bodyPt === undefined ? DEFAULT_BODY_PT : meta.bodyPt;
|
|
254
|
+
const pt = { title: bodyPt + 6, head: bodyPt + 2, body: bodyPt, bullets: bodyPt };
|
|
255
|
+
const fullCx = colCx(grid, COLS);
|
|
256
|
+
const slides = [];
|
|
257
|
+
const warnings = [];
|
|
258
|
+
|
|
259
|
+
deck.pages.forEach((page, pageIndex) => {
|
|
260
|
+
const title = page.title === undefined ? '' : page.title;
|
|
261
|
+
let slide = null;
|
|
262
|
+
let used = 0;
|
|
263
|
+
let part = 0;
|
|
264
|
+
|
|
265
|
+
const openSlide = () => {
|
|
266
|
+
slide = { index: slides.length, pageIndex, part, title, shapes: [] };
|
|
267
|
+
part += 1;
|
|
268
|
+
used = 0;
|
|
269
|
+
slide.shapes.push({
|
|
270
|
+
role: 'title',
|
|
271
|
+
pageIndex,
|
|
272
|
+
blockIndex: null,
|
|
273
|
+
itemIndex: null,
|
|
274
|
+
rows: TITLE_ROWS,
|
|
275
|
+
x: colX(grid, 0),
|
|
276
|
+
y: rowY(grid, 0),
|
|
277
|
+
cx: fullCx,
|
|
278
|
+
cy: rowCy(grid, TITLE_ROWS),
|
|
279
|
+
text: title,
|
|
280
|
+
pt: pt.title,
|
|
281
|
+
bold: true,
|
|
282
|
+
color: COLOR_HEAD,
|
|
283
|
+
});
|
|
284
|
+
slides.push(slide);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
openSlide();
|
|
288
|
+
|
|
289
|
+
const blocks = page.blocks === undefined ? [] : page.blocks;
|
|
290
|
+
blocks.forEach((block, blockIndex) => {
|
|
291
|
+
const want = requestedRows(block);
|
|
292
|
+
const n = rowsOf(block);
|
|
293
|
+
if (want > MAX_BLOCK_ROWS) {
|
|
294
|
+
warnings.push({
|
|
295
|
+
code: 'BLOCK_TOO_TALL',
|
|
296
|
+
page: pageIndex,
|
|
297
|
+
block: blockIndex,
|
|
298
|
+
detail: `rows ${want} -> ${n}`,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
// 세는 규칙만: 넘치면 새 슬라이드로 넘긴다. 블록은 절대 쪼개지 않는다.
|
|
302
|
+
if (used > 0 && used + n > MAX_BLOCK_ROWS) openSlide();
|
|
303
|
+
const base = TITLE_ROWS + used;
|
|
304
|
+
|
|
305
|
+
if (block.type === 'bullets') {
|
|
306
|
+
bulletSpans(n, block.items.length).forEach((span, itemIndex) => {
|
|
307
|
+
slide.shapes.push({
|
|
308
|
+
role: 'bullet',
|
|
309
|
+
pageIndex,
|
|
310
|
+
blockIndex,
|
|
311
|
+
itemIndex,
|
|
312
|
+
rows: span.rows,
|
|
313
|
+
x: colX(grid, 0),
|
|
314
|
+
y: rowY(grid, base + span.start),
|
|
315
|
+
cx: fullCx,
|
|
316
|
+
cy: rowCy(grid, span.rows),
|
|
317
|
+
text: BULLET_PREFIX + block.items[itemIndex],
|
|
318
|
+
pt: pt.bullets,
|
|
319
|
+
bold: false,
|
|
320
|
+
color: COLOR_BODY,
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
} else {
|
|
324
|
+
slide.shapes.push({
|
|
325
|
+
role: block.type,
|
|
326
|
+
pageIndex,
|
|
327
|
+
blockIndex,
|
|
328
|
+
itemIndex: null,
|
|
329
|
+
rows: n,
|
|
330
|
+
x: colX(grid, 0),
|
|
331
|
+
y: rowY(grid, base),
|
|
332
|
+
cx: fullCx,
|
|
333
|
+
cy: rowCy(grid, n),
|
|
334
|
+
text: block.text,
|
|
335
|
+
pt: block.type === 'head' ? pt.head : pt.body,
|
|
336
|
+
bold: block.type === 'head',
|
|
337
|
+
color: block.type === 'head' ? COLOR_HEAD : COLOR_BODY,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
used += n;
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
return { grid, bodyPt, slides, warnings };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** 기하가 실제로 여백 안에 있는지 자기 점검. 클램프가 살아 있으면 절대 발동하지 않는다. */
|
|
348
|
+
function assertInsideSlide(laid) {
|
|
349
|
+
const g = laid.grid;
|
|
350
|
+
const bad = [];
|
|
351
|
+
laid.slides.forEach((s) => {
|
|
352
|
+
s.shapes.forEach((sh, j) => {
|
|
353
|
+
if (sh.x < g.margin || sh.y < g.margin || sh.x + sh.cx > SLIDE_W - g.margin || sh.y + sh.cy > SLIDE_H - g.margin) {
|
|
354
|
+
bad.push(`slide ${s.index} shape ${j} (${sh.role}): x=${sh.x} y=${sh.y} cx=${sh.cx} cy=${sh.cy}`);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
if (bad.length) throw new VerifyError(`도형이 여백 밖으로 나갔다 — 기하 버그다:\n ${bad.join('\n ')}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── 마크다운 → deck JSON (이 프로젝트의 유일한 마크다운 파서) ────────────────
|
|
362
|
+
const RE_HEADING = /^(#{1,6})\s+(.*)$/;
|
|
363
|
+
const RE_BULLET = /^\s*([-*+]|\d+[.)])\s+(.*)$/;
|
|
364
|
+
const RE_HRULE = /^\s*([-*_])(\s*\1){2,}\s*$/;
|
|
365
|
+
const RE_FENCE = /^\s*(```|~~~)/;
|
|
366
|
+
|
|
367
|
+
/** 인라인 마크업 정리. 기하에는 영향이 없고 화면에 남는 별표만 없앤다. */
|
|
368
|
+
function inlineText(s) {
|
|
369
|
+
return String(s)
|
|
370
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
371
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
|
|
372
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
373
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
374
|
+
.replace(/\*([^*]+)\*/g, '$1')
|
|
375
|
+
.replace(/~~([^~]+)~~/g, '$1')
|
|
376
|
+
.replace(/\\([\\`*_{}[\]()#+\-.!])/g, '$1')
|
|
377
|
+
.trim();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* @param {string} md 마크다운 원문
|
|
382
|
+
* @returns {{deck:object, warnings:Array}}
|
|
383
|
+
* h1 → 새 페이지 · h2 → head · 불릿 → bullets · 문단 → body · h3 이하 → body 강등 + 경고 1건.
|
|
384
|
+
* ★ 변환기는 rows 를 계산하지 않는다. 글자 수는 폰트와 폭을 모르는 조잡한 측정이고,
|
|
385
|
+
* 정확해 보이는 오답을 만든다. 넘치면 verify_a4p.mjs --suggest-deck 이 재고 나서 고친다.
|
|
386
|
+
*/
|
|
387
|
+
function mdToDeck(md) {
|
|
388
|
+
const raw = String(md).replace(/\r\n?/g, '\n').split('\n');
|
|
389
|
+
const warnings = [];
|
|
390
|
+
const pages = [];
|
|
391
|
+
let title = '';
|
|
392
|
+
let page = null;
|
|
393
|
+
let para = [];
|
|
394
|
+
let items = [];
|
|
395
|
+
let fence = null;
|
|
396
|
+
let code = [];
|
|
397
|
+
let curLine = 0;
|
|
398
|
+
|
|
399
|
+
const ensurePage = () => {
|
|
400
|
+
if (page) return;
|
|
401
|
+
page = { title: '', blocks: [] };
|
|
402
|
+
pages.push(page);
|
|
403
|
+
warnings.push({ code: 'IMPLICIT_PAGE', line: curLine, detail: 'h1 이전 본문' });
|
|
404
|
+
};
|
|
405
|
+
const flushPara = () => {
|
|
406
|
+
if (!para.length) return;
|
|
407
|
+
ensurePage();
|
|
408
|
+
page.blocks.push({ type: 'body', text: para.join(' ') });
|
|
409
|
+
para = [];
|
|
410
|
+
};
|
|
411
|
+
const flushItems = () => {
|
|
412
|
+
if (!items.length) return;
|
|
413
|
+
ensurePage();
|
|
414
|
+
page.blocks.push({ type: 'bullets', items: items.slice() });
|
|
415
|
+
items = [];
|
|
416
|
+
};
|
|
417
|
+
const flushAll = () => { flushPara(); flushItems(); };
|
|
418
|
+
|
|
419
|
+
// YAML frontmatter 는 문서가 아니라 메타다 — 건너뛴다.
|
|
420
|
+
let i = 0;
|
|
421
|
+
if (raw[0] !== undefined && raw[0].trim() === '---') {
|
|
422
|
+
let end = -1;
|
|
423
|
+
for (let k = 1; k < raw.length; k += 1) { if (raw[k].trim() === '---') { end = k; break; } }
|
|
424
|
+
if (end > 0) i = end + 1;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
for (; i < raw.length; i += 1) {
|
|
428
|
+
const line = raw[i];
|
|
429
|
+
const lineNo = i + 1;
|
|
430
|
+
curLine = lineNo;
|
|
431
|
+
|
|
432
|
+
const fenceHit = RE_FENCE.exec(line);
|
|
433
|
+
if (fence) {
|
|
434
|
+
if (fenceHit && fenceHit[1] === fence) {
|
|
435
|
+
ensurePage();
|
|
436
|
+
page.blocks.push({ type: 'body', text: code.join('\n') });
|
|
437
|
+
fence = null;
|
|
438
|
+
code = [];
|
|
439
|
+
} else {
|
|
440
|
+
code.push(line);
|
|
441
|
+
}
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (fenceHit) { flushAll(); fence = fenceHit[1]; code = []; continue; }
|
|
445
|
+
|
|
446
|
+
if (line.trim() === '') { flushAll(); continue; }
|
|
447
|
+
if (RE_HRULE.test(line)) { flushAll(); continue; }
|
|
448
|
+
|
|
449
|
+
const heading = RE_HEADING.exec(line);
|
|
450
|
+
if (heading) {
|
|
451
|
+
flushAll();
|
|
452
|
+
const level = heading[1].length;
|
|
453
|
+
const text = inlineText(heading[2]);
|
|
454
|
+
if (level === 1) {
|
|
455
|
+
page = { title: text, blocks: [] };
|
|
456
|
+
pages.push(page);
|
|
457
|
+
if (!title) title = text;
|
|
458
|
+
} else if (level === 2) {
|
|
459
|
+
ensurePage();
|
|
460
|
+
page.blocks.push({ type: 'head', text });
|
|
461
|
+
} else {
|
|
462
|
+
ensurePage();
|
|
463
|
+
page.blocks.push({ type: 'body', text });
|
|
464
|
+
warnings.push({ code: 'HEADING_DEMOTED', line: lineNo, detail: `h${level} -> body` });
|
|
465
|
+
}
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const bullet = RE_BULLET.exec(line);
|
|
470
|
+
if (bullet) { flushPara(); items.push(inlineText(bullet[2])); continue; }
|
|
471
|
+
|
|
472
|
+
flushItems();
|
|
473
|
+
para.push(inlineText(line.replace(/^\s*>\s?/, '')));
|
|
474
|
+
}
|
|
475
|
+
if (fence) { ensurePage(); page.blocks.push({ type: 'body', text: code.join('\n') }); }
|
|
476
|
+
flushAll();
|
|
477
|
+
|
|
478
|
+
if (!pages.length) pages.push({ title: '', blocks: [] });
|
|
479
|
+
|
|
480
|
+
const deck = {
|
|
481
|
+
meta: { title, font: 'auto', bodyPt: DEFAULT_BODY_PT, marginMm: DEFAULT_MARGIN_MM },
|
|
482
|
+
pages,
|
|
483
|
+
};
|
|
484
|
+
return { deck, warnings };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ── 폰트: 이름을 고르고 파일이 있는지만 확인한다 (바이너리 파싱 0줄) ─────────
|
|
488
|
+
const normName = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
489
|
+
const escFc = (s) => String(s).replace(/([-:,\\])/g, '\\$1');
|
|
490
|
+
// pptxgenjs 는 fontFace 를 XML 속성에 그대로 박는다(이스케이프 없음). 속성을 깨는 문자는 여기서 없앤다.
|
|
491
|
+
const sanitizeFontName = (s) => (typeof s === 'string' ? s.replace(/["'<>&]/g, '').replace(/[\u0000-\u001f\u007f]/g, '').trim() : '');
|
|
492
|
+
|
|
493
|
+
let fcMatchAvailable = null;
|
|
494
|
+
function fcMatch(pattern) {
|
|
495
|
+
if (fcMatchAvailable === false) return null;
|
|
496
|
+
let res;
|
|
497
|
+
try {
|
|
498
|
+
res = cp.spawnSync('fc-match', ['--format=%{file}|%{family}', pattern], {
|
|
499
|
+
encoding: 'utf8', timeout: 5000, windowsHide: true,
|
|
500
|
+
});
|
|
501
|
+
} catch (e) {
|
|
502
|
+
fcMatchAvailable = false;
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
if (res.error || res.status !== 0 || !res.stdout) {
|
|
506
|
+
if (res.error && res.error.code === 'ENOENT') fcMatchAvailable = false;
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
fcMatchAvailable = true;
|
|
510
|
+
const cut = res.stdout.indexOf('|');
|
|
511
|
+
if (cut < 0) return null;
|
|
512
|
+
const file = res.stdout.slice(0, cut).trim();
|
|
513
|
+
const families = res.stdout.slice(cut + 1).split(',').map((s) => s.trim()).filter(Boolean);
|
|
514
|
+
if (!file) return null;
|
|
515
|
+
return { file, families };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function fontSearchDirs() {
|
|
519
|
+
const home = os.homedir();
|
|
520
|
+
if (process.platform === 'darwin') {
|
|
521
|
+
return ['/System/Library/Fonts', '/Library/Fonts', path.join(home, 'Library', 'Fonts')];
|
|
522
|
+
}
|
|
523
|
+
if (process.platform === 'win32') {
|
|
524
|
+
const dirs = [path.join(process.env.WINDIR || 'C:\\Windows', 'Fonts')];
|
|
525
|
+
if (process.env.LOCALAPPDATA) dirs.push(path.join(process.env.LOCALAPPDATA, 'Microsoft', 'Windows', 'Fonts'));
|
|
526
|
+
return dirs;
|
|
527
|
+
}
|
|
528
|
+
return [
|
|
529
|
+
'/usr/share/fonts', '/usr/local/share/fonts',
|
|
530
|
+
path.join(home, '.local', 'share', 'fonts'), path.join(home, '.fonts'),
|
|
531
|
+
];
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* fc-match 가 없는 환경용 파일명 탐색. 깊이·개수 예산을 둬서 병적인 트리에서도 끝난다.
|
|
536
|
+
* ★ 첫 히트를 그냥 쓰면 안 된다 — 같은 패밀리의 Black·Bold 가 먼저 걸리면 검증기가
|
|
537
|
+
* 본문과 다른 굵기의 메트릭으로 폭을 재게 된다. Regular 를 우선하고 순위는 완전 결정론적이다.
|
|
538
|
+
*/
|
|
539
|
+
function fontWeightRank(flat) {
|
|
540
|
+
if (flat.includes('regular') || flat.includes('normal')) return 0;
|
|
541
|
+
if (/bold|black|light|thin|medium|semi|extra|heavy|demi|italic|oblique/.test(flat)) return 2;
|
|
542
|
+
return 1;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function globFontFile(name) {
|
|
546
|
+
const hints = FONT_FILE_HINTS[String(name).toLowerCase()] || [normName(name)];
|
|
547
|
+
let budget = 20000;
|
|
548
|
+
const found = [];
|
|
549
|
+
const walk = (dir, depth) => {
|
|
550
|
+
if (depth > 6 || budget <= 0) return;
|
|
551
|
+
let entries;
|
|
552
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
|
|
553
|
+
const subdirs = [];
|
|
554
|
+
for (const ent of entries) {
|
|
555
|
+
budget -= 1;
|
|
556
|
+
if (budget <= 0) return;
|
|
557
|
+
const full = path.join(dir, ent.name);
|
|
558
|
+
if (ent.isDirectory()) { subdirs.push(full); continue; }
|
|
559
|
+
if (!FONT_EXT.has(path.extname(ent.name).toLowerCase())) continue;
|
|
560
|
+
const flat = normName(path.basename(ent.name, path.extname(ent.name)));
|
|
561
|
+
if (hints.some((h) => flat.includes(h))) found.push({ full, rank: fontWeightRank(flat) });
|
|
562
|
+
}
|
|
563
|
+
for (const sub of subdirs) walk(sub, depth + 1);
|
|
564
|
+
};
|
|
565
|
+
for (const dir of fontSearchDirs()) {
|
|
566
|
+
if (!fs.existsSync(dir)) continue;
|
|
567
|
+
walk(dir, 0);
|
|
568
|
+
if (found.length) break; // 검색 경로는 우선순위 순서다 — 앞선 경로에서 찾으면 거기서 고른다
|
|
569
|
+
}
|
|
570
|
+
if (!found.length) return null;
|
|
571
|
+
found.sort((a, b) => a.rank - b.rank
|
|
572
|
+
|| path.basename(a.full).length - path.basename(b.full).length
|
|
573
|
+
|| (a.full < b.full ? -1 : a.full > b.full ? 1 : 0));
|
|
574
|
+
return found[0].full;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* 이름 하나를 실제 파일로 해석한다.
|
|
579
|
+
* fc-match 는 없는 폰트에도 대체 폰트를 돌려주므로(예: Malgun Gothic → DejaVu Sans)
|
|
580
|
+
* 돌려받은 family 가 요청한 이름과 실제로 맞는지 대조해야 한다. 안 그러면 이름이 거짓말이 된다.
|
|
581
|
+
*/
|
|
582
|
+
function findFontFile(name) {
|
|
583
|
+
const want = normName(name);
|
|
584
|
+
// 한 방향(family 가 요청 이름을 품는가)만 본다. 반대 방향까지 허용하면
|
|
585
|
+
// "Noto Sans CJK KR" 을 요청했는데 "Noto Sans" 가 통과해 한글 없는 폰트에 그 이름이 붙는다.
|
|
586
|
+
const hit = fcMatch(escFc(name));
|
|
587
|
+
if (hit && hit.families.some((f) => { const n = normName(f); return n === want || n.includes(want); })) {
|
|
588
|
+
if (fs.existsSync(hit.file)) return hit.file;
|
|
589
|
+
}
|
|
590
|
+
const globbed = globFontFile(name);
|
|
591
|
+
if (globbed && fs.existsSync(globbed)) return globbed;
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* 체인을 돌며 "존재하는 파일을 가진 이름" 을 고른다.
|
|
597
|
+
* 명시된 이름(--font·env·meta.font)이 해석되지 않으면 경고를 남기고 다음으로 내려간다 — 조용히 바꾸지 않는다.
|
|
598
|
+
*/
|
|
599
|
+
function resolveFont(opts) {
|
|
600
|
+
const warnings = [];
|
|
601
|
+
const explicit = [
|
|
602
|
+
{ name: sanitizeFontName(opts.fontArg), from: '--font' },
|
|
603
|
+
{ name: sanitizeFontName(process.env.BANKER_PPTX_FONT), from: 'BANKER_PPTX_FONT' },
|
|
604
|
+
{ name: opts.metaFont === 'auto' ? '' : sanitizeFontName(opts.metaFont), from: 'meta.font' },
|
|
605
|
+
].filter((c) => c.name !== '');
|
|
606
|
+
|
|
607
|
+
for (const c of explicit) {
|
|
608
|
+
const file = findFontFile(c.name);
|
|
609
|
+
if (file) return { name: c.name, file, from: c.from, warnings };
|
|
610
|
+
warnings.push({ code: 'FONT_NOT_FOUND', from: c.from, detail: c.name });
|
|
611
|
+
}
|
|
612
|
+
for (const name of FONT_CHAIN) {
|
|
613
|
+
const file = findFontFile(name);
|
|
614
|
+
if (file) return { name, file, from: 'chain', warnings };
|
|
615
|
+
}
|
|
616
|
+
const sys = fcMatch('sans-serif:lang=ko');
|
|
617
|
+
if (sys && sys.families.length && fs.existsSync(sys.file)) {
|
|
618
|
+
return { name: sys.families[0], file: sys.file, from: 'system', warnings };
|
|
619
|
+
}
|
|
620
|
+
const fallback = explicit.length ? explicit[0].name : FONT_CHAIN[0];
|
|
621
|
+
warnings.push({ code: 'FONT_FILE_UNRESOLVED', from: 'chain', detail: fallback });
|
|
622
|
+
return { name: fallback, file: null, from: 'unresolved', warnings };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ── pptxgenjs 해석 (설치는 하지 않는다) ─────────────────────────────────────
|
|
626
|
+
/** 스킬 디렉터리 상위에는 node_modules 가 없다. 사용자 환경에서 직접 찾는다. */
|
|
627
|
+
function moduleSearchPaths() {
|
|
628
|
+
const dirs = [process.cwd()];
|
|
629
|
+
const nodeDir = path.dirname(process.execPath);
|
|
630
|
+
if (process.platform === 'win32') {
|
|
631
|
+
dirs.push(path.join(nodeDir, 'node_modules'));
|
|
632
|
+
if (process.env.APPDATA) dirs.push(path.join(process.env.APPDATA, 'npm', 'node_modules'));
|
|
633
|
+
} else {
|
|
634
|
+
dirs.push(path.join(nodeDir, '..', 'lib', 'node_modules'));
|
|
635
|
+
dirs.push('/usr/local/lib/node_modules', '/usr/lib/node_modules');
|
|
636
|
+
}
|
|
637
|
+
if (process.env.npm_config_prefix) {
|
|
638
|
+
dirs.push(process.platform === 'win32'
|
|
639
|
+
? path.join(process.env.npm_config_prefix, 'node_modules')
|
|
640
|
+
: path.join(process.env.npm_config_prefix, 'lib', 'node_modules'));
|
|
641
|
+
}
|
|
642
|
+
// require.resolve 에 paths 를 주면 NODE_PATH 기본 경로가 대체되므로 직접 넣는다.
|
|
643
|
+
if (process.env.NODE_PATH) {
|
|
644
|
+
for (const p of process.env.NODE_PATH.split(path.delimiter)) if (p) dirs.push(p);
|
|
645
|
+
}
|
|
646
|
+
const seen = new Set();
|
|
647
|
+
return dirs.filter((d) => { const r = path.resolve(d); if (seen.has(r)) return false; seen.add(r); return fs.existsSync(r); });
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** 자동 설치는 하지 않는다. 사람이 읽고 스스로 고칠 수 있는 안내만 낸다. */
|
|
651
|
+
function pptxgenMissingMessage() {
|
|
652
|
+
return [
|
|
653
|
+
'pptxgenjs 를 찾지 못했다. 이 스킬은 의존성을 자동 설치하지 않는다.',
|
|
654
|
+
'',
|
|
655
|
+
' 설치: npm i pptxgenjs (프로젝트) 또는 npm i -g pptxgenjs (전역)',
|
|
656
|
+
' 안내: docs-setup 스킬이 이 스킬의 런타임 준비 절차를 담고 있다 (/banker:docs-setup).',
|
|
657
|
+
' 대안: pptxgenjs 를 설치할 수 없는 환경이면 build_a4p.py (python-pptx) 를 쓴다.',
|
|
658
|
+
'',
|
|
659
|
+
` 찾아본 곳: ${moduleSearchPaths().join(', ') || '(없음)'}`,
|
|
660
|
+
].join('\n');
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function loadPptxgen() {
|
|
664
|
+
try {
|
|
665
|
+
return require(require.resolve('pptxgenjs'));
|
|
666
|
+
} catch (e) { /* 기본 경로 실패 — 아래에서 사용자 환경을 뒤진다 */ }
|
|
667
|
+
try {
|
|
668
|
+
return require(require.resolve('pptxgenjs', { paths: moduleSearchPaths() }));
|
|
669
|
+
} catch (e) {
|
|
670
|
+
throw new EnvError(pptxgenMissingMessage());
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ── 빌드 ────────────────────────────────────────────────────────────────────
|
|
675
|
+
// OPC core property `title` 의 길이 상한. OOXML 규격이 아니라 python-pptx 가 스스로 거는 제한인데,
|
|
676
|
+
// 두 빌더가 같은 deck 에서 갈리면 원칙 5(교체 가능)가 깨지므로 node 도 똑같이 자른다.
|
|
677
|
+
// meta.title 은 문서 메타데이터일 뿐이고 페이지 제목은 별도 도형이라, 잘려도 보이는 내용은 잃지 않는다.
|
|
678
|
+
const CORE_TITLE_MAX = 255;
|
|
679
|
+
|
|
680
|
+
// ★ 코드 포인트로 센다. String.length 는 UTF-16 유닛이라 python 의 len() 과 다르다.
|
|
681
|
+
// 이모지 200개는 코드 포인트 200 이지만 UTF-16 유닛 400 이라, 유닛으로 재면 node 만 자른다.
|
|
682
|
+
// 게다가 유닛 경계에서 자르면 서로게이트 페어가 쪼개져 마지막 글자가 U+FFFD 로 깨진다.
|
|
683
|
+
function clampCoreTitle(title) {
|
|
684
|
+
if (typeof title !== 'string') return { value: title, truncated: false, length: 0 };
|
|
685
|
+
const cps = Array.from(title);
|
|
686
|
+
if (cps.length <= CORE_TITLE_MAX) return { value: title, truncated: false, length: cps.length };
|
|
687
|
+
return { value: cps.slice(0, CORE_TITLE_MAX).join(''), truncated: true, length: cps.length };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function build(laid, outPath, font, deck, mod, warnings) {
|
|
691
|
+
const Ctor = mod && mod.default ? mod.default : mod;
|
|
692
|
+
const pptx = new Ctor();
|
|
693
|
+
// ★ 레이아웃은 슬라이드 추가 전에 확정한다. 폭·높이는 전정밀도 mm/25.4 를 쓴다 —
|
|
694
|
+
// 반올림한 인치 리터럴은 sldSz 를 A4 에서 수천 EMU 어긋나게 만든다.
|
|
695
|
+
pptx.defineLayout({ name: LAYOUT_NAME, width: 210 / 25.4, height: 297 / 25.4 });
|
|
696
|
+
pptx.layout = LAYOUT_NAME;
|
|
697
|
+
const meta = deck.meta || {};
|
|
698
|
+
if (meta.title) {
|
|
699
|
+
const t = clampCoreTitle(meta.title);
|
|
700
|
+
pptx.title = t.value;
|
|
701
|
+
if (t.truncated && warnings) {
|
|
702
|
+
// t.length 는 코드 포인트 수다. meta.title.length(UTF-16 유닛)를 쓰면 detail 이 python 과 갈린다.
|
|
703
|
+
warnings.push({ code: 'TITLE_TRUNCATED', from: 'meta.title', detail: `${t.length} -> ${CORE_TITLE_MAX}` });
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
laid.slides.forEach((s) => {
|
|
708
|
+
const slide = pptx.addSlide();
|
|
709
|
+
s.shapes.forEach((sh) => {
|
|
710
|
+
slide.addText(sh.text, {
|
|
711
|
+
x: sh.x, y: sh.y, w: sh.cx, h: sh.cy, // >= 100 인 수는 pptxgenjs 가 EMU 로 그대로 쓴다
|
|
712
|
+
fontFace: font.name, // 한 속성이 a:latin·a:ea·a:cs 3슬롯을 모두 emit 한다
|
|
713
|
+
fontSize: sh.pt,
|
|
714
|
+
bold: sh.bold,
|
|
715
|
+
color: sh.color,
|
|
716
|
+
align: 'left',
|
|
717
|
+
valign: 'top',
|
|
718
|
+
lineSpacingMultiple: LINE_SPACING,
|
|
719
|
+
// ★ a:bodyPr 를 명시적으로 고정한다(F13). 기본값에 맡기면 autofit 요소가 붙어
|
|
720
|
+
// 뷰어가 열 때 도형 크기를 바꾸고, 파일 안 좌표는 맞는데 화면에서 INV-G 가 깨진다.
|
|
721
|
+
wrap: true,
|
|
722
|
+
fit: 'none',
|
|
723
|
+
margin: 0,
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
return pptx.writeFile({ fileName: outPath }).then(() => outPath);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ── 출력 ────────────────────────────────────────────────────────────────────
|
|
732
|
+
function humanReport(laid, font, outPath, warnings) {
|
|
733
|
+
const g = laid.grid;
|
|
734
|
+
const shapes = laid.slides.reduce((a, s) => a + s.shapes.length, 0);
|
|
735
|
+
const lines = [
|
|
736
|
+
'vertical-pptx (node 빌더)',
|
|
737
|
+
` 용지 A4 세로 210x297mm · sldSz ${SLIDE_W} x ${SLIDE_H} EMU`,
|
|
738
|
+
` 여백 ${g.marginMm}mm · 그리드 ${COLS}열 x ${ROWS}행 · 거터 4mm`,
|
|
739
|
+
` 칸 컬럼 ${g.colW} · 행 ${g.rowH} · 피치 ${g.colPitch} / ${g.rowPitch} EMU`,
|
|
740
|
+
];
|
|
741
|
+
if (font) {
|
|
742
|
+
lines.push(` 폰트 ${font.name}${font.file ? '' : ' (파일 미해석 — 검증기 폭 판정은 estimate 로 내려간다)'}`);
|
|
743
|
+
lines.push(` ${font.file || '(경로 없음)'}`);
|
|
744
|
+
}
|
|
745
|
+
lines.push(
|
|
746
|
+
` 본문 ${laid.bodyPt}pt · 줄간 ${LINE_SPACING}`,
|
|
747
|
+
` 슬라이드 ${laid.slides.length} (deck 페이지 ${new Set(laid.slides.map((s) => s.pageIndex)).size}개)`,
|
|
748
|
+
` 도형 ${shapes}`,
|
|
749
|
+
` 경고 ${warnings.length}`
|
|
750
|
+
);
|
|
751
|
+
warnings.forEach((w) => {
|
|
752
|
+
let where = '';
|
|
753
|
+
if (w.page !== undefined) where = `page ${w.page} block ${w.block}`;
|
|
754
|
+
else if (w.line !== undefined) where = `line ${w.line}`;
|
|
755
|
+
else if (w.from !== undefined) where = w.from;
|
|
756
|
+
lines.push(` ${w.code} ${where} ${w.detail || ''}`.replace(/\s+$/, ''));
|
|
757
|
+
});
|
|
758
|
+
if (outPath) lines.push(` 출력 ${outPath}`);
|
|
759
|
+
return lines.join('\n');
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const statusJson = (font, slides, warnings) => JSON.stringify({
|
|
763
|
+
skill: 'vertical-pptx',
|
|
764
|
+
builder: 'node',
|
|
765
|
+
font: font ? font.name : null,
|
|
766
|
+
fontFile: font ? font.file : null,
|
|
767
|
+
slides,
|
|
768
|
+
warnings,
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────
|
|
772
|
+
const USAGE = [
|
|
773
|
+
'vertical-pptx / build_a4p.js — A4 세로(210x297mm) PPTX node 빌더',
|
|
774
|
+
'',
|
|
775
|
+
'사용법:',
|
|
776
|
+
' node build_a4p.js --in deck.json --out out.pptx [--font <이름>]',
|
|
777
|
+
' node build_a4p.js --md doc.md --emit-json > deck.json',
|
|
778
|
+
' node build_a4p.js --md doc.md --emit-json --out deck.json',
|
|
779
|
+
' node build_a4p.js --md doc.md --out out.pptx [--font <이름>]',
|
|
780
|
+
'',
|
|
781
|
+
'옵션:',
|
|
782
|
+
' --in <deck.json> deck JSON 입력 (두 빌더의 유일한 계약)',
|
|
783
|
+
' --md <doc.md> 마크다운 입력 (h1=페이지 · h2=head · 불릿=bullets · 문단=body)',
|
|
784
|
+
' --emit-json 빌드하지 않고 deck JSON 만 낸다. --out 이 없으면 stdout 이 deck JSON 전용이 되고',
|
|
785
|
+
' 사람이 읽는 표와 상태 JSON 은 stderr 로 간다(리다이렉트 안전).',
|
|
786
|
+
' --out <경로> .pptx 출력 경로 (--emit-json 과 함께 쓰면 deck JSON 출력 경로)',
|
|
787
|
+
' --font <이름> 폰트 이름을 직접 지정. 체인: --font → BANKER_PPTX_FONT → meta.font',
|
|
788
|
+
' → Noto Sans CJK KR → Malgun Gothic → Apple SD Gothic Neo → NanumGothic → 시스템 한국어 sans',
|
|
789
|
+
' -h, --help 이 도움말',
|
|
790
|
+
'',
|
|
791
|
+
'종료 코드: 0 통과 · 1 검증 실패 · 2 환경 미비(pptxgenjs 없음) · 3 사용법 오류',
|
|
792
|
+
].join('\n');
|
|
793
|
+
|
|
794
|
+
function parseArgs(argv) {
|
|
795
|
+
const opts = { in: '', md: '', out: '', fontArg: '', emitJson: false, help: false };
|
|
796
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
797
|
+
const a = argv[i];
|
|
798
|
+
const need = (name) => {
|
|
799
|
+
const v = argv[i + 1];
|
|
800
|
+
if (v === undefined || v.startsWith('--')) throw new UsageError(`${name} 에 값이 필요하다.`);
|
|
801
|
+
i += 1;
|
|
802
|
+
return v;
|
|
803
|
+
};
|
|
804
|
+
if (a === '--in') opts.in = need('--in');
|
|
805
|
+
else if (a === '--md') opts.md = need('--md');
|
|
806
|
+
else if (a === '--out') opts.out = need('--out');
|
|
807
|
+
else if (a === '--font') opts.fontArg = need('--font');
|
|
808
|
+
else if (a === '--emit-json') opts.emitJson = true;
|
|
809
|
+
else if (a === '-h' || a === '--help') opts.help = true;
|
|
810
|
+
else throw new UsageError(`알 수 없는 인자: ${a}\n\n${USAGE}`);
|
|
811
|
+
}
|
|
812
|
+
return opts;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function readDeckFile(file) {
|
|
816
|
+
let text;
|
|
817
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch (e) { throw new UsageError(`deck JSON 을 읽지 못했다: ${e.message}`); }
|
|
818
|
+
try { return JSON.parse(text); } catch (e) { throw new UsageError(`deck JSON 파싱 실패 (${file}): ${e.message}`); }
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** 언제나 Promise 를 돌려준다 — 동기 throw 와 비동기 reject 를 호출자가 구분하지 않아도 되게. */
|
|
822
|
+
function main(argv) {
|
|
823
|
+
try { return runMain(argv); } catch (err) { return Promise.reject(err); }
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function runMain(argv) {
|
|
827
|
+
const opts = parseArgs(argv);
|
|
828
|
+
if (opts.help || (!opts.in && !opts.md)) {
|
|
829
|
+
process.stdout.write(`${USAGE}\n`);
|
|
830
|
+
return Promise.resolve(opts.help ? EXIT.OK : EXIT.USAGE);
|
|
831
|
+
}
|
|
832
|
+
if (opts.in && opts.md) throw new UsageError('--in 과 --md 는 함께 쓸 수 없다.');
|
|
833
|
+
|
|
834
|
+
let deck;
|
|
835
|
+
let warnings = [];
|
|
836
|
+
if (opts.md) {
|
|
837
|
+
let text;
|
|
838
|
+
try { text = fs.readFileSync(opts.md, 'utf8'); } catch (e) { throw new UsageError(`마크다운을 읽지 못했다: ${e.message}`); }
|
|
839
|
+
const converted = mdToDeck(text);
|
|
840
|
+
deck = converted.deck;
|
|
841
|
+
warnings = converted.warnings;
|
|
842
|
+
} else {
|
|
843
|
+
deck = readDeckFile(opts.in);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const laid = layout(deck); // validateDeck 포함 — 스키마 위반은 여기서 exit 3
|
|
847
|
+
warnings = warnings.concat(laid.warnings);
|
|
848
|
+
|
|
849
|
+
if (opts.emitJson) {
|
|
850
|
+
const json = `${JSON.stringify(deck, null, 2)}\n`;
|
|
851
|
+
// 리다이렉트(`> deck.json`) 안전: --out 이 없으면 stdout 은 deck JSON 전용이고
|
|
852
|
+
// 사람이 읽는 표와 상태 JSON 은 stderr 로 간다.
|
|
853
|
+
const side = opts.out ? process.stdout : process.stderr;
|
|
854
|
+
if (opts.out) fs.writeFileSync(opts.out, json); else process.stdout.write(json);
|
|
855
|
+
side.write(`${humanReport(laid, null, opts.out || '', warnings)}\n`);
|
|
856
|
+
side.write(`${statusJson(null, laid.slides.length, warnings)}\n`);
|
|
857
|
+
return Promise.resolve(EXIT.OK);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (!opts.out) throw new UsageError('--out <경로> 가 필요하다.');
|
|
861
|
+
assertInsideSlide(laid);
|
|
862
|
+
|
|
863
|
+
// ★ pptxgenjs 해석을 폰트 해석보다 먼저 한다.
|
|
864
|
+
// 환경이 미비하면 자식 프로세스(fc-match)를 하나도 띄우지 않고 exit 2 로 나간다.
|
|
865
|
+
const mod = loadPptxgen();
|
|
866
|
+
const font = resolveFont({ fontArg: opts.fontArg, metaFont: deck.meta && deck.meta.font });
|
|
867
|
+
warnings = warnings.concat(font.warnings);
|
|
868
|
+
|
|
869
|
+
return build(laid, path.resolve(opts.out), font, deck, mod, warnings).then((outPath) => {
|
|
870
|
+
process.stdout.write(`${humanReport(laid, font, outPath, warnings)}\n`);
|
|
871
|
+
process.stdout.write(`${statusJson(font, laid.slides.length, warnings)}\n`);
|
|
872
|
+
return EXIT.OK;
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (require.main === module) {
|
|
877
|
+
// 환경 미비(exit 2)는 사용자가 그대로 따라 할 수 있는 안내라서 stdout 으로 낸다.
|
|
878
|
+
// 나머지 오류는 stderr.
|
|
879
|
+
const fail = (err) => {
|
|
880
|
+
const code = err && err.exitCode ? err.exitCode : EXIT.USAGE;
|
|
881
|
+
const text = `${err && err.message ? err.message : String(err)}\n`;
|
|
882
|
+
if (code === EXIT.ENV) process.stdout.write(text); else process.stderr.write(text);
|
|
883
|
+
process.exitCode = code;
|
|
884
|
+
};
|
|
885
|
+
main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, fail);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
module.exports = {
|
|
889
|
+
layout,
|
|
890
|
+
mdToDeck,
|
|
891
|
+
validateDeck,
|
|
892
|
+
clampCoreTitle,
|
|
893
|
+
CORE_TITLE_MAX,
|
|
894
|
+
gridFor,
|
|
895
|
+
rowsOf,
|
|
896
|
+
requestedRows,
|
|
897
|
+
defaultRows,
|
|
898
|
+
bulletSpans,
|
|
899
|
+
resolveFont,
|
|
900
|
+
findFontFile,
|
|
901
|
+
moduleSearchPaths,
|
|
902
|
+
inlineText,
|
|
903
|
+
main,
|
|
904
|
+
UsageError,
|
|
905
|
+
EnvError,
|
|
906
|
+
VerifyError,
|
|
907
|
+
EXIT,
|
|
908
|
+
SLIDE_W,
|
|
909
|
+
SLIDE_H,
|
|
910
|
+
GUTTER,
|
|
911
|
+
COLS,
|
|
912
|
+
ROWS,
|
|
913
|
+
TITLE_ROWS,
|
|
914
|
+
MAX_BLOCK_ROWS,
|
|
915
|
+
MARGIN_MM_MIN,
|
|
916
|
+
MARGIN_MM_MAX,
|
|
917
|
+
DEFAULT_MARGIN_MM,
|
|
918
|
+
DEFAULT_BODY_PT,
|
|
919
|
+
LINE_SPACING,
|
|
920
|
+
BULLET_PREFIX,
|
|
921
|
+
FONT_CHAIN,
|
|
922
|
+
FONT_FILE_HINTS,
|
|
923
|
+
};
|