@kaydash9999/banker-plugins 0.20.5 → 0.22.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/PRIVACY.md +25 -1
- package/README.md +63 -18
- package/bin/banker.js +173 -14
- package/bin/lib/codex-hooks.mjs +254 -0
- package/bin/lib/codex-routes.mjs +112 -0
- package/bin/lib/command-skill.cjs +19 -0
- package/bin/lib/telemetry-config.mjs +4 -1
- package/codex/command-skills/front-qa/SKILL.md +56 -0
- package/codex/command-skills/setup/SKILL.md +87 -0
- package/codex/hooks.json +29 -0
- package/codex/manifest.json +13 -10
- package/codex/skills/graceful-pause/SKILL.md +30 -0
- package/codex/skills/progress/SKILL.md +42 -0
- package/codex/skills/progress/scripts/progress-plan.mjs +722 -0
- package/codex/skills/progress/scripts/progress.mjs +295 -0
- package/codex/transform-matrix.md +18 -13
- package/commands/setup.md +2 -2
- package/hooks/obsidize-hook.mjs +116 -4
- package/hooks/progress-plan.mjs +1 -1
- package/hooks/telemetry-count-codex.mjs +309 -0
- package/hooks/update-notify.mjs +105 -14
- package/package.json +1 -1
- package/skills/3d-intro-build/references/verify-intro.mjs +15 -1
- package/skills/anti-drm/SKILL.md +1 -1
- package/skills/anti-drm/scripts/instruction-toggle.mjs +57 -4
- package/skills/append_wiki/SKILL.md +34 -8
- package/skills/arch-diagram/build_pptx_template.py +47 -28
- package/skills/audit-web-page/SKILL.md +1 -1
- package/skills/audit-web-page/harness.py +0 -1
- package/skills/compact-wiki/SKILL.md +37 -6
- package/skills/deep-research/SKILL.md +6 -0
- package/skills/lfm/SKILL.md +148 -0
- package/skills/lineage/SKILL.md +14 -4
- package/skills/lineage/lineage.py +1582 -516
- package/skills/motion-graphic-make/SKILL.md +1 -1
- package/skills/obsidizer/SKILL.md +44 -10
- package/skills/obsidizer/obsidize.mjs +42 -11
- package/skills/omc-patch/SKILL.md +6 -0
- package/skills/payload-mon/SKILL.md +3 -1
- package/skills/play-qa/SKILL.md +1 -1
- package/skills/ralph-qa/SKILL.md +1 -1
- package/skills/remains/SKILL.md +1 -1
- package/skills/rfp-author/SKILL.md +5 -3
- package/skills/rfp-author/references/vendor-rfp-structure.md +1 -1
- package/skills/rfp-author/scripts/md2docx.py +63 -34
- package/skills/setup-bypass-permissions/SKILL.md +28 -10
- package/skills/setup-bypass-permissions/scripts/codex-bypass.mjs +135 -0
- package/skills/setup-bypass-permissions/scripts/codex-config.mjs +210 -0
- package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +66 -15
- package/skills/setup-omc-hud/SKILL.md +33 -3
- package/skills/setup-omc-hud/assets/codex-config.mjs +210 -0
- package/skills/setup-omc-hud/assets/codex-status-line.mjs +90 -0
- package/skills/smart-compact/SKILL.md +59 -13
- package/skills/smart-compact/scripts/codex-gate.mjs +385 -0
- package/skills/theme-selection/SKILL.md +1 -1
- package/skills/tone-compact/SKILL.md +1 -1
- package/skills/tone-compact/scripts/instruction-toggle.mjs +57 -4
- package/skills/ultra-chain/SKILL.md +10 -6
- package/skills/ultra-interview/SKILL.md +13 -2
- package/skills/ultra-ui-qa/SKILL.md +1 -1
- package/skills/update-banker/SKILL.md +31 -6
- package/skills/vertical-pptx/SKILL.md +1 -1
- package/skills/vertical-pptx/build_a4p.js +20 -2
- package/skills/vertical-pptx/build_a4p.py +560 -359
|
@@ -59,6 +59,7 @@ python-pptx 특유의 함정 셋. 전부 이 저장소에서 실측한 것이고
|
|
|
59
59
|
import json
|
|
60
60
|
import math
|
|
61
61
|
import os
|
|
62
|
+
import re
|
|
62
63
|
import subprocess
|
|
63
64
|
import sys
|
|
64
65
|
|
|
@@ -67,7 +68,7 @@ MIN_PYTHON = (3, 9)
|
|
|
67
68
|
EMU_PER_MM = 36000
|
|
68
69
|
SLIDE_W = 210 * EMU_PER_MM # 7560000
|
|
69
70
|
SLIDE_H = 297 * EMU_PER_MM # 10692000
|
|
70
|
-
GUTTER = 4 * EMU_PER_MM
|
|
71
|
+
GUTTER = 4 * EMU_PER_MM # 144000
|
|
71
72
|
COLS = 8
|
|
72
73
|
ROWS = 20
|
|
73
74
|
TITLE_ROWS = 2
|
|
@@ -77,11 +78,11 @@ MARGIN_MM_MAX = 40
|
|
|
77
78
|
DEFAULT_MARGIN_MM = 10
|
|
78
79
|
DEFAULT_BODY_PT = 10.5 # 동결 범위 10~11pt
|
|
79
80
|
LINE_SPACING = 1.2
|
|
80
|
-
BULLET_PREFIX =
|
|
81
|
-
COLOR_HEAD =
|
|
82
|
-
COLOR_BODY =
|
|
83
|
-
BLOCK_TYPES = (
|
|
84
|
-
ORIENTATIONS = (
|
|
81
|
+
BULLET_PREFIX = "\u2022 "
|
|
82
|
+
COLOR_HEAD = "1A1A1A"
|
|
83
|
+
COLOR_BODY = "222222"
|
|
84
|
+
BLOCK_TYPES = ("head", "body", "bullets")
|
|
85
|
+
ORIENTATIONS = ("portrait", "landscape")
|
|
85
86
|
|
|
86
87
|
EXIT_OK = 0
|
|
87
88
|
EXIT_VERIFY = 1
|
|
@@ -89,15 +90,29 @@ EXIT_ENV = 2
|
|
|
89
90
|
EXIT_USAGE = 3
|
|
90
91
|
|
|
91
92
|
# 폰트 체인. --font → BANKER_PPTX_FONT → meta.font → 아래 목록 → 시스템 한국어 sans.
|
|
92
|
-
|
|
93
|
+
# build_a4p.js 의 FONT_CHAIN, FONT_FILE_HINTS 와 같아야 한다(a4p.test.mjs U18b).
|
|
94
|
+
# "Noto Sans KR" 은 "Noto Sans CJK KR" 의 별칭이 아니라 다른 패밀리다. Windows 의 NotoSansKR-VF.ttf 가 그것이다.
|
|
95
|
+
# CJK 항목의 힌트로 묶으면 그 머신에 없는 이름 "Noto Sans CJK KR" 을 pptx 에 적게 된다.
|
|
96
|
+
# AppleGothic 도 Apple SD Gothic Neo 와 다른 폰트다.
|
|
97
|
+
FONT_CHAIN = (
|
|
98
|
+
"Noto Sans CJK KR",
|
|
99
|
+
"Noto Sans KR",
|
|
100
|
+
"Malgun Gothic",
|
|
101
|
+
"Apple SD Gothic Neo",
|
|
102
|
+
"AppleGothic",
|
|
103
|
+
"NanumGothic",
|
|
104
|
+
)
|
|
93
105
|
# fc-match 가 없는 환경에서 파일명으로 찾기 위한 힌트(정규화된 소문자 조각).
|
|
106
|
+
# 힌트는 그 항목의 패밀리보다 느슨하면 안 된다. 파일명 탐색에는 패밀리 대조가 없다.
|
|
94
107
|
FONT_FILE_HINTS = {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
108
|
+
"noto sans cjk kr": ("notosanscjkkr", "notosanscjk"),
|
|
109
|
+
"noto sans kr": ("notosanskr",),
|
|
110
|
+
"malgun gothic": ("malgun",),
|
|
111
|
+
"apple sd gothic neo": ("applesdgothicneo",),
|
|
112
|
+
"applegothic": ("applegothic",),
|
|
113
|
+
"nanumgothic": ("nanumgothic",),
|
|
99
114
|
}
|
|
100
|
-
FONT_EXT = frozenset((
|
|
115
|
+
FONT_EXT = frozenset((".ttf", ".ttc", ".otf", ".otc"))
|
|
101
116
|
|
|
102
117
|
|
|
103
118
|
# ── 오류 타입 ───────────────────────────────────────────────────────────────
|
|
@@ -119,49 +134,49 @@ def grid_for(margin_mm):
|
|
|
119
134
|
col_w = (182 - 2 * m) * 4500
|
|
120
135
|
row_h = (221 - 2 * m) * 1800
|
|
121
136
|
return {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
137
|
+
"marginMm": m,
|
|
138
|
+
"margin": EMU_PER_MM * m,
|
|
139
|
+
"slideW": SLIDE_W,
|
|
140
|
+
"slideH": SLIDE_H,
|
|
141
|
+
"contentW": SLIDE_W - 2 * EMU_PER_MM * m,
|
|
142
|
+
"contentH": SLIDE_H - 2 * EMU_PER_MM * m,
|
|
143
|
+
"gutter": GUTTER,
|
|
144
|
+
"cols": COLS,
|
|
145
|
+
"rows": ROWS,
|
|
146
|
+
"colW": col_w,
|
|
147
|
+
"rowH": row_h,
|
|
148
|
+
"colPitch": col_w + GUTTER,
|
|
149
|
+
"rowPitch": row_h + GUTTER,
|
|
135
150
|
}
|
|
136
151
|
|
|
137
152
|
|
|
138
153
|
def col_x(g, c):
|
|
139
|
-
return g[
|
|
154
|
+
return g["margin"] + g["colPitch"] * c
|
|
140
155
|
|
|
141
156
|
|
|
142
157
|
def col_cx(g, k):
|
|
143
|
-
return g[
|
|
158
|
+
return g["colW"] * k + GUTTER * (k - 1)
|
|
144
159
|
|
|
145
160
|
|
|
146
161
|
def row_y(g, r):
|
|
147
|
-
return g[
|
|
162
|
+
return g["margin"] + g["rowPitch"] * r
|
|
148
163
|
|
|
149
164
|
|
|
150
165
|
def row_cy(g, n):
|
|
151
|
-
return g[
|
|
166
|
+
return g["rowH"] * n + GUTTER * (n - 1)
|
|
152
167
|
|
|
153
168
|
|
|
154
169
|
def default_rows(block):
|
|
155
|
-
if block.get(
|
|
170
|
+
if block.get("type") == "head":
|
|
156
171
|
return 1
|
|
157
|
-
if block.get(
|
|
172
|
+
if block.get("type") == "body":
|
|
158
173
|
return 2
|
|
159
|
-
return max(1, len(block.get(
|
|
174
|
+
return max(1, len(block.get("items") or [])) # bullets
|
|
160
175
|
|
|
161
176
|
|
|
162
177
|
def requested_rows(block):
|
|
163
178
|
"""클램프 이전의 요청 행 수. 클램프 발동 판정은 반드시 이 값으로 한다."""
|
|
164
|
-
rows = block.get(
|
|
179
|
+
rows = block.get("rows")
|
|
165
180
|
return default_rows(block) if rows is None else rows
|
|
166
181
|
|
|
167
182
|
|
|
@@ -179,7 +194,7 @@ def bullet_spans(n, count):
|
|
|
179
194
|
for i in range(count):
|
|
180
195
|
start = min(i, n - 1)
|
|
181
196
|
end = n - 1 if i == count - 1 else min(i + 1, n - 1) - 1
|
|
182
|
-
spans.append({
|
|
197
|
+
spans.append({"start": start, "rows": max(1, end - start + 1)})
|
|
183
198
|
return spans
|
|
184
199
|
|
|
185
200
|
|
|
@@ -220,81 +235,111 @@ def show(value):
|
|
|
220
235
|
|
|
221
236
|
|
|
222
237
|
def orientation_of(deck, page):
|
|
223
|
-
meta = deck.get(
|
|
224
|
-
return page.get(
|
|
238
|
+
meta = deck.get("meta") or {}
|
|
239
|
+
return page.get("orientation") or meta.get("orientation") or "portrait"
|
|
225
240
|
|
|
226
241
|
|
|
227
242
|
def validate_deck(deck):
|
|
228
243
|
if not is_plain_object(deck):
|
|
229
|
-
raise UsageError(
|
|
230
|
-
meta = {} if deck.get(
|
|
244
|
+
raise UsageError("deck JSON 최상위는 객체여야 한다.")
|
|
245
|
+
meta = {} if deck.get("meta") is None else deck.get("meta")
|
|
231
246
|
if not is_plain_object(meta):
|
|
232
|
-
raise UsageError(
|
|
247
|
+
raise UsageError("meta 는 객체여야 한다.")
|
|
233
248
|
|
|
234
|
-
if meta.get(
|
|
235
|
-
m = as_integer(meta.get(
|
|
249
|
+
if meta.get("marginMm") is not None:
|
|
250
|
+
m = as_integer(meta.get("marginMm"))
|
|
236
251
|
if m is None or m < MARGIN_MM_MIN or m > MARGIN_MM_MAX:
|
|
237
252
|
raise UsageError(
|
|
238
|
-
|
|
239
|
-
MARGIN_MM_MIN, MARGIN_MM_MAX, show(meta.get(
|
|
240
|
-
|
|
241
|
-
|
|
253
|
+
"meta.marginMm 은 {0} 이상 {1} 이하의 정수여야 한다 (받은 값: {2}).".format(
|
|
254
|
+
MARGIN_MM_MIN, MARGIN_MM_MAX, show(meta.get("marginMm"))
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
if meta.get("bodyPt") is not None:
|
|
258
|
+
pt = as_number(meta.get("bodyPt"))
|
|
242
259
|
if pt is None or pt <= 0 or pt > 400:
|
|
243
|
-
raise UsageError(
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
260
|
+
raise UsageError(
|
|
261
|
+
"meta.bodyPt 는 0 초과 400 이하의 수여야 한다 (받은 값: {0}).".format(
|
|
262
|
+
show(meta.get("bodyPt"))
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
if meta.get("title") is not None and not isinstance(meta.get("title"), str):
|
|
266
|
+
raise UsageError("meta.title 은 문자열이어야 한다.")
|
|
267
|
+
if meta.get("font") is not None and not isinstance(meta.get("font"), str):
|
|
268
|
+
raise UsageError("meta.font 는 문자열이어야 한다.")
|
|
269
|
+
if (
|
|
270
|
+
meta.get("orientation") is not None
|
|
271
|
+
and meta.get("orientation") not in ORIENTATIONS
|
|
272
|
+
):
|
|
273
|
+
raise UsageError(
|
|
274
|
+
"meta.orientation 은 {0} 여야 한다.".format(" 또는 ".join(ORIENTATIONS))
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
pages = deck.get("pages")
|
|
253
278
|
if not isinstance(pages, list) or not pages:
|
|
254
|
-
raise UsageError(
|
|
279
|
+
raise UsageError("pages 는 비어 있지 않은 배열이어야 한다.")
|
|
255
280
|
|
|
256
281
|
for pi, page in enumerate(pages):
|
|
257
|
-
at =
|
|
282
|
+
at = "pages[{0}]".format(pi)
|
|
258
283
|
if not is_plain_object(page):
|
|
259
|
-
raise UsageError(
|
|
260
|
-
if page.get(
|
|
261
|
-
raise UsageError(
|
|
262
|
-
if
|
|
263
|
-
|
|
264
|
-
|
|
284
|
+
raise UsageError("{0} 는 객체여야 한다.".format(at))
|
|
285
|
+
if page.get("title") is not None and not isinstance(page.get("title"), str):
|
|
286
|
+
raise UsageError("{0}.title 은 문자열이어야 한다.".format(at))
|
|
287
|
+
if (
|
|
288
|
+
page.get("orientation") is not None
|
|
289
|
+
and page.get("orientation") not in ORIENTATIONS
|
|
290
|
+
):
|
|
291
|
+
raise UsageError(
|
|
292
|
+
"{0}.orientation 은 {1} 여야 한다.".format(
|
|
293
|
+
at, " 또는 ".join(ORIENTATIONS)
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
blocks = [] if page.get("blocks") is None else page.get("blocks")
|
|
265
297
|
if not isinstance(blocks, list):
|
|
266
|
-
raise UsageError(
|
|
298
|
+
raise UsageError("{0}.blocks 는 배열이어야 한다.".format(at))
|
|
267
299
|
for bi, block in enumerate(blocks):
|
|
268
|
-
bat =
|
|
300
|
+
bat = "{0}.blocks[{1}]".format(at, bi)
|
|
269
301
|
if not is_plain_object(block):
|
|
270
|
-
raise UsageError(
|
|
271
|
-
if block.get(
|
|
272
|
-
raise UsageError(
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
302
|
+
raise UsageError("{0} 는 객체여야 한다.".format(bat))
|
|
303
|
+
if block.get("type") not in BLOCK_TYPES:
|
|
304
|
+
raise UsageError(
|
|
305
|
+
"{0}.type 은 {1} 중 하나여야 한다 (받은 값: {2}).".format(
|
|
306
|
+
bat, " / ".join(BLOCK_TYPES), show(block.get("type"))
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
if block.get("type") == "bullets":
|
|
310
|
+
items = block.get("items")
|
|
276
311
|
if not isinstance(items, list) or not items:
|
|
277
|
-
raise UsageError(
|
|
312
|
+
raise UsageError(
|
|
313
|
+
"{0}.items 는 비어 있지 않은 배열이어야 한다.".format(bat)
|
|
314
|
+
)
|
|
278
315
|
for ii, item in enumerate(items):
|
|
279
316
|
if not isinstance(item, str):
|
|
280
|
-
raise UsageError(
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
317
|
+
raise UsageError(
|
|
318
|
+
"{0}.items[{1}] 는 문자열이어야 한다.".format(bat, ii)
|
|
319
|
+
)
|
|
320
|
+
elif not isinstance(block.get("text"), str):
|
|
321
|
+
raise UsageError("{0}.text 는 문자열이어야 한다.".format(bat))
|
|
322
|
+
if block.get("rows") is not None:
|
|
323
|
+
rows = as_integer(block.get("rows"))
|
|
285
324
|
if rows is None or rows < 1:
|
|
286
|
-
raise UsageError(
|
|
287
|
-
|
|
325
|
+
raise UsageError(
|
|
326
|
+
"{0}.rows 는 1 이상의 정수여야 한다 (받은 값: {1}).".format(
|
|
327
|
+
bat, show(block.get("rows"))
|
|
328
|
+
)
|
|
329
|
+
)
|
|
288
330
|
|
|
289
331
|
# 방향 혼합은 파일 형식이 허용하지 않는다 — 하나의 pptx 는 하나의 sldSz 만 갖는다.
|
|
290
332
|
seen = set(orientation_of(deck, p) for p in pages)
|
|
291
333
|
if len(seen) > 1:
|
|
292
334
|
raise UsageError(
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
335
|
+
"한 pptx 는 세로와 가로를 함께 담을 수 없다(슬라이드 크기가 파일당 하나다). "
|
|
336
|
+
"방향별로 파일을 나누어 각각 빌드해라 — split the deck into separate files, "
|
|
337
|
+
"one per orientation."
|
|
338
|
+
)
|
|
339
|
+
if "landscape" in seen:
|
|
340
|
+
raise UsageError(
|
|
341
|
+
"이 스킬은 A4 세로(210x297mm) 전용이다. 가로 덱은 대상이 아니다."
|
|
342
|
+
)
|
|
298
343
|
return True
|
|
299
344
|
|
|
300
345
|
|
|
@@ -306,197 +351,275 @@ def layout(deck):
|
|
|
306
351
|
pageIndex·blockIndex 가 있어야 검증기가 (슬라이드, 도형) 을 (페이지, 블록) 으로 되돌릴 수 있다.
|
|
307
352
|
"""
|
|
308
353
|
validate_deck(deck)
|
|
309
|
-
meta = deck.get(
|
|
310
|
-
margin_mm =
|
|
354
|
+
meta = deck.get("meta") or {}
|
|
355
|
+
margin_mm = (
|
|
356
|
+
DEFAULT_MARGIN_MM
|
|
357
|
+
if meta.get("marginMm") is None
|
|
358
|
+
else as_integer(meta.get("marginMm"))
|
|
359
|
+
)
|
|
311
360
|
grid = grid_for(margin_mm)
|
|
312
|
-
body_pt =
|
|
313
|
-
|
|
361
|
+
body_pt = (
|
|
362
|
+
DEFAULT_BODY_PT if meta.get("bodyPt") is None else as_number(meta.get("bodyPt"))
|
|
363
|
+
)
|
|
364
|
+
pt = {
|
|
365
|
+
"title": body_pt + 6,
|
|
366
|
+
"head": body_pt + 2,
|
|
367
|
+
"body": body_pt,
|
|
368
|
+
"bullets": body_pt,
|
|
369
|
+
}
|
|
314
370
|
full_cx = col_cx(grid, COLS)
|
|
315
371
|
slides = []
|
|
316
372
|
warnings = []
|
|
317
373
|
|
|
318
|
-
for page_index, page in enumerate(deck[
|
|
319
|
-
title =
|
|
320
|
-
state = {
|
|
374
|
+
for page_index, page in enumerate(deck["pages"]):
|
|
375
|
+
title = "" if page.get("title") is None else page.get("title")
|
|
376
|
+
state = {"slide": None, "used": 0, "part": 0}
|
|
321
377
|
|
|
322
378
|
def open_slide():
|
|
323
|
-
slide = {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
379
|
+
slide = {
|
|
380
|
+
"index": len(slides),
|
|
381
|
+
"pageIndex": page_index,
|
|
382
|
+
"part": state["part"],
|
|
383
|
+
"title": title,
|
|
384
|
+
"shapes": [],
|
|
385
|
+
}
|
|
386
|
+
state["part"] += 1
|
|
387
|
+
state["used"] = 0
|
|
388
|
+
slide["shapes"].append(
|
|
389
|
+
{
|
|
390
|
+
"role": "title",
|
|
391
|
+
"pageIndex": page_index,
|
|
392
|
+
"blockIndex": None,
|
|
393
|
+
"itemIndex": None,
|
|
394
|
+
"rows": TITLE_ROWS,
|
|
395
|
+
"x": col_x(grid, 0),
|
|
396
|
+
"y": row_y(grid, 0),
|
|
397
|
+
"cx": full_cx,
|
|
398
|
+
"cy": row_cy(grid, TITLE_ROWS),
|
|
399
|
+
"text": title,
|
|
400
|
+
"pt": pt["title"],
|
|
401
|
+
"bold": True,
|
|
402
|
+
"color": COLOR_HEAD,
|
|
403
|
+
}
|
|
404
|
+
)
|
|
342
405
|
slides.append(slide)
|
|
343
|
-
state[
|
|
406
|
+
state["slide"] = slide
|
|
344
407
|
|
|
345
408
|
open_slide()
|
|
346
409
|
|
|
347
|
-
blocks = [] if page.get(
|
|
410
|
+
blocks = [] if page.get("blocks") is None else page.get("blocks")
|
|
348
411
|
for block_index, block in enumerate(blocks):
|
|
349
412
|
want = requested_rows(block)
|
|
350
413
|
n = rows_of(block)
|
|
351
414
|
if want > MAX_BLOCK_ROWS:
|
|
352
|
-
warnings.append(
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
415
|
+
warnings.append(
|
|
416
|
+
{
|
|
417
|
+
"code": "BLOCK_TOO_TALL",
|
|
418
|
+
"page": page_index,
|
|
419
|
+
"block": block_index,
|
|
420
|
+
"detail": "rows {0} -> {1}".format(want, n),
|
|
421
|
+
}
|
|
422
|
+
)
|
|
358
423
|
# 세는 규칙만: 넘치면 새 슬라이드로 넘긴다. 블록은 절대 쪼개지 않는다.
|
|
359
|
-
if state[
|
|
424
|
+
if state["used"] > 0 and state["used"] + n > MAX_BLOCK_ROWS:
|
|
360
425
|
open_slide()
|
|
361
|
-
base = TITLE_ROWS + state[
|
|
362
|
-
slide = state[
|
|
426
|
+
base = TITLE_ROWS + state["used"]
|
|
427
|
+
slide = state["slide"]
|
|
363
428
|
|
|
364
|
-
if block[
|
|
365
|
-
items = block[
|
|
429
|
+
if block["type"] == "bullets":
|
|
430
|
+
items = block["items"]
|
|
366
431
|
for item_index, span in enumerate(bullet_spans(n, len(items))):
|
|
367
|
-
slide[
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
432
|
+
slide["shapes"].append(
|
|
433
|
+
{
|
|
434
|
+
"role": "bullet",
|
|
435
|
+
"pageIndex": page_index,
|
|
436
|
+
"blockIndex": block_index,
|
|
437
|
+
"itemIndex": item_index,
|
|
438
|
+
"rows": span["rows"],
|
|
439
|
+
"x": col_x(grid, 0),
|
|
440
|
+
"y": row_y(grid, base + span["start"]),
|
|
441
|
+
"cx": full_cx,
|
|
442
|
+
"cy": row_cy(grid, span["rows"]),
|
|
443
|
+
"text": BULLET_PREFIX + items[item_index],
|
|
444
|
+
"pt": pt["bullets"],
|
|
445
|
+
"bold": False,
|
|
446
|
+
"color": COLOR_BODY,
|
|
447
|
+
}
|
|
448
|
+
)
|
|
382
449
|
else:
|
|
383
|
-
is_head = block[
|
|
384
|
-
slide[
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
450
|
+
is_head = block["type"] == "head"
|
|
451
|
+
slide["shapes"].append(
|
|
452
|
+
{
|
|
453
|
+
"role": block["type"],
|
|
454
|
+
"pageIndex": page_index,
|
|
455
|
+
"blockIndex": block_index,
|
|
456
|
+
"itemIndex": None,
|
|
457
|
+
"rows": n,
|
|
458
|
+
"x": col_x(grid, 0),
|
|
459
|
+
"y": row_y(grid, base),
|
|
460
|
+
"cx": full_cx,
|
|
461
|
+
"cy": row_cy(grid, n),
|
|
462
|
+
"text": block["text"],
|
|
463
|
+
"pt": pt["head"] if is_head else pt["body"],
|
|
464
|
+
"bold": is_head,
|
|
465
|
+
"color": COLOR_HEAD if is_head else COLOR_BODY,
|
|
466
|
+
}
|
|
467
|
+
)
|
|
468
|
+
state["used"] += n
|
|
469
|
+
|
|
470
|
+
return {"grid": grid, "bodyPt": body_pt, "slides": slides, "warnings": warnings}
|
|
402
471
|
|
|
403
472
|
|
|
404
473
|
def assert_inside_slide(laid):
|
|
405
474
|
"""기하가 실제로 여백 안에 있는지 자기 점검. 클램프가 살아 있으면 절대 발동하지 않는다."""
|
|
406
|
-
g = laid[
|
|
475
|
+
g = laid["grid"]
|
|
407
476
|
bad = []
|
|
408
|
-
for s in laid[
|
|
409
|
-
for j, sh in enumerate(s[
|
|
410
|
-
if (
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
477
|
+
for s in laid["slides"]:
|
|
478
|
+
for j, sh in enumerate(s["shapes"]):
|
|
479
|
+
if (
|
|
480
|
+
sh["x"] < g["margin"]
|
|
481
|
+
or sh["y"] < g["margin"]
|
|
482
|
+
or sh["x"] + sh["cx"] > SLIDE_W - g["margin"]
|
|
483
|
+
or sh["y"] + sh["cy"] > SLIDE_H - g["margin"]
|
|
484
|
+
):
|
|
485
|
+
bad.append(
|
|
486
|
+
"slide {0} shape {1} ({2}): x={3} y={4} cx={5} cy={6}".format(
|
|
487
|
+
s["index"], j, sh["role"], sh["x"], sh["y"], sh["cx"], sh["cy"]
|
|
488
|
+
)
|
|
489
|
+
)
|
|
415
490
|
if bad:
|
|
416
|
-
raise VerifyError(
|
|
491
|
+
raise VerifyError(
|
|
492
|
+
"도형이 여백 밖으로 나갔다 — 기하 버그다:\n " + "\n ".join(bad)
|
|
493
|
+
)
|
|
417
494
|
|
|
418
495
|
|
|
419
496
|
# ── 폰트: 이름을 고르고 파일이 있는지만 확인한다 (바이너리 파싱 0줄) ─────────
|
|
420
497
|
def norm_name(s):
|
|
421
|
-
return
|
|
498
|
+
return "".join(ch for ch in str(s).lower() if ch.isalnum() and ch.isascii())
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
# JavaScript String.prototype.trim() 이 지우는 문자. python 의 strip() 은 BOM 을 남기고 NEL 을 지워서 다르다.
|
|
502
|
+
JS_TRIM = (
|
|
503
|
+
" \t\n\v\f\r\xa0\u1680"
|
|
504
|
+
+ "".join(chr(c) for c in range(0x2000, 0x200B))
|
|
505
|
+
+ "\u2028\u2029\u202f\u205f\u3000\ufeff"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def sanitize_font_name(s):
|
|
510
|
+
"""build_a4p.js 의 sanitizeFontName 과 같은 정리. 두 빌더가 같은 이름을 적게 한다."""
|
|
511
|
+
if not isinstance(s, str):
|
|
512
|
+
return ""
|
|
513
|
+
return re.sub(r"[\x00-\x1f\x7f]", "", re.sub(r'["\'<>&]', "", s)).strip(JS_TRIM)
|
|
422
514
|
|
|
423
515
|
|
|
424
516
|
def esc_fc(s):
|
|
425
517
|
out = []
|
|
426
518
|
for ch in str(s):
|
|
427
|
-
if ch in
|
|
428
|
-
out.append(
|
|
519
|
+
if ch in "-:,\\":
|
|
520
|
+
out.append("\\")
|
|
429
521
|
out.append(ch)
|
|
430
|
-
return
|
|
522
|
+
return "".join(out)
|
|
431
523
|
|
|
432
524
|
|
|
433
|
-
_FC_STATE = {
|
|
525
|
+
_FC_STATE = {"available": None}
|
|
434
526
|
|
|
435
527
|
|
|
436
528
|
def fc_match(pattern):
|
|
437
529
|
"""fc-match 로 이름 하나를 파일 + family 로 해석한다. 없으면 None (한 번 확인하면 기억한다)."""
|
|
438
|
-
if _FC_STATE[
|
|
530
|
+
if _FC_STATE["available"] is False:
|
|
439
531
|
return None
|
|
440
532
|
try:
|
|
441
533
|
res = subprocess.run(
|
|
442
|
-
[
|
|
443
|
-
stdout=subprocess.PIPE,
|
|
444
|
-
|
|
534
|
+
["fc-match", "--format=%{file}|%{family}", pattern],
|
|
535
|
+
stdout=subprocess.PIPE,
|
|
536
|
+
stderr=subprocess.PIPE,
|
|
537
|
+
universal_newlines=True,
|
|
538
|
+
timeout=5,
|
|
539
|
+
)
|
|
445
540
|
except FileNotFoundError:
|
|
446
|
-
_FC_STATE[
|
|
541
|
+
_FC_STATE["available"] = False
|
|
447
542
|
return None
|
|
448
543
|
except (OSError, subprocess.SubprocessError):
|
|
449
544
|
return None
|
|
450
545
|
if res.returncode != 0 or not res.stdout:
|
|
451
546
|
return None
|
|
452
|
-
_FC_STATE[
|
|
453
|
-
cut = res.stdout.find(
|
|
547
|
+
_FC_STATE["available"] = True
|
|
548
|
+
cut = res.stdout.find("|")
|
|
454
549
|
if cut < 0:
|
|
455
550
|
return None
|
|
456
551
|
file_path = res.stdout[:cut].strip()
|
|
457
|
-
families = [f.strip() for f in res.stdout[cut + 1:].split(
|
|
552
|
+
families = [f.strip() for f in res.stdout[cut + 1 :].split(",") if f.strip()]
|
|
458
553
|
if not file_path:
|
|
459
554
|
return None
|
|
460
|
-
return {
|
|
555
|
+
return {"file": file_path, "families": families}
|
|
461
556
|
|
|
462
557
|
|
|
463
558
|
def font_search_dirs():
|
|
464
|
-
home = os.path.expanduser(
|
|
465
|
-
if sys.platform ==
|
|
466
|
-
return [
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
559
|
+
home = os.path.expanduser("~")
|
|
560
|
+
if sys.platform == "darwin":
|
|
561
|
+
return [
|
|
562
|
+
"/System/Library/Fonts",
|
|
563
|
+
"/Library/Fonts",
|
|
564
|
+
os.path.join(home, "Library", "Fonts"),
|
|
565
|
+
]
|
|
566
|
+
if sys.platform.startswith("win"):
|
|
567
|
+
dirs = [os.path.join(os.environ.get("WINDIR") or "C:\\Windows", "Fonts")]
|
|
568
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
470
569
|
if local:
|
|
471
|
-
dirs.append(os.path.join(local,
|
|
570
|
+
dirs.append(os.path.join(local, "Microsoft", "Windows", "Fonts"))
|
|
472
571
|
return dirs
|
|
473
572
|
return [
|
|
474
|
-
|
|
475
|
-
|
|
573
|
+
"/usr/share/fonts",
|
|
574
|
+
"/usr/local/share/fonts",
|
|
575
|
+
os.path.join(home, ".local", "share", "fonts"),
|
|
576
|
+
os.path.join(home, ".fonts"),
|
|
476
577
|
]
|
|
477
578
|
|
|
478
579
|
|
|
580
|
+
def font_weight_rank(flat):
|
|
581
|
+
"""같은 패밀리의 Bold 나 Black 이 먼저 걸리면 검증기가 다른 굵기의 메트릭으로 폭을 잰다. Regular 를 우선한다."""
|
|
582
|
+
if "regular" in flat or "normal" in flat:
|
|
583
|
+
return 0
|
|
584
|
+
if re.search(
|
|
585
|
+
r"bold|black|light|thin|medium|semi|extra|heavy|demi|italic|oblique", flat
|
|
586
|
+
):
|
|
587
|
+
return 2
|
|
588
|
+
return 1
|
|
589
|
+
|
|
590
|
+
|
|
479
591
|
def glob_font_file(name):
|
|
480
|
-
"""fc-match 가 없는 환경용 파일명 탐색. 깊이·개수 예산을 둬서 병적인 트리에서도 끝난다."""
|
|
481
|
-
hints = FONT_FILE_HINTS.get(str(name).lower(), (norm_name(name),))
|
|
592
|
+
"""fc-match 가 없는 환경용 파일명 탐색. 깊이·개수 예산을 둬서 병적인 트리에서도 끝난다. 순위는 결정론적이다."""
|
|
593
|
+
hints = [h for h in FONT_FILE_HINTS.get(str(name).lower(), (norm_name(name),)) if h]
|
|
594
|
+
if not hints:
|
|
595
|
+
return None
|
|
482
596
|
budget = [20000]
|
|
597
|
+
found = []
|
|
483
598
|
|
|
484
599
|
def walk(directory, depth):
|
|
485
600
|
if depth > 6 or budget[0] <= 0:
|
|
486
|
-
return
|
|
601
|
+
return
|
|
487
602
|
try:
|
|
488
|
-
entries =
|
|
603
|
+
entries = sorted(
|
|
604
|
+
os.scandir(directory), key=lambda e: e.name
|
|
605
|
+
) # node 처럼 이름순
|
|
489
606
|
except OSError:
|
|
490
|
-
return
|
|
607
|
+
return
|
|
491
608
|
subdirs = []
|
|
492
609
|
for ent in entries:
|
|
493
610
|
budget[0] -= 1
|
|
494
611
|
if budget[0] <= 0:
|
|
495
|
-
return
|
|
612
|
+
return
|
|
496
613
|
full = os.path.join(directory, ent.name)
|
|
497
614
|
try:
|
|
498
|
-
|
|
499
|
-
|
|
615
|
+
# node 의 Dirent 처럼 링크 폴더는 따라가지 않는다. Windows junction 은 python 3.12 전까지
|
|
616
|
+
# 링크로 보지 않으므로 재분석 지점(0x400)인지 따로 본다. node 는 재분석 지점을 링크로 본다
|
|
617
|
+
if ent.is_dir(follow_symlinks=False):
|
|
618
|
+
attrs = getattr(
|
|
619
|
+
ent.stat(follow_symlinks=False), "st_file_attributes", 0
|
|
620
|
+
)
|
|
621
|
+
if not attrs & 0x400:
|
|
622
|
+
subdirs.append(full)
|
|
500
623
|
continue
|
|
501
624
|
except OSError:
|
|
502
625
|
continue
|
|
@@ -505,20 +628,19 @@ def glob_font_file(name):
|
|
|
505
628
|
continue
|
|
506
629
|
flat = norm_name(stem)
|
|
507
630
|
if any(h in flat for h in hints):
|
|
508
|
-
|
|
631
|
+
found.append((font_weight_rank(flat), len(ent.name), full))
|
|
509
632
|
for sub in subdirs:
|
|
510
|
-
|
|
511
|
-
if hit:
|
|
512
|
-
return hit
|
|
513
|
-
return None
|
|
633
|
+
walk(sub, depth + 1)
|
|
514
634
|
|
|
515
635
|
for directory in font_search_dirs():
|
|
516
636
|
if not os.path.exists(directory):
|
|
517
637
|
continue
|
|
518
|
-
|
|
519
|
-
if
|
|
520
|
-
|
|
521
|
-
|
|
638
|
+
walk(directory, 0)
|
|
639
|
+
if found:
|
|
640
|
+
break # 검색 경로는 우선순위 순서다. 앞선 경로에서 찾으면 거기서 고른다
|
|
641
|
+
if not found:
|
|
642
|
+
return None
|
|
643
|
+
return sorted(found)[0][2]
|
|
522
644
|
|
|
523
645
|
|
|
524
646
|
def find_font_file(name):
|
|
@@ -528,14 +650,26 @@ def find_font_file(name):
|
|
|
528
650
|
돌려받은 family 가 요청한 이름과 실제로 맞는지 대조해야 한다. 안 그러면 이름이 거짓말이 된다.
|
|
529
651
|
"""
|
|
530
652
|
want = norm_name(name)
|
|
653
|
+
if not want:
|
|
654
|
+
# ASCII 글자가 없는 이름(예: 나눔고딕)은 정리한 형태로 대조할 수 없다. 빈 문자열은 어떤 family 에나 들어 있다.
|
|
655
|
+
# fc-match 가 돌려준 family 원문과 정확히 같을 때만 찾은 것으로 본다. 파일명 탐색은 하지 않는다.
|
|
656
|
+
exact = str(name).strip().lower()
|
|
657
|
+
hit = fc_match(esc_fc(name))
|
|
658
|
+
if (
|
|
659
|
+
hit
|
|
660
|
+
and any(f.lower() == exact for f in hit["families"])
|
|
661
|
+
and os.path.exists(hit["file"])
|
|
662
|
+
):
|
|
663
|
+
return hit["file"]
|
|
664
|
+
return None
|
|
665
|
+
# 한 방향(family 가 요청 이름을 품는가)만 본다. 반대 방향까지 허용하면
|
|
666
|
+
# "Noto Sans CJK KR" 을 요청했는데 "Noto Sans" 가 통과해 한글 없는 폰트에 그 이름이 붙는다.
|
|
531
667
|
hit = fc_match(esc_fc(name))
|
|
532
|
-
if hit
|
|
533
|
-
for
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
return hit['file']
|
|
538
|
-
break
|
|
668
|
+
if hit and any(
|
|
669
|
+
norm_name(f) == want or want in norm_name(f) for f in hit["families"]
|
|
670
|
+
):
|
|
671
|
+
if os.path.exists(hit["file"]):
|
|
672
|
+
return hit["file"]
|
|
539
673
|
globbed = glob_font_file(name)
|
|
540
674
|
if globbed and os.path.exists(globbed):
|
|
541
675
|
return globbed
|
|
@@ -550,32 +684,54 @@ def resolve_font(font_arg, meta_font):
|
|
|
550
684
|
"""
|
|
551
685
|
warnings = []
|
|
552
686
|
candidates = [
|
|
553
|
-
{
|
|
554
|
-
{
|
|
555
|
-
|
|
687
|
+
{"name": sanitize_font_name(font_arg), "from": "--font"},
|
|
688
|
+
{
|
|
689
|
+
"name": sanitize_font_name(os.environ.get("BANKER_PPTX_FONT")),
|
|
690
|
+
"from": "BANKER_PPTX_FONT",
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
"name": "" if meta_font == "auto" else sanitize_font_name(meta_font),
|
|
694
|
+
"from": "meta.font",
|
|
695
|
+
},
|
|
556
696
|
]
|
|
557
|
-
explicit = [c for c in candidates if
|
|
697
|
+
explicit = [c for c in candidates if c["name"] != ""]
|
|
558
698
|
|
|
559
699
|
for c in explicit:
|
|
560
|
-
name = c[
|
|
700
|
+
name = c["name"]
|
|
561
701
|
file_path = find_font_file(name)
|
|
562
702
|
if file_path:
|
|
563
|
-
return {
|
|
564
|
-
|
|
703
|
+
return {
|
|
704
|
+
"name": name,
|
|
705
|
+
"file": file_path,
|
|
706
|
+
"from": c["from"],
|
|
707
|
+
"warnings": warnings,
|
|
708
|
+
}
|
|
709
|
+
warnings.append({"code": "FONT_NOT_FOUND", "from": c["from"], "detail": name})
|
|
565
710
|
|
|
566
711
|
for name in FONT_CHAIN:
|
|
567
712
|
file_path = find_font_file(name)
|
|
568
713
|
if file_path:
|
|
569
|
-
return {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
714
|
+
return {
|
|
715
|
+
"name": name,
|
|
716
|
+
"file": file_path,
|
|
717
|
+
"from": "chain",
|
|
718
|
+
"warnings": warnings,
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
sys_hit = fc_match("sans-serif:lang=ko")
|
|
722
|
+
if sys_hit and sys_hit["families"] and os.path.exists(sys_hit["file"]):
|
|
723
|
+
return {
|
|
724
|
+
"name": sys_hit["families"][0],
|
|
725
|
+
"file": sys_hit["file"],
|
|
726
|
+
"from": "system",
|
|
727
|
+
"warnings": warnings,
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
fallback = explicit[0]["name"] if explicit else FONT_CHAIN[0]
|
|
731
|
+
warnings.append(
|
|
732
|
+
{"code": "FONT_FILE_UNRESOLVED", "from": "chain", "detail": fallback}
|
|
733
|
+
)
|
|
734
|
+
return {"name": fallback, "file": None, "from": "unresolved", "warnings": warnings}
|
|
579
735
|
|
|
580
736
|
|
|
581
737
|
# ── python-pptx 해석 (설치는 하지 않는다) ───────────────────────────────────
|
|
@@ -594,31 +750,40 @@ class Pptx(object):
|
|
|
594
750
|
|
|
595
751
|
def pptx_missing_message(reason):
|
|
596
752
|
"""자동 설치는 하지 않는다. 사람이 읽고 스스로 고칠 수 있는 안내만 낸다(자식 프로세스 0개)."""
|
|
597
|
-
return
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
753
|
+
return "\n".join(
|
|
754
|
+
[
|
|
755
|
+
"python-pptx 를 찾지 못했다. 이 스킬은 의존성을 자동 설치하지 않는다.",
|
|
756
|
+
"",
|
|
757
|
+
" 설치: python -m pip install python-pptx",
|
|
758
|
+
" (전역 설치가 막힌 환경이면 --target <dir> 로 받고 PYTHONPATH 에 그 경로를 넣는다)",
|
|
759
|
+
" 안내: docs-setup 스킬이 이 스킬의 런타임 준비 절차를 담고 있다 (/banker:docs-setup).",
|
|
760
|
+
" 대안: node 를 쓸 수 있으면 build_a4p.js (pptxgenjs) 로 같은 deck JSON 을 빌드한다.",
|
|
761
|
+
" 검증기 verify_a4p.mjs 는 node 전용이라 검증 경로는 어느 쪽이든 node 를 요구한다.",
|
|
762
|
+
"",
|
|
763
|
+
" 인터프리터: {0} (python {1}.{2}.{3})".format(
|
|
764
|
+
sys.executable or "(알 수 없음)", *sys.version_info[:3]
|
|
765
|
+
),
|
|
766
|
+
" sys.path: {0}".format(
|
|
767
|
+
", ".join(p for p in sys.path if p) or "(비어 있음)"
|
|
768
|
+
),
|
|
769
|
+
" 원인: {0}".format(reason),
|
|
770
|
+
]
|
|
771
|
+
)
|
|
611
772
|
|
|
612
773
|
|
|
613
774
|
def load_pptx():
|
|
614
775
|
if sys.version_info < MIN_PYTHON:
|
|
615
776
|
raise EnvError(
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
MIN_PYTHON[0],
|
|
621
|
-
|
|
777
|
+
"python {0}.{1} 이상이 필요하다 (실행 중인 인터프리터: {2} — python {3}.{4}.{5}).\n"
|
|
778
|
+
" 이 파일은 셰방에 의존하지 않는다. 쓸 인터프리터를 직접 지정해 부른다:\n"
|
|
779
|
+
" /path/to/python{0}.{1}+ build_a4p.py --in deck.json --out out.pptx\n"
|
|
780
|
+
" 안내: docs-setup 스킬 (/banker:docs-setup).".format(
|
|
781
|
+
MIN_PYTHON[0],
|
|
782
|
+
MIN_PYTHON[1],
|
|
783
|
+
sys.executable or "(알 수 없음)",
|
|
784
|
+
*sys.version_info[:3],
|
|
785
|
+
)
|
|
786
|
+
)
|
|
622
787
|
try:
|
|
623
788
|
from pptx import Presentation
|
|
624
789
|
from pptx.util import Emu, Pt
|
|
@@ -638,10 +803,12 @@ def strip_sldsz_type(prs, qn):
|
|
|
638
803
|
선언 쪽을 믿는 뷰어·변환기가 있다. OOXML 에서 이 속성의 기본값이 custom 이므로
|
|
639
804
|
지우는 것이 곧 custom 선언이다.
|
|
640
805
|
"""
|
|
641
|
-
sldsz = prs._element.find(qn(
|
|
806
|
+
sldsz = prs._element.find(qn("p:sldSz"))
|
|
642
807
|
if sldsz is None:
|
|
643
|
-
raise VerifyError(
|
|
644
|
-
|
|
808
|
+
raise VerifyError(
|
|
809
|
+
"p:sldSz 를 찾지 못했다 — python-pptx 기본 템플릿이 예상과 다르다."
|
|
810
|
+
)
|
|
811
|
+
sldsz.attrib.pop("type", None)
|
|
645
812
|
return sldsz
|
|
646
813
|
|
|
647
814
|
|
|
@@ -654,15 +821,15 @@ def fix_body_pr(tf, mod):
|
|
|
654
821
|
도형 크기를 바꾼다 — 저장된 a:ext 는 맞는데 화면에서 INV-G 가 깨지는 상태다.
|
|
655
822
|
속성을 넣는 순서가 곧 XML 의 속성 순서다. 위 문자열과 같은 순서로 넣는다.
|
|
656
823
|
"""
|
|
657
|
-
tf.word_wrap = True
|
|
824
|
+
tf.word_wrap = True # wrap="square"
|
|
658
825
|
tf.margin_left = mod.Emu(0)
|
|
659
826
|
tf.margin_top = mod.Emu(0)
|
|
660
827
|
tf.margin_right = mod.Emu(0)
|
|
661
828
|
tf.margin_bottom = mod.Emu(0)
|
|
662
829
|
body_pr = tf._txBody.bodyPr
|
|
663
|
-
body_pr.set(
|
|
830
|
+
body_pr.set("rtlCol", "0")
|
|
664
831
|
tf.vertical_anchor = mod.MSO_ANCHOR.TOP # anchor="t"
|
|
665
|
-
for tag in (
|
|
832
|
+
for tag in ("a:spAutoFit", "a:normAutofit", "a:noAutofit"):
|
|
666
833
|
el = body_pr.find(mod.qn(tag))
|
|
667
834
|
if el is not None:
|
|
668
835
|
body_pr.remove(el)
|
|
@@ -672,12 +839,12 @@ def style_paragraph(paragraph, mod):
|
|
|
672
839
|
"""문단 속성도 node 산출물과 맞춘다: 왼쪽 정렬 · 들여쓰기 0 · 줄간 1.2 · 불릿 글리프 없음."""
|
|
673
840
|
paragraph.alignment = mod.PP_ALIGN.LEFT
|
|
674
841
|
p_pr = paragraph._p.get_or_add_pPr()
|
|
675
|
-
p_pr.set(
|
|
676
|
-
p_pr.set(
|
|
842
|
+
p_pr.set("indent", "0")
|
|
843
|
+
p_pr.set("marL", "0")
|
|
677
844
|
paragraph.line_spacing = LINE_SPACING
|
|
678
845
|
# 불릿 글리프는 항목 텍스트 앞의 '• ' 로 직접 그린다. 목록 자동 글리프가 붙으면 두 번 찍힌다.
|
|
679
|
-
if p_pr.find(mod.qn(
|
|
680
|
-
p_pr.append(p_pr.makeelement(mod.qn(
|
|
846
|
+
if p_pr.find(mod.qn("a:buNone")) is None:
|
|
847
|
+
p_pr.append(p_pr.makeelement(mod.qn("a:buNone"), {}))
|
|
681
848
|
|
|
682
849
|
|
|
683
850
|
def set_run(run, text, pt, bold, color, typeface, mod):
|
|
@@ -694,12 +861,12 @@ def set_run(run, text, pt, bold, color, typeface, mod):
|
|
|
694
861
|
font.color.rgb = mod.RGBColor.from_string(color)
|
|
695
862
|
font.name = typeface
|
|
696
863
|
r_pr = run._r.get_or_add_rPr()
|
|
697
|
-
for tag in (
|
|
864
|
+
for tag in ("a:latin", "a:ea", "a:cs"):
|
|
698
865
|
el = r_pr.find(mod.qn(tag))
|
|
699
866
|
if el is None:
|
|
700
867
|
el = r_pr.makeelement(mod.qn(tag), {})
|
|
701
868
|
r_pr.append(el)
|
|
702
|
-
el.set(
|
|
869
|
+
el.set("typeface", typeface)
|
|
703
870
|
|
|
704
871
|
|
|
705
872
|
# OPC core property `title` 의 길이 상한. OOXML 규격이 아니라 python-pptx 가 스스로 거는 제한인데,
|
|
@@ -721,38 +888,53 @@ def build(laid, out_path, font, deck, mod, warnings=None):
|
|
|
721
888
|
prs.slide_height = mod.Emu(SLIDE_H)
|
|
722
889
|
strip_sldsz_type(prs, mod.qn)
|
|
723
890
|
|
|
724
|
-
meta = deck.get(
|
|
725
|
-
if meta.get(
|
|
726
|
-
value, truncated = clamp_core_title(meta[
|
|
891
|
+
meta = deck.get("meta") or {}
|
|
892
|
+
if meta.get("title"):
|
|
893
|
+
value, truncated = clamp_core_title(meta["title"])
|
|
727
894
|
if truncated and warnings is not None:
|
|
728
|
-
warnings.append(
|
|
729
|
-
|
|
895
|
+
warnings.append(
|
|
896
|
+
{
|
|
897
|
+
"code": "TITLE_TRUNCATED",
|
|
898
|
+
"from": "meta.title",
|
|
899
|
+
"detail": "{0} -> {1}".format(len(meta["title"]), CORE_TITLE_MAX),
|
|
900
|
+
}
|
|
901
|
+
)
|
|
730
902
|
# 클램프가 막아 주지만, 라이브러리가 거는 다른 상한이 남아 있을 수 있다.
|
|
731
903
|
# 원시 트레이스백이 새면 exit code 계약(입력 문제 = 3)이 깨지므로 UsageError 로 들여보낸다.
|
|
732
904
|
try:
|
|
733
905
|
prs.core_properties.title = value
|
|
734
906
|
except ValueError as err:
|
|
735
|
-
raise UsageError(
|
|
907
|
+
raise UsageError(
|
|
908
|
+
"meta.title 을 core property 로 쓸 수 없다: {0}".format(err)
|
|
909
|
+
)
|
|
736
910
|
|
|
737
911
|
# 빈 레이아웃만 쓴다. 기본 템플릿의 자리표시자는 4:3 좌표를 들고 있어서
|
|
738
912
|
# A4 로 바꾼 슬라이드에 얹으면 좌표가 규격 밖으로 나간다.
|
|
739
913
|
blank = prs.slide_layouts[6]
|
|
740
914
|
|
|
741
|
-
for s in laid[
|
|
915
|
+
for s in laid["slides"]:
|
|
742
916
|
slide = prs.slides.add_slide(blank)
|
|
743
|
-
for sh in s[
|
|
917
|
+
for sh in s["shapes"]:
|
|
744
918
|
box = slide.shapes.add_textbox(
|
|
745
|
-
mod.Emu(sh[
|
|
919
|
+
mod.Emu(sh["x"]), mod.Emu(sh["y"]), mod.Emu(sh["cx"]), mod.Emu(sh["cy"])
|
|
920
|
+
)
|
|
746
921
|
tf = box.text_frame
|
|
747
922
|
fix_body_pr(tf, mod)
|
|
748
923
|
# 개행은 문단으로 편다 — node 쪽(pptxgenjs)이 '\n' 마다 <a:p> 를 하나씩 내고,
|
|
749
924
|
# 빈 줄에는 run 을 만들지 않는다. 그 모양을 그대로 따른다.
|
|
750
|
-
for i, line in enumerate(str(sh[
|
|
925
|
+
for i, line in enumerate(str(sh["text"]).split("\n")):
|
|
751
926
|
paragraph = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
|
752
927
|
style_paragraph(paragraph, mod)
|
|
753
|
-
if line !=
|
|
754
|
-
set_run(
|
|
755
|
-
|
|
928
|
+
if line != "":
|
|
929
|
+
set_run(
|
|
930
|
+
paragraph.add_run(),
|
|
931
|
+
line,
|
|
932
|
+
sh["pt"],
|
|
933
|
+
sh["bold"],
|
|
934
|
+
sh["color"],
|
|
935
|
+
font["name"],
|
|
936
|
+
mod,
|
|
937
|
+
)
|
|
756
938
|
|
|
757
939
|
prs.save(out_path)
|
|
758
940
|
return out_path
|
|
@@ -760,35 +942,48 @@ def build(laid, out_path, font, deck, mod, warnings=None):
|
|
|
760
942
|
|
|
761
943
|
# ── 출력 ────────────────────────────────────────────────────────────────────
|
|
762
944
|
def human_report(laid, font, out_path, warnings):
|
|
763
|
-
g = laid[
|
|
764
|
-
shapes = sum(len(s[
|
|
765
|
-
pages = len(set(s[
|
|
945
|
+
g = laid["grid"]
|
|
946
|
+
shapes = sum(len(s["shapes"]) for s in laid["slides"])
|
|
947
|
+
pages = len(set(s["pageIndex"] for s in laid["slides"]))
|
|
766
948
|
lines = [
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
949
|
+
"vertical-pptx (python 빌더)",
|
|
950
|
+
" 용지 A4 세로 210x297mm · sldSz {0} x {1} EMU".format(SLIDE_W, SLIDE_H),
|
|
951
|
+
" 여백 {0}mm · 그리드 {1}열 x {2}행 · 거터 4mm".format(
|
|
952
|
+
g["marginMm"], COLS, ROWS
|
|
953
|
+
),
|
|
954
|
+
" 칸 컬럼 {0} · 행 {1} · 피치 {2} / {3} EMU".format(
|
|
955
|
+
g["colW"], g["rowH"], g["colPitch"], g["rowPitch"]
|
|
956
|
+
),
|
|
772
957
|
]
|
|
773
958
|
if font:
|
|
774
|
-
note =
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
959
|
+
note = (
|
|
960
|
+
""
|
|
961
|
+
if font["file"]
|
|
962
|
+
else " (파일 미해석 — 검증기 폭 판정은 estimate 로 내려간다)"
|
|
963
|
+
)
|
|
964
|
+
lines.append(" 폰트 {0}{1}".format(font["name"], note))
|
|
965
|
+
lines.append(" {0}".format(font["file"] or "(경로 없음)"))
|
|
966
|
+
lines.append(
|
|
967
|
+
" 본문 {0}pt · 줄간 {1}".format(fmt_num(laid["bodyPt"]), LINE_SPACING)
|
|
968
|
+
)
|
|
969
|
+
lines.append(
|
|
970
|
+
" 슬라이드 {0} (deck 페이지 {1}개)".format(len(laid["slides"]), pages)
|
|
971
|
+
)
|
|
972
|
+
lines.append(" 도형 {0}".format(shapes))
|
|
973
|
+
lines.append(" 경고 {0}".format(len(warnings)))
|
|
781
974
|
for w in warnings:
|
|
782
|
-
if w.get(
|
|
783
|
-
where =
|
|
784
|
-
elif w.get(
|
|
785
|
-
where = w[
|
|
975
|
+
if w.get("page") is not None:
|
|
976
|
+
where = "page {0} block {1}".format(w["page"], w["block"])
|
|
977
|
+
elif w.get("from") is not None:
|
|
978
|
+
where = w["from"]
|
|
786
979
|
else:
|
|
787
|
-
where =
|
|
788
|
-
lines.append(
|
|
980
|
+
where = ""
|
|
981
|
+
lines.append(
|
|
982
|
+
" {0} {1} {2}".format(w["code"], where, w.get("detail") or "").rstrip()
|
|
983
|
+
)
|
|
789
984
|
if out_path:
|
|
790
|
-
lines.append(
|
|
791
|
-
return
|
|
985
|
+
lines.append(" 출력 {0}".format(out_path))
|
|
986
|
+
return "\n".join(lines)
|
|
792
987
|
|
|
793
988
|
|
|
794
989
|
def fmt_num(value):
|
|
@@ -799,105 +994,111 @@ def fmt_num(value):
|
|
|
799
994
|
|
|
800
995
|
|
|
801
996
|
def status_json(font, slides, warnings):
|
|
802
|
-
return json.dumps(
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
997
|
+
return json.dumps(
|
|
998
|
+
{
|
|
999
|
+
"skill": "vertical-pptx",
|
|
1000
|
+
"builder": "python",
|
|
1001
|
+
"font": font["name"] if font else None,
|
|
1002
|
+
"fontFile": font["file"] if font else None,
|
|
1003
|
+
"slides": slides,
|
|
1004
|
+
"warnings": warnings,
|
|
1005
|
+
},
|
|
1006
|
+
ensure_ascii=False,
|
|
1007
|
+
separators=(",", ":"),
|
|
1008
|
+
)
|
|
810
1009
|
|
|
811
1010
|
|
|
812
1011
|
# ── CLI ─────────────────────────────────────────────────────────────────────
|
|
813
|
-
USAGE =
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
1012
|
+
USAGE = "\n".join(
|
|
1013
|
+
[
|
|
1014
|
+
"vertical-pptx / build_a4p.py — A4 세로(210x297mm) PPTX python 빌더",
|
|
1015
|
+
"",
|
|
1016
|
+
"사용법:",
|
|
1017
|
+
" python build_a4p.py --in deck.json --out out.pptx [--font <이름>]",
|
|
1018
|
+
"",
|
|
1019
|
+
"옵션:",
|
|
1020
|
+
" --in <deck.json> deck JSON 입력 (두 빌더의 유일한 계약)",
|
|
1021
|
+
" --out <경로> .pptx 출력 경로",
|
|
1022
|
+
" --font <이름> 폰트 이름을 직접 지정. 체인: --font → BANKER_PPTX_FONT → meta.font",
|
|
1023
|
+
" → Noto Sans CJK KR → Noto Sans KR → Malgun Gothic → Apple SD Gothic Neo"
|
|
1024
|
+
" → AppleGothic → NanumGothic → 시스템 한국어 sans",
|
|
1025
|
+
" -h, --help 이 도움말",
|
|
1026
|
+
"",
|
|
1027
|
+
"입력은 deck JSON 뿐이다. 마크다운 변환 플래그는 이 빌더에 없다 —",
|
|
1028
|
+
"원고를 deck JSON 으로 바꾸는 일은 node 빌더(build_a4p.js)가 단독으로 맡는다.",
|
|
1029
|
+
"이 프로젝트의 마크다운 파서는 그 한 벌뿐이고, 두 벌이 되는 순간 같은 원고가 두 개의 덱이 된다.",
|
|
1030
|
+
"",
|
|
1031
|
+
"요구 사항: python 3.9 이상 · python-pptx (자동 설치하지 않는다)",
|
|
1032
|
+
"이 파일은 셰방에 의존하지 않는다. 쓸 인터프리터를 직접 지정해 부르는 것이 안전하다.",
|
|
1033
|
+
"",
|
|
1034
|
+
"종료 코드: 0 통과 · 1 검증 실패 · 2 환경 미비(python 버전·python-pptx) · 3 사용법 오류",
|
|
1035
|
+
]
|
|
1036
|
+
)
|
|
836
1037
|
|
|
837
1038
|
|
|
838
1039
|
def parse_args(argv):
|
|
839
|
-
opts = {
|
|
1040
|
+
opts = {"in": "", "out": "", "fontArg": "", "help": False}
|
|
840
1041
|
i = 0
|
|
841
1042
|
while i < len(argv):
|
|
842
1043
|
a = argv[i]
|
|
843
1044
|
|
|
844
1045
|
def need(name):
|
|
845
|
-
if i + 1 >= len(argv) or argv[i + 1].startswith(
|
|
846
|
-
raise UsageError(
|
|
1046
|
+
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
|
|
1047
|
+
raise UsageError("{0} 에 값이 필요하다.".format(name))
|
|
847
1048
|
return argv[i + 1]
|
|
848
1049
|
|
|
849
|
-
if a ==
|
|
850
|
-
opts[
|
|
1050
|
+
if a == "--in":
|
|
1051
|
+
opts["in"] = need("--in")
|
|
851
1052
|
i += 1
|
|
852
|
-
elif a ==
|
|
853
|
-
opts[
|
|
1053
|
+
elif a == "--out":
|
|
1054
|
+
opts["out"] = need("--out")
|
|
854
1055
|
i += 1
|
|
855
|
-
elif a ==
|
|
856
|
-
opts[
|
|
1056
|
+
elif a == "--font":
|
|
1057
|
+
opts["fontArg"] = need("--font")
|
|
857
1058
|
i += 1
|
|
858
|
-
elif a in (
|
|
859
|
-
opts[
|
|
1059
|
+
elif a in ("-h", "--help"):
|
|
1060
|
+
opts["help"] = True
|
|
860
1061
|
else:
|
|
861
|
-
raise UsageError(
|
|
1062
|
+
raise UsageError("알 수 없는 인자: {0}\n\n{1}".format(a, USAGE))
|
|
862
1063
|
i += 1
|
|
863
1064
|
return opts
|
|
864
1065
|
|
|
865
1066
|
|
|
866
1067
|
def read_deck_file(path):
|
|
867
1068
|
try:
|
|
868
|
-
with open(path,
|
|
1069
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
869
1070
|
text = fh.read()
|
|
870
1071
|
except OSError as exc:
|
|
871
|
-
raise UsageError(
|
|
1072
|
+
raise UsageError("deck JSON 을 읽지 못했다: {0}".format(exc))
|
|
872
1073
|
try:
|
|
873
1074
|
return json.loads(text)
|
|
874
1075
|
except ValueError as exc:
|
|
875
|
-
raise UsageError(
|
|
1076
|
+
raise UsageError("deck JSON 파싱 실패 ({0}): {1}".format(path, exc))
|
|
876
1077
|
|
|
877
1078
|
|
|
878
1079
|
def run_main(argv):
|
|
879
1080
|
opts = parse_args(argv)
|
|
880
|
-
if opts[
|
|
881
|
-
sys.stdout.write(USAGE +
|
|
882
|
-
return EXIT_OK if opts[
|
|
883
|
-
if not opts[
|
|
884
|
-
raise UsageError(
|
|
1081
|
+
if opts["help"] or not opts["in"]:
|
|
1082
|
+
sys.stdout.write(USAGE + "\n")
|
|
1083
|
+
return EXIT_OK if opts["help"] else EXIT_USAGE
|
|
1084
|
+
if not opts["out"]:
|
|
1085
|
+
raise UsageError("--out <경로> 가 필요하다.")
|
|
885
1086
|
|
|
886
|
-
deck = read_deck_file(opts[
|
|
1087
|
+
deck = read_deck_file(opts["in"])
|
|
887
1088
|
laid = layout(deck) # validate_deck 포함 — 스키마 위반은 여기서 exit 3
|
|
888
|
-
warnings = list(laid[
|
|
1089
|
+
warnings = list(laid["warnings"])
|
|
889
1090
|
assert_inside_slide(laid)
|
|
890
1091
|
|
|
891
1092
|
# ★ python-pptx 해석을 폰트 해석보다 먼저 한다.
|
|
892
1093
|
# 환경이 미비하면 자식 프로세스(fc-match)를 하나도 띄우지 않고 exit 2 로 나간다.
|
|
893
1094
|
mod = load_pptx()
|
|
894
|
-
meta = deck.get(
|
|
895
|
-
font = resolve_font(opts[
|
|
896
|
-
warnings.extend(font[
|
|
1095
|
+
meta = deck.get("meta") or {}
|
|
1096
|
+
font = resolve_font(opts["fontArg"], meta.get("font"))
|
|
1097
|
+
warnings.extend(font["warnings"])
|
|
897
1098
|
|
|
898
|
-
out_path = build(laid, os.path.abspath(opts[
|
|
899
|
-
sys.stdout.write(human_report(laid, font, out_path, warnings) +
|
|
900
|
-
sys.stdout.write(status_json(font, len(laid[
|
|
1099
|
+
out_path = build(laid, os.path.abspath(opts["out"]), font, deck, mod, warnings)
|
|
1100
|
+
sys.stdout.write(human_report(laid, font, out_path, warnings) + "\n")
|
|
1101
|
+
sys.stdout.write(status_json(font, len(laid["slides"]), warnings) + "\n")
|
|
901
1102
|
return EXIT_OK
|
|
902
1103
|
|
|
903
1104
|
|
|
@@ -908,9 +1109,9 @@ def main(argv):
|
|
|
908
1109
|
# 환경 미비(exit 2)는 사용자가 그대로 따라 할 수 있는 안내라서 stdout 으로 낸다.
|
|
909
1110
|
# 나머지 오류는 stderr.
|
|
910
1111
|
stream = sys.stdout if err.exit_code == EXIT_ENV else sys.stderr
|
|
911
|
-
stream.write(
|
|
1112
|
+
stream.write("{0}\n".format(err))
|
|
912
1113
|
return err.exit_code
|
|
913
1114
|
|
|
914
1115
|
|
|
915
|
-
if __name__ ==
|
|
1116
|
+
if __name__ == "__main__":
|
|
916
1117
|
sys.exit(main(sys.argv[1:]))
|