@kaydash9999/banker-plugins 0.11.0 → 0.12.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 +30 -0
- package/README.md +26 -6
- package/codex/manifest.json +7 -0
- 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/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,916 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
vertical-pptx — A4 세로(210 x 297mm) PPTX python 빌더.
|
|
5
|
+
|
|
6
|
+
build_a4p.js 와 의미가 같은 산출물을 낸다. 같은 deck JSON 을 먹이면 sldSz·슬라이드 수·
|
|
7
|
+
도형 x/y/cx/cy·텍스트·3슬롯 폰트·a:bodyPr·warnings 가 전부 일치해야 한다(교차 동등성 테스트가 본다).
|
|
8
|
+
|
|
9
|
+
★ 입력은 deck JSON 뿐이다. 마크다운 플래그는 여기 없다.
|
|
10
|
+
마크다운 → deck JSON 변환은 node 빌더가 단독으로 맡는다. 파서가 두 벌이 되는 순간
|
|
11
|
+
같은 원고가 두 개의 다른 덱이 되고, 그것이 이 설계가 없애려던 바로 그 드리프트다.
|
|
12
|
+
|
|
13
|
+
★ INV-G (기하 불변식) — node 빌더와 공유하는 절대 규칙.
|
|
14
|
+
모든 도형의 x·y·cx·cy 는 다음 값들만의 순수 함수다:
|
|
15
|
+
meta.marginMm · 페이지 인덱스 · 블록 순서 · 블록 type · bullets.items 배열 길이 ·
|
|
16
|
+
명시된 rows · 컬럼 스팬.
|
|
17
|
+
텍스트의 내용·길이·폰트·pt 는 어떤 좌표에도 들어가지 않는다.
|
|
18
|
+
|
|
19
|
+
★ 측정은 여기서 하지 않는다.
|
|
20
|
+
폰트 바이너리 파싱·폭 합산·오버플로 판정은 verify_a4p.mjs 단독 소유다.
|
|
21
|
+
이 파일은 폰트 "이름" 을 고르고 그 파일이 존재하는지만 확인해서 경로를 보고한다.
|
|
22
|
+
|
|
23
|
+
python-pptx 특유의 함정 셋. 전부 이 저장소에서 실측한 것이고, 안 고치면 조용히 틀린 파일이 나온다.
|
|
24
|
+
F3 slide_width/height 만 바꾸면 <p:sldSz ... type="screen4x3"/> 가 남는다.
|
|
25
|
+
치수는 A4 인데 선언은 4:3 인 파일이고, 그 선언을 믿는 뷰어·변환기가 있다 → type 속성을 지운다.
|
|
26
|
+
F4 run.font.name 은 a:latin 하나만 낸다. a:ea / a:cs 가 없으면 한글이 대체 폰트로 떨어진다
|
|
27
|
+
→ 세 슬롯에 같은 typeface 를 직접 주입한다(패턴 출처: skills/arch-diagram/build_pptx_template.py).
|
|
28
|
+
F13 add_textbox 의 기본 a:bodyPr 는 wrap="none" + <a:spAutoFit/> 다.
|
|
29
|
+
텍스트가 안 감기고, 뷰어가 열 때 도형 크기를 바꾼다 — 파일 안 좌표는 맞는데 화면에서 INV-G 가 깨진다
|
|
30
|
+
→ wrap="square" · 인셋 4개 0 · rtlCol="0" · anchor="t" · autofit 요소 제거로 고정한다.
|
|
31
|
+
|
|
32
|
+
기하 (단위 EMU, 1mm = 36000, m = meta.marginMm):
|
|
33
|
+
슬라이드 7560000 x 10692000 (고정)
|
|
34
|
+
여백 36000m · 본문 (7560000 - 72000m) x (10692000 - 72000m) · 거터 144000 (4mm 고정)
|
|
35
|
+
8열: 컬럼 폭 (182 - 2m) * 4500 · 20행: 행 높이 (221 - 2m) * 1800
|
|
36
|
+
컬럼 c 에서 k 열: x = 36000m + (컬럼폭 + 144000) * c · cx = 컬럼폭 * k + 144000 * (k - 1)
|
|
37
|
+
행 r 에서 n 행: y = 36000m + (행높이 + 144000) * r · cy = 행높이 * n + 144000 * (n - 1)
|
|
38
|
+
m=10 검산: 컬럼 폭 729000 · 행 높이 361800 · 피치 873000 / 505800
|
|
39
|
+
|
|
40
|
+
행 수 함수 (측정 없음):
|
|
41
|
+
rows(block) = min(18, block.rows ?? default(type))
|
|
42
|
+
default: head=1 · body=2 · bullets=max(1, items 개수) · 페이지 제목 = 2행 고정
|
|
43
|
+
★ 클램프 발동 조건은 block.rows 가 아니라 block.rows ?? default(type) 다.
|
|
44
|
+
항목 19개짜리 불릿에 rows 를 안 적어도 클램프가 걸리고, 그때 경고가 없으면
|
|
45
|
+
항목 17·18 이 조용히 겹친 채 출하된다.
|
|
46
|
+
|
|
47
|
+
페이지 채우기(세는 규칙만): avail = 20 - 2 = 18. used + rows(block) > 18 이면 새 슬라이드.
|
|
48
|
+
블록은 쪼개지 않는다(쪼개려면 측정이 필요하다).
|
|
49
|
+
|
|
50
|
+
요구 사항: python 3.9 이상 + python-pptx.
|
|
51
|
+
이 파일은 셰방에 의존하지 않는다 — 호출자가 인터프리터를 고른다.
|
|
52
|
+
`python3` 가 3.9 미만인 머신이 있으므로(이 개발 머신의 python3 는 3.6.8 이다)
|
|
53
|
+
실제로 쓸 인터프리터를 명시해 부르는 것이 안전하다: `/path/to/python build_a4p.py ...`.
|
|
54
|
+
3.9 미만이면 exit 2 로 그 사실만 알린다. 의존성을 자동 설치하지 않는다.
|
|
55
|
+
|
|
56
|
+
종료 코드: 0 통과 · 1 검증 실패 · 2 환경 미비 · 3 사용법 오류.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
import json
|
|
60
|
+
import math
|
|
61
|
+
import os
|
|
62
|
+
import subprocess
|
|
63
|
+
import sys
|
|
64
|
+
|
|
65
|
+
# ── 상수 (build_a4p.js 와 값이 같아야 한다) ─────────────────────────────────
|
|
66
|
+
MIN_PYTHON = (3, 9)
|
|
67
|
+
EMU_PER_MM = 36000
|
|
68
|
+
SLIDE_W = 210 * EMU_PER_MM # 7560000
|
|
69
|
+
SLIDE_H = 297 * EMU_PER_MM # 10692000
|
|
70
|
+
GUTTER = 4 * EMU_PER_MM # 144000
|
|
71
|
+
COLS = 8
|
|
72
|
+
ROWS = 20
|
|
73
|
+
TITLE_ROWS = 2
|
|
74
|
+
MAX_BLOCK_ROWS = ROWS - TITLE_ROWS # 18
|
|
75
|
+
MARGIN_MM_MIN = 10 # 동결 스펙의 "여백 10mm 이상"
|
|
76
|
+
MARGIN_MM_MAX = 40
|
|
77
|
+
DEFAULT_MARGIN_MM = 10
|
|
78
|
+
DEFAULT_BODY_PT = 10.5 # 동결 범위 10~11pt
|
|
79
|
+
LINE_SPACING = 1.2
|
|
80
|
+
BULLET_PREFIX = '\u2022 '
|
|
81
|
+
COLOR_HEAD = '1A1A1A'
|
|
82
|
+
COLOR_BODY = '222222'
|
|
83
|
+
BLOCK_TYPES = ('head', 'body', 'bullets')
|
|
84
|
+
ORIENTATIONS = ('portrait', 'landscape')
|
|
85
|
+
|
|
86
|
+
EXIT_OK = 0
|
|
87
|
+
EXIT_VERIFY = 1
|
|
88
|
+
EXIT_ENV = 2
|
|
89
|
+
EXIT_USAGE = 3
|
|
90
|
+
|
|
91
|
+
# 폰트 체인. --font → BANKER_PPTX_FONT → meta.font → 아래 목록 → 시스템 한국어 sans.
|
|
92
|
+
FONT_CHAIN = ('Noto Sans CJK KR', 'Malgun Gothic', 'Apple SD Gothic Neo', 'NanumGothic')
|
|
93
|
+
# fc-match 가 없는 환경에서 파일명으로 찾기 위한 힌트(정규화된 소문자 조각).
|
|
94
|
+
FONT_FILE_HINTS = {
|
|
95
|
+
'noto sans cjk kr': ('notosanscjkkr', 'notosanscjk', 'notosanskr'),
|
|
96
|
+
'malgun gothic': ('malgun',),
|
|
97
|
+
'apple sd gothic neo': ('applesdgothicneo', 'applegothic'),
|
|
98
|
+
'nanumgothic': ('nanumgothic',),
|
|
99
|
+
}
|
|
100
|
+
FONT_EXT = frozenset(('.ttf', '.ttc', '.otf', '.otc'))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── 오류 타입 ───────────────────────────────────────────────────────────────
|
|
104
|
+
class UsageError(Exception):
|
|
105
|
+
exit_code = EXIT_USAGE
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class EnvError(Exception):
|
|
109
|
+
exit_code = EXIT_ENV
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class VerifyError(Exception):
|
|
113
|
+
exit_code = EXIT_VERIFY
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── 그리드 ──────────────────────────────────────────────────────────────────
|
|
117
|
+
def grid_for(margin_mm):
|
|
118
|
+
m = margin_mm
|
|
119
|
+
col_w = (182 - 2 * m) * 4500
|
|
120
|
+
row_h = (221 - 2 * m) * 1800
|
|
121
|
+
return {
|
|
122
|
+
'marginMm': m,
|
|
123
|
+
'margin': EMU_PER_MM * m,
|
|
124
|
+
'slideW': SLIDE_W,
|
|
125
|
+
'slideH': SLIDE_H,
|
|
126
|
+
'contentW': SLIDE_W - 2 * EMU_PER_MM * m,
|
|
127
|
+
'contentH': SLIDE_H - 2 * EMU_PER_MM * m,
|
|
128
|
+
'gutter': GUTTER,
|
|
129
|
+
'cols': COLS,
|
|
130
|
+
'rows': ROWS,
|
|
131
|
+
'colW': col_w,
|
|
132
|
+
'rowH': row_h,
|
|
133
|
+
'colPitch': col_w + GUTTER,
|
|
134
|
+
'rowPitch': row_h + GUTTER,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def col_x(g, c):
|
|
139
|
+
return g['margin'] + g['colPitch'] * c
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def col_cx(g, k):
|
|
143
|
+
return g['colW'] * k + GUTTER * (k - 1)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def row_y(g, r):
|
|
147
|
+
return g['margin'] + g['rowPitch'] * r
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def row_cy(g, n):
|
|
151
|
+
return g['rowH'] * n + GUTTER * (n - 1)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def default_rows(block):
|
|
155
|
+
if block.get('type') == 'head':
|
|
156
|
+
return 1
|
|
157
|
+
if block.get('type') == 'body':
|
|
158
|
+
return 2
|
|
159
|
+
return max(1, len(block.get('items') or [])) # bullets
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def requested_rows(block):
|
|
163
|
+
"""클램프 이전의 요청 행 수. 클램프 발동 판정은 반드시 이 값으로 한다."""
|
|
164
|
+
rows = block.get('rows')
|
|
165
|
+
return default_rows(block) if rows is None else rows
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def rows_of(block):
|
|
169
|
+
return min(MAX_BLOCK_ROWS, requested_rows(block))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def bullet_spans(n, count):
|
|
173
|
+
"""
|
|
174
|
+
불릿 항목별 행 배분.
|
|
175
|
+
항목 i 는 min(i, n-1) 행에서 시작하고, 남는 행은 전부 마지막 항목이 가져간다.
|
|
176
|
+
n < 항목 수면 n-1 행부터 겹쳐 쌓인다 — 그 겹침을 보고하는 것은 검증기 몫이다.
|
|
177
|
+
"""
|
|
178
|
+
spans = []
|
|
179
|
+
for i in range(count):
|
|
180
|
+
start = min(i, n - 1)
|
|
181
|
+
end = n - 1 if i == count - 1 else min(i + 1, n - 1) - 1
|
|
182
|
+
spans.append({'start': start, 'rows': max(1, end - start + 1)})
|
|
183
|
+
return spans
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── deck 검증 (전부 exit 3) ─────────────────────────────────────────────────
|
|
187
|
+
def is_plain_object(v):
|
|
188
|
+
return isinstance(v, dict)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def as_integer(value):
|
|
192
|
+
"""
|
|
193
|
+
JS 의 Number.isInteger 와 같은 판정.
|
|
194
|
+
JSON 의 10.0 은 JS 에서 정수 10 이지만 python 에서는 float 이다 —
|
|
195
|
+
그대로 isinstance(int) 로 보면 node 빌더가 받는 덱을 python 빌더만 거부한다.
|
|
196
|
+
"""
|
|
197
|
+
if isinstance(value, bool):
|
|
198
|
+
return None
|
|
199
|
+
if isinstance(value, int):
|
|
200
|
+
return value
|
|
201
|
+
if isinstance(value, float) and math.isfinite(value) and value.is_integer():
|
|
202
|
+
return int(value)
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def as_number(value):
|
|
207
|
+
if isinstance(value, bool):
|
|
208
|
+
return None
|
|
209
|
+
if isinstance(value, (int, float)) and math.isfinite(value):
|
|
210
|
+
return float(value)
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def show(value):
|
|
215
|
+
"""오류 메시지에 값을 그대로 되비추기 위한 JSON 표현(JS 의 JSON.stringify 와 같은 자리)."""
|
|
216
|
+
try:
|
|
217
|
+
return json.dumps(value, ensure_ascii=False)
|
|
218
|
+
except (TypeError, ValueError):
|
|
219
|
+
return repr(value)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def orientation_of(deck, page):
|
|
223
|
+
meta = deck.get('meta') or {}
|
|
224
|
+
return page.get('orientation') or meta.get('orientation') or 'portrait'
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def validate_deck(deck):
|
|
228
|
+
if not is_plain_object(deck):
|
|
229
|
+
raise UsageError('deck JSON 최상위는 객체여야 한다.')
|
|
230
|
+
meta = {} if deck.get('meta') is None else deck.get('meta')
|
|
231
|
+
if not is_plain_object(meta):
|
|
232
|
+
raise UsageError('meta 는 객체여야 한다.')
|
|
233
|
+
|
|
234
|
+
if meta.get('marginMm') is not None:
|
|
235
|
+
m = as_integer(meta.get('marginMm'))
|
|
236
|
+
if m is None or m < MARGIN_MM_MIN or m > MARGIN_MM_MAX:
|
|
237
|
+
raise UsageError(
|
|
238
|
+
'meta.marginMm 은 {0} 이상 {1} 이하의 정수여야 한다 (받은 값: {2}).'.format(
|
|
239
|
+
MARGIN_MM_MIN, MARGIN_MM_MAX, show(meta.get('marginMm'))))
|
|
240
|
+
if meta.get('bodyPt') is not None:
|
|
241
|
+
pt = as_number(meta.get('bodyPt'))
|
|
242
|
+
if pt is None or pt <= 0 or pt > 400:
|
|
243
|
+
raise UsageError('meta.bodyPt 는 0 초과 400 이하의 수여야 한다 (받은 값: {0}).'.format(
|
|
244
|
+
show(meta.get('bodyPt'))))
|
|
245
|
+
if meta.get('title') is not None and not isinstance(meta.get('title'), str):
|
|
246
|
+
raise UsageError('meta.title 은 문자열이어야 한다.')
|
|
247
|
+
if meta.get('font') is not None and not isinstance(meta.get('font'), str):
|
|
248
|
+
raise UsageError('meta.font 는 문자열이어야 한다.')
|
|
249
|
+
if meta.get('orientation') is not None and meta.get('orientation') not in ORIENTATIONS:
|
|
250
|
+
raise UsageError('meta.orientation 은 {0} 여야 한다.'.format(' 또는 '.join(ORIENTATIONS)))
|
|
251
|
+
|
|
252
|
+
pages = deck.get('pages')
|
|
253
|
+
if not isinstance(pages, list) or not pages:
|
|
254
|
+
raise UsageError('pages 는 비어 있지 않은 배열이어야 한다.')
|
|
255
|
+
|
|
256
|
+
for pi, page in enumerate(pages):
|
|
257
|
+
at = 'pages[{0}]'.format(pi)
|
|
258
|
+
if not is_plain_object(page):
|
|
259
|
+
raise UsageError('{0} 는 객체여야 한다.'.format(at))
|
|
260
|
+
if page.get('title') is not None and not isinstance(page.get('title'), str):
|
|
261
|
+
raise UsageError('{0}.title 은 문자열이어야 한다.'.format(at))
|
|
262
|
+
if page.get('orientation') is not None and page.get('orientation') not in ORIENTATIONS:
|
|
263
|
+
raise UsageError('{0}.orientation 은 {1} 여야 한다.'.format(at, ' 또는 '.join(ORIENTATIONS)))
|
|
264
|
+
blocks = [] if page.get('blocks') is None else page.get('blocks')
|
|
265
|
+
if not isinstance(blocks, list):
|
|
266
|
+
raise UsageError('{0}.blocks 는 배열이어야 한다.'.format(at))
|
|
267
|
+
for bi, block in enumerate(blocks):
|
|
268
|
+
bat = '{0}.blocks[{1}]'.format(at, bi)
|
|
269
|
+
if not is_plain_object(block):
|
|
270
|
+
raise UsageError('{0} 는 객체여야 한다.'.format(bat))
|
|
271
|
+
if block.get('type') not in BLOCK_TYPES:
|
|
272
|
+
raise UsageError('{0}.type 은 {1} 중 하나여야 한다 (받은 값: {2}).'.format(
|
|
273
|
+
bat, ' / '.join(BLOCK_TYPES), show(block.get('type'))))
|
|
274
|
+
if block.get('type') == 'bullets':
|
|
275
|
+
items = block.get('items')
|
|
276
|
+
if not isinstance(items, list) or not items:
|
|
277
|
+
raise UsageError('{0}.items 는 비어 있지 않은 배열이어야 한다.'.format(bat))
|
|
278
|
+
for ii, item in enumerate(items):
|
|
279
|
+
if not isinstance(item, str):
|
|
280
|
+
raise UsageError('{0}.items[{1}] 는 문자열이어야 한다.'.format(bat, ii))
|
|
281
|
+
elif not isinstance(block.get('text'), str):
|
|
282
|
+
raise UsageError('{0}.text 는 문자열이어야 한다.'.format(bat))
|
|
283
|
+
if block.get('rows') is not None:
|
|
284
|
+
rows = as_integer(block.get('rows'))
|
|
285
|
+
if rows is None or rows < 1:
|
|
286
|
+
raise UsageError('{0}.rows 는 1 이상의 정수여야 한다 (받은 값: {1}).'.format(
|
|
287
|
+
bat, show(block.get('rows'))))
|
|
288
|
+
|
|
289
|
+
# 방향 혼합은 파일 형식이 허용하지 않는다 — 하나의 pptx 는 하나의 sldSz 만 갖는다.
|
|
290
|
+
seen = set(orientation_of(deck, p) for p in pages)
|
|
291
|
+
if len(seen) > 1:
|
|
292
|
+
raise UsageError(
|
|
293
|
+
'한 pptx 는 세로와 가로를 함께 담을 수 없다(슬라이드 크기가 파일당 하나다). '
|
|
294
|
+
'방향별로 파일을 나누어 각각 빌드해라 — split the deck into separate files, '
|
|
295
|
+
'one per orientation.')
|
|
296
|
+
if 'landscape' in seen:
|
|
297
|
+
raise UsageError('이 스킬은 A4 세로(210x297mm) 전용이다. 가로 덱은 대상이 아니다.')
|
|
298
|
+
return True
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ── layout: deck JSON → 좌표 (INV-G 의 구현체) ──────────────────────────────
|
|
302
|
+
def layout(deck):
|
|
303
|
+
"""
|
|
304
|
+
반환: {'grid', 'bodyPt', 'slides', 'warnings'}
|
|
305
|
+
slides[i]['shapes'][j] 는 role·pageIndex·blockIndex·itemIndex·rows·x·y·cx·cy·text·pt·bold·color.
|
|
306
|
+
pageIndex·blockIndex 가 있어야 검증기가 (슬라이드, 도형) 을 (페이지, 블록) 으로 되돌릴 수 있다.
|
|
307
|
+
"""
|
|
308
|
+
validate_deck(deck)
|
|
309
|
+
meta = deck.get('meta') or {}
|
|
310
|
+
margin_mm = DEFAULT_MARGIN_MM if meta.get('marginMm') is None else as_integer(meta.get('marginMm'))
|
|
311
|
+
grid = grid_for(margin_mm)
|
|
312
|
+
body_pt = DEFAULT_BODY_PT if meta.get('bodyPt') is None else as_number(meta.get('bodyPt'))
|
|
313
|
+
pt = {'title': body_pt + 6, 'head': body_pt + 2, 'body': body_pt, 'bullets': body_pt}
|
|
314
|
+
full_cx = col_cx(grid, COLS)
|
|
315
|
+
slides = []
|
|
316
|
+
warnings = []
|
|
317
|
+
|
|
318
|
+
for page_index, page in enumerate(deck['pages']):
|
|
319
|
+
title = '' if page.get('title') is None else page.get('title')
|
|
320
|
+
state = {'slide': None, 'used': 0, 'part': 0}
|
|
321
|
+
|
|
322
|
+
def open_slide():
|
|
323
|
+
slide = {'index': len(slides), 'pageIndex': page_index, 'part': state['part'],
|
|
324
|
+
'title': title, 'shapes': []}
|
|
325
|
+
state['part'] += 1
|
|
326
|
+
state['used'] = 0
|
|
327
|
+
slide['shapes'].append({
|
|
328
|
+
'role': 'title',
|
|
329
|
+
'pageIndex': page_index,
|
|
330
|
+
'blockIndex': None,
|
|
331
|
+
'itemIndex': None,
|
|
332
|
+
'rows': TITLE_ROWS,
|
|
333
|
+
'x': col_x(grid, 0),
|
|
334
|
+
'y': row_y(grid, 0),
|
|
335
|
+
'cx': full_cx,
|
|
336
|
+
'cy': row_cy(grid, TITLE_ROWS),
|
|
337
|
+
'text': title,
|
|
338
|
+
'pt': pt['title'],
|
|
339
|
+
'bold': True,
|
|
340
|
+
'color': COLOR_HEAD,
|
|
341
|
+
})
|
|
342
|
+
slides.append(slide)
|
|
343
|
+
state['slide'] = slide
|
|
344
|
+
|
|
345
|
+
open_slide()
|
|
346
|
+
|
|
347
|
+
blocks = [] if page.get('blocks') is None else page.get('blocks')
|
|
348
|
+
for block_index, block in enumerate(blocks):
|
|
349
|
+
want = requested_rows(block)
|
|
350
|
+
n = rows_of(block)
|
|
351
|
+
if want > MAX_BLOCK_ROWS:
|
|
352
|
+
warnings.append({
|
|
353
|
+
'code': 'BLOCK_TOO_TALL',
|
|
354
|
+
'page': page_index,
|
|
355
|
+
'block': block_index,
|
|
356
|
+
'detail': 'rows {0} -> {1}'.format(want, n),
|
|
357
|
+
})
|
|
358
|
+
# 세는 규칙만: 넘치면 새 슬라이드로 넘긴다. 블록은 절대 쪼개지 않는다.
|
|
359
|
+
if state['used'] > 0 and state['used'] + n > MAX_BLOCK_ROWS:
|
|
360
|
+
open_slide()
|
|
361
|
+
base = TITLE_ROWS + state['used']
|
|
362
|
+
slide = state['slide']
|
|
363
|
+
|
|
364
|
+
if block['type'] == 'bullets':
|
|
365
|
+
items = block['items']
|
|
366
|
+
for item_index, span in enumerate(bullet_spans(n, len(items))):
|
|
367
|
+
slide['shapes'].append({
|
|
368
|
+
'role': 'bullet',
|
|
369
|
+
'pageIndex': page_index,
|
|
370
|
+
'blockIndex': block_index,
|
|
371
|
+
'itemIndex': item_index,
|
|
372
|
+
'rows': span['rows'],
|
|
373
|
+
'x': col_x(grid, 0),
|
|
374
|
+
'y': row_y(grid, base + span['start']),
|
|
375
|
+
'cx': full_cx,
|
|
376
|
+
'cy': row_cy(grid, span['rows']),
|
|
377
|
+
'text': BULLET_PREFIX + items[item_index],
|
|
378
|
+
'pt': pt['bullets'],
|
|
379
|
+
'bold': False,
|
|
380
|
+
'color': COLOR_BODY,
|
|
381
|
+
})
|
|
382
|
+
else:
|
|
383
|
+
is_head = block['type'] == 'head'
|
|
384
|
+
slide['shapes'].append({
|
|
385
|
+
'role': block['type'],
|
|
386
|
+
'pageIndex': page_index,
|
|
387
|
+
'blockIndex': block_index,
|
|
388
|
+
'itemIndex': None,
|
|
389
|
+
'rows': n,
|
|
390
|
+
'x': col_x(grid, 0),
|
|
391
|
+
'y': row_y(grid, base),
|
|
392
|
+
'cx': full_cx,
|
|
393
|
+
'cy': row_cy(grid, n),
|
|
394
|
+
'text': block['text'],
|
|
395
|
+
'pt': pt['head'] if is_head else pt['body'],
|
|
396
|
+
'bold': is_head,
|
|
397
|
+
'color': COLOR_HEAD if is_head else COLOR_BODY,
|
|
398
|
+
})
|
|
399
|
+
state['used'] += n
|
|
400
|
+
|
|
401
|
+
return {'grid': grid, 'bodyPt': body_pt, 'slides': slides, 'warnings': warnings}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def assert_inside_slide(laid):
|
|
405
|
+
"""기하가 실제로 여백 안에 있는지 자기 점검. 클램프가 살아 있으면 절대 발동하지 않는다."""
|
|
406
|
+
g = laid['grid']
|
|
407
|
+
bad = []
|
|
408
|
+
for s in laid['slides']:
|
|
409
|
+
for j, sh in enumerate(s['shapes']):
|
|
410
|
+
if (sh['x'] < g['margin'] or sh['y'] < g['margin']
|
|
411
|
+
or sh['x'] + sh['cx'] > SLIDE_W - g['margin']
|
|
412
|
+
or sh['y'] + sh['cy'] > SLIDE_H - g['margin']):
|
|
413
|
+
bad.append('slide {0} shape {1} ({2}): x={3} y={4} cx={5} cy={6}'.format(
|
|
414
|
+
s['index'], j, sh['role'], sh['x'], sh['y'], sh['cx'], sh['cy']))
|
|
415
|
+
if bad:
|
|
416
|
+
raise VerifyError('도형이 여백 밖으로 나갔다 — 기하 버그다:\n ' + '\n '.join(bad))
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# ── 폰트: 이름을 고르고 파일이 있는지만 확인한다 (바이너리 파싱 0줄) ─────────
|
|
420
|
+
def norm_name(s):
|
|
421
|
+
return ''.join(ch for ch in str(s).lower() if ch.isalnum() and ch.isascii())
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def esc_fc(s):
|
|
425
|
+
out = []
|
|
426
|
+
for ch in str(s):
|
|
427
|
+
if ch in '-:,\\':
|
|
428
|
+
out.append('\\')
|
|
429
|
+
out.append(ch)
|
|
430
|
+
return ''.join(out)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
_FC_STATE = {'available': None}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def fc_match(pattern):
|
|
437
|
+
"""fc-match 로 이름 하나를 파일 + family 로 해석한다. 없으면 None (한 번 확인하면 기억한다)."""
|
|
438
|
+
if _FC_STATE['available'] is False:
|
|
439
|
+
return None
|
|
440
|
+
try:
|
|
441
|
+
res = subprocess.run(
|
|
442
|
+
['fc-match', '--format=%{file}|%{family}', pattern],
|
|
443
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
444
|
+
universal_newlines=True, timeout=5)
|
|
445
|
+
except FileNotFoundError:
|
|
446
|
+
_FC_STATE['available'] = False
|
|
447
|
+
return None
|
|
448
|
+
except (OSError, subprocess.SubprocessError):
|
|
449
|
+
return None
|
|
450
|
+
if res.returncode != 0 or not res.stdout:
|
|
451
|
+
return None
|
|
452
|
+
_FC_STATE['available'] = True
|
|
453
|
+
cut = res.stdout.find('|')
|
|
454
|
+
if cut < 0:
|
|
455
|
+
return None
|
|
456
|
+
file_path = res.stdout[:cut].strip()
|
|
457
|
+
families = [f.strip() for f in res.stdout[cut + 1:].split(',') if f.strip()]
|
|
458
|
+
if not file_path:
|
|
459
|
+
return None
|
|
460
|
+
return {'file': file_path, 'families': families}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def font_search_dirs():
|
|
464
|
+
home = os.path.expanduser('~')
|
|
465
|
+
if sys.platform == 'darwin':
|
|
466
|
+
return ['/System/Library/Fonts', '/Library/Fonts', os.path.join(home, 'Library', 'Fonts')]
|
|
467
|
+
if sys.platform.startswith('win'):
|
|
468
|
+
dirs = [os.path.join(os.environ.get('WINDIR') or 'C:\\Windows', 'Fonts')]
|
|
469
|
+
local = os.environ.get('LOCALAPPDATA')
|
|
470
|
+
if local:
|
|
471
|
+
dirs.append(os.path.join(local, 'Microsoft', 'Windows', 'Fonts'))
|
|
472
|
+
return dirs
|
|
473
|
+
return [
|
|
474
|
+
'/usr/share/fonts', '/usr/local/share/fonts',
|
|
475
|
+
os.path.join(home, '.local', 'share', 'fonts'), os.path.join(home, '.fonts'),
|
|
476
|
+
]
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def glob_font_file(name):
|
|
480
|
+
"""fc-match 가 없는 환경용 파일명 탐색. 깊이·개수 예산을 둬서 병적인 트리에서도 끝난다."""
|
|
481
|
+
hints = FONT_FILE_HINTS.get(str(name).lower(), (norm_name(name),))
|
|
482
|
+
budget = [20000]
|
|
483
|
+
|
|
484
|
+
def walk(directory, depth):
|
|
485
|
+
if depth > 6 or budget[0] <= 0:
|
|
486
|
+
return None
|
|
487
|
+
try:
|
|
488
|
+
entries = list(os.scandir(directory))
|
|
489
|
+
except OSError:
|
|
490
|
+
return None
|
|
491
|
+
subdirs = []
|
|
492
|
+
for ent in entries:
|
|
493
|
+
budget[0] -= 1
|
|
494
|
+
if budget[0] <= 0:
|
|
495
|
+
return None
|
|
496
|
+
full = os.path.join(directory, ent.name)
|
|
497
|
+
try:
|
|
498
|
+
if ent.is_dir():
|
|
499
|
+
subdirs.append(full)
|
|
500
|
+
continue
|
|
501
|
+
except OSError:
|
|
502
|
+
continue
|
|
503
|
+
stem, ext = os.path.splitext(ent.name)
|
|
504
|
+
if ext.lower() not in FONT_EXT:
|
|
505
|
+
continue
|
|
506
|
+
flat = norm_name(stem)
|
|
507
|
+
if any(h in flat for h in hints):
|
|
508
|
+
return full
|
|
509
|
+
for sub in subdirs:
|
|
510
|
+
hit = walk(sub, depth + 1)
|
|
511
|
+
if hit:
|
|
512
|
+
return hit
|
|
513
|
+
return None
|
|
514
|
+
|
|
515
|
+
for directory in font_search_dirs():
|
|
516
|
+
if not os.path.exists(directory):
|
|
517
|
+
continue
|
|
518
|
+
hit = walk(directory, 0)
|
|
519
|
+
if hit:
|
|
520
|
+
return hit
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def find_font_file(name):
|
|
525
|
+
"""
|
|
526
|
+
이름 하나를 실제 파일로 해석한다.
|
|
527
|
+
fc-match 는 없는 폰트에도 대체 폰트를 돌려주므로(예: Malgun Gothic → DejaVu Sans)
|
|
528
|
+
돌려받은 family 가 요청한 이름과 실제로 맞는지 대조해야 한다. 안 그러면 이름이 거짓말이 된다.
|
|
529
|
+
"""
|
|
530
|
+
want = norm_name(name)
|
|
531
|
+
hit = fc_match(esc_fc(name))
|
|
532
|
+
if hit:
|
|
533
|
+
for fam in hit['families']:
|
|
534
|
+
n = norm_name(fam)
|
|
535
|
+
if n == want or want in n or n in want:
|
|
536
|
+
if os.path.exists(hit['file']):
|
|
537
|
+
return hit['file']
|
|
538
|
+
break
|
|
539
|
+
globbed = glob_font_file(name)
|
|
540
|
+
if globbed and os.path.exists(globbed):
|
|
541
|
+
return globbed
|
|
542
|
+
return None
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def resolve_font(font_arg, meta_font):
|
|
546
|
+
"""
|
|
547
|
+
체인을 돌며 "존재하는 파일을 가진 이름" 을 고른다.
|
|
548
|
+
명시된 이름(--font·env·meta.font)이 해석되지 않으면 경고를 남기고 다음으로 내려간다 —
|
|
549
|
+
조용히 바꾸지 않는다.
|
|
550
|
+
"""
|
|
551
|
+
warnings = []
|
|
552
|
+
candidates = [
|
|
553
|
+
{'name': font_arg, 'from': '--font'},
|
|
554
|
+
{'name': os.environ.get('BANKER_PPTX_FONT'), 'from': 'BANKER_PPTX_FONT'},
|
|
555
|
+
{'name': meta_font if (meta_font and meta_font != 'auto') else '', 'from': 'meta.font'},
|
|
556
|
+
]
|
|
557
|
+
explicit = [c for c in candidates if isinstance(c['name'], str) and c['name'].strip() != '']
|
|
558
|
+
|
|
559
|
+
for c in explicit:
|
|
560
|
+
name = c['name'].strip()
|
|
561
|
+
file_path = find_font_file(name)
|
|
562
|
+
if file_path:
|
|
563
|
+
return {'name': name, 'file': file_path, 'from': c['from'], 'warnings': warnings}
|
|
564
|
+
warnings.append({'code': 'FONT_NOT_FOUND', 'from': c['from'], 'detail': name})
|
|
565
|
+
|
|
566
|
+
for name in FONT_CHAIN:
|
|
567
|
+
file_path = find_font_file(name)
|
|
568
|
+
if file_path:
|
|
569
|
+
return {'name': name, 'file': file_path, 'from': 'chain', 'warnings': warnings}
|
|
570
|
+
|
|
571
|
+
sys_hit = fc_match('sans-serif:lang=ko')
|
|
572
|
+
if sys_hit and sys_hit['families'] and os.path.exists(sys_hit['file']):
|
|
573
|
+
return {'name': sys_hit['families'][0], 'file': sys_hit['file'],
|
|
574
|
+
'from': 'system', 'warnings': warnings}
|
|
575
|
+
|
|
576
|
+
fallback = explicit[0]['name'].strip() if explicit else FONT_CHAIN[0]
|
|
577
|
+
warnings.append({'code': 'FONT_FILE_UNRESOLVED', 'from': 'chain', 'detail': fallback})
|
|
578
|
+
return {'name': fallback, 'file': None, 'from': 'unresolved', 'warnings': warnings}
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# ── python-pptx 해석 (설치는 하지 않는다) ───────────────────────────────────
|
|
582
|
+
class Pptx(object):
|
|
583
|
+
"""python-pptx 에서 필요한 이름만 모아 둔 묶음. import 를 한 곳에 가둔다."""
|
|
584
|
+
|
|
585
|
+
def __init__(self, presentation, emu, pt, rgb, align, anchor, qn):
|
|
586
|
+
self.Presentation = presentation
|
|
587
|
+
self.Emu = emu
|
|
588
|
+
self.Pt = pt
|
|
589
|
+
self.RGBColor = rgb
|
|
590
|
+
self.PP_ALIGN = align
|
|
591
|
+
self.MSO_ANCHOR = anchor
|
|
592
|
+
self.qn = qn
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def pptx_missing_message(reason):
|
|
596
|
+
"""자동 설치는 하지 않는다. 사람이 읽고 스스로 고칠 수 있는 안내만 낸다(자식 프로세스 0개)."""
|
|
597
|
+
return '\n'.join([
|
|
598
|
+
'python-pptx 를 찾지 못했다. 이 스킬은 의존성을 자동 설치하지 않는다.',
|
|
599
|
+
'',
|
|
600
|
+
' 설치: python -m pip install python-pptx',
|
|
601
|
+
' (전역 설치가 막힌 환경이면 --target <dir> 로 받고 PYTHONPATH 에 그 경로를 넣는다)',
|
|
602
|
+
' 안내: docs-setup 스킬이 이 스킬의 런타임 준비 절차를 담고 있다 (/banker:docs-setup).',
|
|
603
|
+
' 대안: node 를 쓸 수 있으면 build_a4p.js (pptxgenjs) 로 같은 deck JSON 을 빌드한다.',
|
|
604
|
+
' 검증기 verify_a4p.mjs 는 node 전용이라 검증 경로는 어느 쪽이든 node 를 요구한다.',
|
|
605
|
+
'',
|
|
606
|
+
' 인터프리터: {0} (python {1}.{2}.{3})'.format(
|
|
607
|
+
sys.executable or '(알 수 없음)', *sys.version_info[:3]),
|
|
608
|
+
' sys.path: {0}'.format(', '.join(p for p in sys.path if p) or '(비어 있음)'),
|
|
609
|
+
' 원인: {0}'.format(reason),
|
|
610
|
+
])
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def load_pptx():
|
|
614
|
+
if sys.version_info < MIN_PYTHON:
|
|
615
|
+
raise EnvError(
|
|
616
|
+
'python {0}.{1} 이상이 필요하다 (실행 중인 인터프리터: {2} — python {3}.{4}.{5}).\n'
|
|
617
|
+
' 이 파일은 셰방에 의존하지 않는다. 쓸 인터프리터를 직접 지정해 부른다:\n'
|
|
618
|
+
' /path/to/python{0}.{1}+ build_a4p.py --in deck.json --out out.pptx\n'
|
|
619
|
+
' 안내: docs-setup 스킬 (/banker:docs-setup).'.format(
|
|
620
|
+
MIN_PYTHON[0], MIN_PYTHON[1], sys.executable or '(알 수 없음)',
|
|
621
|
+
*sys.version_info[:3]))
|
|
622
|
+
try:
|
|
623
|
+
from pptx import Presentation
|
|
624
|
+
from pptx.util import Emu, Pt
|
|
625
|
+
from pptx.dml.color import RGBColor
|
|
626
|
+
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
|
|
627
|
+
from pptx.oxml.ns import qn
|
|
628
|
+
except ImportError as exc:
|
|
629
|
+
raise EnvError(pptx_missing_message(exc))
|
|
630
|
+
return Pptx(Presentation, Emu, Pt, RGBColor, PP_ALIGN, MSO_ANCHOR, qn)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# ── 빌드 ────────────────────────────────────────────────────────────────────
|
|
634
|
+
def strip_sldsz_type(prs, qn):
|
|
635
|
+
"""
|
|
636
|
+
F3. slide_width/height 를 A4 로 바꿔도 python-pptx 는 기본 템플릿의
|
|
637
|
+
type="screen4x3" 선언을 그대로 들고 나간다. 치수는 A4 인데 선언은 4:3 인 파일이고,
|
|
638
|
+
선언 쪽을 믿는 뷰어·변환기가 있다. OOXML 에서 이 속성의 기본값이 custom 이므로
|
|
639
|
+
지우는 것이 곧 custom 선언이다.
|
|
640
|
+
"""
|
|
641
|
+
sldsz = prs._element.find(qn('p:sldSz'))
|
|
642
|
+
if sldsz is None:
|
|
643
|
+
raise VerifyError('p:sldSz 를 찾지 못했다 — python-pptx 기본 템플릿이 예상과 다르다.')
|
|
644
|
+
sldsz.attrib.pop('type', None)
|
|
645
|
+
return sldsz
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def fix_body_pr(tf, mod):
|
|
649
|
+
"""
|
|
650
|
+
F13. a:bodyPr 를 node 빌더와 한 글자까지 맞춘다:
|
|
651
|
+
<a:bodyPr wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" rtlCol="0" anchor="t">
|
|
652
|
+
autofit 요소는 하나도 두지 않는다. python-pptx 의 add_textbox 기본값은
|
|
653
|
+
wrap="none" + <a:spAutoFit/> 라서, 그대로 두면 텍스트가 안 감기고 뷰어가 열 때
|
|
654
|
+
도형 크기를 바꾼다 — 저장된 a:ext 는 맞는데 화면에서 INV-G 가 깨지는 상태다.
|
|
655
|
+
속성을 넣는 순서가 곧 XML 의 속성 순서다. 위 문자열과 같은 순서로 넣는다.
|
|
656
|
+
"""
|
|
657
|
+
tf.word_wrap = True # wrap="square"
|
|
658
|
+
tf.margin_left = mod.Emu(0)
|
|
659
|
+
tf.margin_top = mod.Emu(0)
|
|
660
|
+
tf.margin_right = mod.Emu(0)
|
|
661
|
+
tf.margin_bottom = mod.Emu(0)
|
|
662
|
+
body_pr = tf._txBody.bodyPr
|
|
663
|
+
body_pr.set('rtlCol', '0')
|
|
664
|
+
tf.vertical_anchor = mod.MSO_ANCHOR.TOP # anchor="t"
|
|
665
|
+
for tag in ('a:spAutoFit', 'a:normAutofit', 'a:noAutofit'):
|
|
666
|
+
el = body_pr.find(mod.qn(tag))
|
|
667
|
+
if el is not None:
|
|
668
|
+
body_pr.remove(el)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def style_paragraph(paragraph, mod):
|
|
672
|
+
"""문단 속성도 node 산출물과 맞춘다: 왼쪽 정렬 · 들여쓰기 0 · 줄간 1.2 · 불릿 글리프 없음."""
|
|
673
|
+
paragraph.alignment = mod.PP_ALIGN.LEFT
|
|
674
|
+
p_pr = paragraph._p.get_or_add_pPr()
|
|
675
|
+
p_pr.set('indent', '0')
|
|
676
|
+
p_pr.set('marL', '0')
|
|
677
|
+
paragraph.line_spacing = LINE_SPACING
|
|
678
|
+
# 불릿 글리프는 항목 텍스트 앞의 '• ' 로 직접 그린다. 목록 자동 글리프가 붙으면 두 번 찍힌다.
|
|
679
|
+
if p_pr.find(mod.qn('a:buNone')) is None:
|
|
680
|
+
p_pr.append(p_pr.makeelement(mod.qn('a:buNone'), {}))
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def set_run(run, text, pt, bold, color, typeface, mod):
|
|
684
|
+
"""
|
|
685
|
+
F4. run.font.name 은 a:latin 하나만 낸다.
|
|
686
|
+
a:ea 가 없으면 한글이 대체 폰트로 떨어져 tofu 가 나고, 검증기의 폭 판정도 이름을 잘못 읽는다.
|
|
687
|
+
세 슬롯에 같은 typeface 를 직접 넣는다(패턴 출처: skills/arch-diagram/build_pptx_template.py).
|
|
688
|
+
"""
|
|
689
|
+
run.text = text
|
|
690
|
+
font = run.font
|
|
691
|
+
font.size = mod.Pt(pt)
|
|
692
|
+
if bold:
|
|
693
|
+
font.bold = True
|
|
694
|
+
font.color.rgb = mod.RGBColor.from_string(color)
|
|
695
|
+
font.name = typeface
|
|
696
|
+
r_pr = run._r.get_or_add_rPr()
|
|
697
|
+
for tag in ('a:latin', 'a:ea', 'a:cs'):
|
|
698
|
+
el = r_pr.find(mod.qn(tag))
|
|
699
|
+
if el is None:
|
|
700
|
+
el = r_pr.makeelement(mod.qn(tag), {})
|
|
701
|
+
r_pr.append(el)
|
|
702
|
+
el.set('typeface', typeface)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
# OPC core property `title` 의 길이 상한. OOXML 규격이 아니라 python-pptx 가 스스로 거는 제한인데,
|
|
706
|
+
# 두 빌더가 같은 deck 에서 갈리면 원칙 5(교체 가능)가 깨지므로 node 도 똑같이 자른다.
|
|
707
|
+
# meta.title 은 문서 메타데이터일 뿐이고 페이지 제목은 별도 도형이라, 잘려도 보이는 내용은 잃지 않는다.
|
|
708
|
+
CORE_TITLE_MAX = 255
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def clamp_core_title(title):
|
|
712
|
+
if not isinstance(title, str) or len(title) <= CORE_TITLE_MAX:
|
|
713
|
+
return title, False
|
|
714
|
+
return title[:CORE_TITLE_MAX], True
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def build(laid, out_path, font, deck, mod, warnings=None):
|
|
718
|
+
prs = mod.Presentation()
|
|
719
|
+
# 치수를 먼저 A4 로 바꾸고, 남는 4:3 선언을 지운다.
|
|
720
|
+
prs.slide_width = mod.Emu(SLIDE_W)
|
|
721
|
+
prs.slide_height = mod.Emu(SLIDE_H)
|
|
722
|
+
strip_sldsz_type(prs, mod.qn)
|
|
723
|
+
|
|
724
|
+
meta = deck.get('meta') or {}
|
|
725
|
+
if meta.get('title'):
|
|
726
|
+
value, truncated = clamp_core_title(meta['title'])
|
|
727
|
+
if truncated and warnings is not None:
|
|
728
|
+
warnings.append({'code': 'TITLE_TRUNCATED', 'from': 'meta.title',
|
|
729
|
+
'detail': '{0} -> {1}'.format(len(meta['title']), CORE_TITLE_MAX)})
|
|
730
|
+
# 클램프가 막아 주지만, 라이브러리가 거는 다른 상한이 남아 있을 수 있다.
|
|
731
|
+
# 원시 트레이스백이 새면 exit code 계약(입력 문제 = 3)이 깨지므로 UsageError 로 들여보낸다.
|
|
732
|
+
try:
|
|
733
|
+
prs.core_properties.title = value
|
|
734
|
+
except ValueError as err:
|
|
735
|
+
raise UsageError('meta.title 을 core property 로 쓸 수 없다: {0}'.format(err))
|
|
736
|
+
|
|
737
|
+
# 빈 레이아웃만 쓴다. 기본 템플릿의 자리표시자는 4:3 좌표를 들고 있어서
|
|
738
|
+
# A4 로 바꾼 슬라이드에 얹으면 좌표가 규격 밖으로 나간다.
|
|
739
|
+
blank = prs.slide_layouts[6]
|
|
740
|
+
|
|
741
|
+
for s in laid['slides']:
|
|
742
|
+
slide = prs.slides.add_slide(blank)
|
|
743
|
+
for sh in s['shapes']:
|
|
744
|
+
box = slide.shapes.add_textbox(
|
|
745
|
+
mod.Emu(sh['x']), mod.Emu(sh['y']), mod.Emu(sh['cx']), mod.Emu(sh['cy']))
|
|
746
|
+
tf = box.text_frame
|
|
747
|
+
fix_body_pr(tf, mod)
|
|
748
|
+
# 개행은 문단으로 편다 — node 쪽(pptxgenjs)이 '\n' 마다 <a:p> 를 하나씩 내고,
|
|
749
|
+
# 빈 줄에는 run 을 만들지 않는다. 그 모양을 그대로 따른다.
|
|
750
|
+
for i, line in enumerate(str(sh['text']).split('\n')):
|
|
751
|
+
paragraph = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
|
752
|
+
style_paragraph(paragraph, mod)
|
|
753
|
+
if line != '':
|
|
754
|
+
set_run(paragraph.add_run(), line, sh['pt'], sh['bold'],
|
|
755
|
+
sh['color'], font['name'], mod)
|
|
756
|
+
|
|
757
|
+
prs.save(out_path)
|
|
758
|
+
return out_path
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
# ── 출력 ────────────────────────────────────────────────────────────────────
|
|
762
|
+
def human_report(laid, font, out_path, warnings):
|
|
763
|
+
g = laid['grid']
|
|
764
|
+
shapes = sum(len(s['shapes']) for s in laid['slides'])
|
|
765
|
+
pages = len(set(s['pageIndex'] for s in laid['slides']))
|
|
766
|
+
lines = [
|
|
767
|
+
'vertical-pptx (python 빌더)',
|
|
768
|
+
' 용지 A4 세로 210x297mm · sldSz {0} x {1} EMU'.format(SLIDE_W, SLIDE_H),
|
|
769
|
+
' 여백 {0}mm · 그리드 {1}열 x {2}행 · 거터 4mm'.format(g['marginMm'], COLS, ROWS),
|
|
770
|
+
' 칸 컬럼 {0} · 행 {1} · 피치 {2} / {3} EMU'.format(
|
|
771
|
+
g['colW'], g['rowH'], g['colPitch'], g['rowPitch']),
|
|
772
|
+
]
|
|
773
|
+
if font:
|
|
774
|
+
note = '' if font['file'] else ' (파일 미해석 — 검증기 폭 판정은 estimate 로 내려간다)'
|
|
775
|
+
lines.append(' 폰트 {0}{1}'.format(font['name'], note))
|
|
776
|
+
lines.append(' {0}'.format(font['file'] or '(경로 없음)'))
|
|
777
|
+
lines.append(' 본문 {0}pt · 줄간 {1}'.format(fmt_num(laid['bodyPt']), LINE_SPACING))
|
|
778
|
+
lines.append(' 슬라이드 {0} (deck 페이지 {1}개)'.format(len(laid['slides']), pages))
|
|
779
|
+
lines.append(' 도형 {0}'.format(shapes))
|
|
780
|
+
lines.append(' 경고 {0}'.format(len(warnings)))
|
|
781
|
+
for w in warnings:
|
|
782
|
+
if w.get('page') is not None:
|
|
783
|
+
where = 'page {0} block {1}'.format(w['page'], w['block'])
|
|
784
|
+
elif w.get('from') is not None:
|
|
785
|
+
where = w['from']
|
|
786
|
+
else:
|
|
787
|
+
where = ''
|
|
788
|
+
lines.append(' {0} {1} {2}'.format(w['code'], where, w.get('detail') or '').rstrip())
|
|
789
|
+
if out_path:
|
|
790
|
+
lines.append(' 출력 {0}'.format(out_path))
|
|
791
|
+
return '\n'.join(lines)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def fmt_num(value):
|
|
795
|
+
"""10.5 는 10.5 로, 11.0 은 11 로 — node 의 수 출력과 같게 보이도록."""
|
|
796
|
+
if isinstance(value, float) and value.is_integer():
|
|
797
|
+
return str(int(value))
|
|
798
|
+
return str(value)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def status_json(font, slides, warnings):
|
|
802
|
+
return json.dumps({
|
|
803
|
+
'skill': 'vertical-pptx',
|
|
804
|
+
'builder': 'python',
|
|
805
|
+
'font': font['name'] if font else None,
|
|
806
|
+
'fontFile': font['file'] if font else None,
|
|
807
|
+
'slides': slides,
|
|
808
|
+
'warnings': warnings,
|
|
809
|
+
}, ensure_ascii=False, separators=(',', ':'))
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
# ── CLI ─────────────────────────────────────────────────────────────────────
|
|
813
|
+
USAGE = '\n'.join([
|
|
814
|
+
'vertical-pptx / build_a4p.py — A4 세로(210x297mm) PPTX python 빌더',
|
|
815
|
+
'',
|
|
816
|
+
'사용법:',
|
|
817
|
+
' python build_a4p.py --in deck.json --out out.pptx [--font <이름>]',
|
|
818
|
+
'',
|
|
819
|
+
'옵션:',
|
|
820
|
+
' --in <deck.json> deck JSON 입력 (두 빌더의 유일한 계약)',
|
|
821
|
+
' --out <경로> .pptx 출력 경로',
|
|
822
|
+
' --font <이름> 폰트 이름을 직접 지정. 체인: --font → BANKER_PPTX_FONT → meta.font',
|
|
823
|
+
' → Noto Sans CJK KR → Malgun Gothic → Apple SD Gothic Neo → NanumGothic'
|
|
824
|
+
' → 시스템 한국어 sans',
|
|
825
|
+
' -h, --help 이 도움말',
|
|
826
|
+
'',
|
|
827
|
+
'입력은 deck JSON 뿐이다. 마크다운 변환 플래그는 이 빌더에 없다 —',
|
|
828
|
+
'원고를 deck JSON 으로 바꾸는 일은 node 빌더(build_a4p.js)가 단독으로 맡는다.',
|
|
829
|
+
'이 프로젝트의 마크다운 파서는 그 한 벌뿐이고, 두 벌이 되는 순간 같은 원고가 두 개의 덱이 된다.',
|
|
830
|
+
'',
|
|
831
|
+
'요구 사항: python 3.9 이상 · python-pptx (자동 설치하지 않는다)',
|
|
832
|
+
'이 파일은 셰방에 의존하지 않는다. 쓸 인터프리터를 직접 지정해 부르는 것이 안전하다.',
|
|
833
|
+
'',
|
|
834
|
+
'종료 코드: 0 통과 · 1 검증 실패 · 2 환경 미비(python 버전·python-pptx) · 3 사용법 오류',
|
|
835
|
+
])
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def parse_args(argv):
|
|
839
|
+
opts = {'in': '', 'out': '', 'fontArg': '', 'help': False}
|
|
840
|
+
i = 0
|
|
841
|
+
while i < len(argv):
|
|
842
|
+
a = argv[i]
|
|
843
|
+
|
|
844
|
+
def need(name):
|
|
845
|
+
if i + 1 >= len(argv) or argv[i + 1].startswith('--'):
|
|
846
|
+
raise UsageError('{0} 에 값이 필요하다.'.format(name))
|
|
847
|
+
return argv[i + 1]
|
|
848
|
+
|
|
849
|
+
if a == '--in':
|
|
850
|
+
opts['in'] = need('--in')
|
|
851
|
+
i += 1
|
|
852
|
+
elif a == '--out':
|
|
853
|
+
opts['out'] = need('--out')
|
|
854
|
+
i += 1
|
|
855
|
+
elif a == '--font':
|
|
856
|
+
opts['fontArg'] = need('--font')
|
|
857
|
+
i += 1
|
|
858
|
+
elif a in ('-h', '--help'):
|
|
859
|
+
opts['help'] = True
|
|
860
|
+
else:
|
|
861
|
+
raise UsageError('알 수 없는 인자: {0}\n\n{1}'.format(a, USAGE))
|
|
862
|
+
i += 1
|
|
863
|
+
return opts
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def read_deck_file(path):
|
|
867
|
+
try:
|
|
868
|
+
with open(path, 'r', encoding='utf-8') as fh:
|
|
869
|
+
text = fh.read()
|
|
870
|
+
except OSError as exc:
|
|
871
|
+
raise UsageError('deck JSON 을 읽지 못했다: {0}'.format(exc))
|
|
872
|
+
try:
|
|
873
|
+
return json.loads(text)
|
|
874
|
+
except ValueError as exc:
|
|
875
|
+
raise UsageError('deck JSON 파싱 실패 ({0}): {1}'.format(path, exc))
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def run_main(argv):
|
|
879
|
+
opts = parse_args(argv)
|
|
880
|
+
if opts['help'] or not opts['in']:
|
|
881
|
+
sys.stdout.write(USAGE + '\n')
|
|
882
|
+
return EXIT_OK if opts['help'] else EXIT_USAGE
|
|
883
|
+
if not opts['out']:
|
|
884
|
+
raise UsageError('--out <경로> 가 필요하다.')
|
|
885
|
+
|
|
886
|
+
deck = read_deck_file(opts['in'])
|
|
887
|
+
laid = layout(deck) # validate_deck 포함 — 스키마 위반은 여기서 exit 3
|
|
888
|
+
warnings = list(laid['warnings'])
|
|
889
|
+
assert_inside_slide(laid)
|
|
890
|
+
|
|
891
|
+
# ★ python-pptx 해석을 폰트 해석보다 먼저 한다.
|
|
892
|
+
# 환경이 미비하면 자식 프로세스(fc-match)를 하나도 띄우지 않고 exit 2 로 나간다.
|
|
893
|
+
mod = load_pptx()
|
|
894
|
+
meta = deck.get('meta') or {}
|
|
895
|
+
font = resolve_font(opts['fontArg'], meta.get('font'))
|
|
896
|
+
warnings.extend(font['warnings'])
|
|
897
|
+
|
|
898
|
+
out_path = build(laid, os.path.abspath(opts['out']), font, deck, mod, warnings)
|
|
899
|
+
sys.stdout.write(human_report(laid, font, out_path, warnings) + '\n')
|
|
900
|
+
sys.stdout.write(status_json(font, len(laid['slides']), warnings) + '\n')
|
|
901
|
+
return EXIT_OK
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def main(argv):
|
|
905
|
+
try:
|
|
906
|
+
return run_main(argv)
|
|
907
|
+
except (UsageError, EnvError, VerifyError) as err:
|
|
908
|
+
# 환경 미비(exit 2)는 사용자가 그대로 따라 할 수 있는 안내라서 stdout 으로 낸다.
|
|
909
|
+
# 나머지 오류는 stderr.
|
|
910
|
+
stream = sys.stdout if err.exit_code == EXIT_ENV else sys.stderr
|
|
911
|
+
stream.write('{0}\n'.format(err))
|
|
912
|
+
return err.exit_code
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
if __name__ == '__main__':
|
|
916
|
+
sys.exit(main(sys.argv[1:]))
|