@kaydash9999/banker-plugins 0.21.0 → 0.23.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.
Files changed (129) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +69 -0
  4. package/PRIVACY.md +8 -3
  5. package/README.md +78 -15
  6. package/bin/banker.js +154 -32
  7. package/bin/lib/codex-hooks.mjs +123 -20
  8. package/bin/lib/codex-names.mjs +4 -0
  9. package/bin/lib/codex-routes.mjs +204 -0
  10. package/bin/lib/command-skill.cjs +50 -0
  11. package/bin/lib/telemetry-config.mjs +2 -1
  12. package/codex/command-skills/front-qa/SKILL.md +56 -0
  13. package/codex/command-skills/setup/SKILL.md +87 -0
  14. package/codex/hooks.json +29 -0
  15. package/codex/manifest.json +18 -15
  16. package/codex/skills/graceful-pause/SKILL.md +2 -2
  17. package/codex/skills/progress/scripts/progress.mjs +44 -14
  18. package/codex/skills/teamwork/SKILL.md +137 -0
  19. package/codex/skills/teamwork/scripts/teamwork.mjs +957 -0
  20. package/codex/transform-matrix.md +5 -3
  21. package/commands/setup.md +2 -2
  22. package/hooks/obsidize-hook.mjs +75 -32
  23. package/hooks/telemetry-count-codex.mjs +303 -35
  24. package/hooks/update-fetch.mjs +2 -1
  25. package/hooks/update-notify.mjs +146 -13
  26. package/package.json +3 -1
  27. package/skills/3d-intro-build/SKILL.md +101 -20
  28. package/skills/3d-intro-build/references/NOTICE.md +9 -2
  29. package/skills/3d-intro-build/references/assemble.mjs +113 -22
  30. package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
  31. package/skills/3d-intro-build/references/clip-post.mjs +443 -0
  32. package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
  33. package/skills/3d-intro-build/references/intro-fixes.css +11 -0
  34. package/skills/3d-intro-build/references/prompts.md +51 -4
  35. package/skills/3d-intro-build/references/scrub-engine.js +1 -0
  36. package/skills/3d-intro-build/references/step-nav.css +29 -0
  37. package/skills/3d-intro-build/references/step-nav.js +277 -106
  38. package/skills/3d-intro-build/references/verify-intro.mjs +387 -32
  39. package/skills/3d-intro-build/references/verify.md +6 -0
  40. package/skills/3d-intro-build/references/video-pool.mjs +142 -12
  41. package/skills/3d-intro-setup/SKILL.md +20 -7
  42. package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
  43. package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
  44. package/skills/all-in-one/SKILL.md +125 -86
  45. package/skills/anti-drm/SKILL.md +1 -1
  46. package/skills/anti-drm/scripts/instruction-toggle.mjs +57 -4
  47. package/skills/arch-diagram/build_pptx_template.py +47 -28
  48. package/skills/audit-web-page/SKILL.md +1 -1
  49. package/skills/audit-web-page/harness.py +0 -1
  50. package/skills/lineage/lineage.py +1435 -533
  51. package/skills/motion-graphic-make/SKILL.md +1 -1
  52. package/skills/obsidizer/SKILL.md +20 -5
  53. package/skills/omc-patch/SKILL.md +1 -1
  54. package/skills/payload-mon/SKILL.md +2 -2
  55. package/skills/play-qa/SKILL.md +7 -5
  56. package/skills/progress-mon/README.md +162 -0
  57. package/skills/progress-mon/SKILL.md +165 -0
  58. package/skills/progress-mon/assets/fixture/README.md +1 -0
  59. package/skills/progress-mon/assets/fixture/arch.json +33 -0
  60. package/skills/progress-mon/assets/fixture/commits.json +7 -0
  61. package/skills/progress-mon/assets/fixture/config.json +30 -0
  62. package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
  63. package/skills/progress-mon/assets/fixture/flow.json +44 -0
  64. package/skills/progress-mon/assets/fixture/items.json +14 -0
  65. package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
  66. package/skills/progress-mon/assets/fixture/reqs.json +11 -0
  67. package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
  68. package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
  69. package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
  70. package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
  71. package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
  72. package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
  73. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
  74. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
  75. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
  76. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
  77. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
  78. package/skills/progress-mon/references/arch.md +280 -0
  79. package/skills/progress-mon/references/authoring.md +199 -0
  80. package/skills/progress-mon/references/config.md +115 -0
  81. package/skills/progress-mon/references/flow.md +75 -0
  82. package/skills/progress-mon/references/inventory.md +58 -0
  83. package/skills/progress-mon/references/items.md +74 -0
  84. package/skills/progress-mon/references/req-spec.md +74 -0
  85. package/skills/progress-mon/references/reqs.md +30 -0
  86. package/skills/progress-mon/scripts/index.js +31 -0
  87. package/skills/progress-mon/scripts/pm-check.mjs +526 -0
  88. package/skills/progress-mon/scripts/pm-config.mjs +341 -0
  89. package/skills/progress-mon/scripts/pm-data.mjs +394 -0
  90. package/skills/progress-mon/scripts/pm-html.mjs +77 -0
  91. package/skills/progress-mon/scripts/pm-memory.mjs +308 -0
  92. package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
  93. package/skills/progress-mon/scripts/pm-server.mjs +735 -0
  94. package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
  95. package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
  96. package/skills/ralph-qa/SKILL.md +105 -16
  97. package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
  98. package/skills/ralph-qa/references/payload-scan.mjs +19 -2
  99. package/skills/ralph-qa/references/run-folder.mjs +299 -0
  100. package/skills/ralph-qa/references/seat-payload.mjs +60 -13
  101. package/skills/ralph-qa/references/seat-run.mjs +159 -155
  102. package/skills/remains/SKILL.md +2 -1
  103. package/skills/remains/scripts/boxes.mjs +6 -2
  104. package/skills/rfp-author/scripts/md2docx.py +63 -34
  105. package/skills/setup-bypass-permissions/SKILL.md +2 -2
  106. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
  107. package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +66 -15
  108. package/skills/setup-mcp/SKILL.md +1 -1
  109. package/skills/setup-omc-hud/SKILL.md +2 -1
  110. package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
  111. package/skills/smart-compact/SKILL.md +1 -1
  112. package/skills/smart-compact/scripts/codex-gate.mjs +2 -1
  113. package/skills/theme-selection/SKILL.md +1 -1
  114. package/skills/tone-compact/SKILL.md +1 -1
  115. package/skills/tone-compact/scripts/instruction-toggle.mjs +57 -4
  116. package/skills/ultra-chain/SKILL.md +34 -16
  117. package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
  118. package/skills/ultra-init/SKILL.md +30 -22
  119. package/skills/ultra-ui-qa/SKILL.md +1 -1
  120. package/skills/ultraqa/NOTICE.md +34 -0
  121. package/skills/ultraqa/SKILL.md +196 -0
  122. package/skills/ultraqa/references/gate-evidence.mjs +457 -0
  123. package/skills/ultraqa/references/run-folder.mjs +299 -0
  124. package/skills/ultraqa/references/tmux-drive.md +52 -0
  125. package/skills/ultraqa/references/uqa-run.mjs +339 -0
  126. package/skills/update-banker/SKILL.md +32 -7
  127. package/skills/vertical-pptx/SKILL.md +1 -1
  128. package/skills/vertical-pptx/build_a4p.js +20 -2
  129. 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 # 144000
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 = '\u2022 '
81
- COLOR_HEAD = '1A1A1A'
82
- COLOR_BODY = '222222'
83
- BLOCK_TYPES = ('head', 'body', 'bullets')
84
- ORIENTATIONS = ('portrait', 'landscape')
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
- FONT_CHAIN = ('Noto Sans CJK KR', 'Malgun Gothic', 'Apple SD Gothic Neo', 'NanumGothic')
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
- 'noto sans cjk kr': ('notosanscjkkr', 'notosanscjk', 'notosanskr'),
96
- 'malgun gothic': ('malgun',),
97
- 'apple sd gothic neo': ('applesdgothicneo', 'applegothic'),
98
- 'nanumgothic': ('nanumgothic',),
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(('.ttf', '.ttc', '.otf', '.otc'))
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
- '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,
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['margin'] + g['colPitch'] * c
154
+ return g["margin"] + g["colPitch"] * c
140
155
 
141
156
 
142
157
  def col_cx(g, k):
143
- return g['colW'] * k + GUTTER * (k - 1)
158
+ return g["colW"] * k + GUTTER * (k - 1)
144
159
 
145
160
 
146
161
  def row_y(g, r):
147
- return g['margin'] + g['rowPitch'] * r
162
+ return g["margin"] + g["rowPitch"] * r
148
163
 
149
164
 
150
165
  def row_cy(g, n):
151
- return g['rowH'] * n + GUTTER * (n - 1)
166
+ return g["rowH"] * n + GUTTER * (n - 1)
152
167
 
153
168
 
154
169
  def default_rows(block):
155
- if block.get('type') == 'head':
170
+ if block.get("type") == "head":
156
171
  return 1
157
- if block.get('type') == 'body':
172
+ if block.get("type") == "body":
158
173
  return 2
159
- return max(1, len(block.get('items') or [])) # bullets
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('rows')
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({'start': start, 'rows': max(1, end - start + 1)})
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('meta') or {}
224
- return page.get('orientation') or meta.get('orientation') or 'portrait'
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('deck JSON 최상위는 객체여야 한다.')
230
- meta = {} if deck.get('meta') is None else deck.get('meta')
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('meta 는 객체여야 한다.')
247
+ raise UsageError("meta 는 객체여야 한다.")
233
248
 
234
- if meta.get('marginMm') is not None:
235
- m = as_integer(meta.get('marginMm'))
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
- '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'))
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('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')
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('pages 는 비어 있지 않은 배열이어야 한다.')
279
+ raise UsageError("pages 는 비어 있지 않은 배열이어야 한다.")
255
280
 
256
281
  for pi, page in enumerate(pages):
257
- at = 'pages[{0}]'.format(pi)
282
+ at = "pages[{0}]".format(pi)
258
283
  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')
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('{0}.blocks 는 배열이어야 한다.'.format(at))
298
+ raise UsageError("{0}.blocks 는 배열이어야 한다.".format(at))
267
299
  for bi, block in enumerate(blocks):
268
- bat = '{0}.blocks[{1}]'.format(at, bi)
300
+ bat = "{0}.blocks[{1}]".format(at, bi)
269
301
  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')
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('{0}.items 는 비어 있지 않은 배열이어야 한다.'.format(bat))
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('{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'))
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('{0}.rows 는 1 이상의 정수여야 한다 (받은 값: {1}).'.format(
287
- bat, show(block.get('rows'))))
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
- '한 pptx 는 세로와 가로를 함께 담을 수 없다(슬라이드 크기가 파일당 하나다). '
294
- '방향별로 파일을 나누어 각각 빌드해라 — split the deck into separate files, '
295
- 'one per orientation.')
296
- if 'landscape' in seen:
297
- raise UsageError('이 스킬은 A4 세로(210x297mm) 전용이다. 가로 덱은 대상이 아니다.')
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('meta') or {}
310
- margin_mm = DEFAULT_MARGIN_MM if meta.get('marginMm') is None else as_integer(meta.get('marginMm'))
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 = 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}
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['pages']):
319
- title = '' if page.get('title') is None else page.get('title')
320
- state = {'slide': None, 'used': 0, 'part': 0}
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 = {'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
- })
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['slide'] = slide
406
+ state["slide"] = slide
344
407
 
345
408
  open_slide()
346
409
 
347
- blocks = [] if page.get('blocks') is None else page.get('blocks')
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
- 'code': 'BLOCK_TOO_TALL',
354
- 'page': page_index,
355
- 'block': block_index,
356
- 'detail': 'rows {0} -> {1}'.format(want, n),
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['used'] > 0 and state['used'] + n > MAX_BLOCK_ROWS:
424
+ if state["used"] > 0 and state["used"] + n > MAX_BLOCK_ROWS:
360
425
  open_slide()
361
- base = TITLE_ROWS + state['used']
362
- slide = state['slide']
426
+ base = TITLE_ROWS + state["used"]
427
+ slide = state["slide"]
363
428
 
364
- if block['type'] == 'bullets':
365
- items = block['items']
429
+ if block["type"] == "bullets":
430
+ items = block["items"]
366
431
  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
- })
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['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}
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['grid']
475
+ g = laid["grid"]
407
476
  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']))
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('도형이 여백 밖으로 나갔다 — 기하 버그다:\n ' + '\n '.join(bad))
491
+ raise VerifyError(
492
+ "도형이 여백 밖으로 나갔다 — 기하 버그다:\n " + "\n ".join(bad)
493
+ )
417
494
 
418
495
 
419
496
  # ── 폰트: 이름을 고르고 파일이 있는지만 확인한다 (바이너리 파싱 0줄) ─────────
420
497
  def norm_name(s):
421
- return ''.join(ch for ch in str(s).lower() if ch.isalnum() and ch.isascii())
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 ''.join(out)
522
+ return "".join(out)
431
523
 
432
524
 
433
- _FC_STATE = {'available': None}
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['available'] is False:
530
+ if _FC_STATE["available"] is False:
439
531
  return None
440
532
  try:
441
533
  res = subprocess.run(
442
- ['fc-match', '--format=%{file}|%{family}', pattern],
443
- stdout=subprocess.PIPE, stderr=subprocess.PIPE,
444
- universal_newlines=True, timeout=5)
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['available'] = False
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['available'] = True
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(',') if f.strip()]
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 {'file': file_path, 'families': families}
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 == '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')
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, 'Microsoft', 'Windows', 'Fonts'))
570
+ dirs.append(os.path.join(local, "Microsoft", "Windows", "Fonts"))
472
571
  return dirs
473
572
  return [
474
- '/usr/share/fonts', '/usr/local/share/fonts',
475
- os.path.join(home, '.local', 'share', 'fonts'), os.path.join(home, '.fonts'),
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 None
601
+ return
487
602
  try:
488
- entries = list(os.scandir(directory))
603
+ entries = sorted(
604
+ os.scandir(directory), key=lambda e: e.name
605
+ ) # node 처럼 이름순
489
606
  except OSError:
490
- return None
607
+ return
491
608
  subdirs = []
492
609
  for ent in entries:
493
610
  budget[0] -= 1
494
611
  if budget[0] <= 0:
495
- return None
612
+ return
496
613
  full = os.path.join(directory, ent.name)
497
614
  try:
498
- if ent.is_dir():
499
- subdirs.append(full)
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
- return full
631
+ found.append((font_weight_rank(flat), len(ent.name), full))
509
632
  for sub in subdirs:
510
- hit = walk(sub, depth + 1)
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
- hit = walk(directory, 0)
519
- if hit:
520
- return hit
521
- return None
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 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
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
- {'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'},
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 isinstance(c['name'], str) and c['name'].strip() != '']
697
+ explicit = [c for c in candidates if c["name"] != ""]
558
698
 
559
699
  for c in explicit:
560
- name = c['name'].strip()
700
+ name = c["name"]
561
701
  file_path = find_font_file(name)
562
702
  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})
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 {'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}
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 '\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
- ])
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
- '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]))
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('p:sldSz'))
806
+ sldsz = prs._element.find(qn("p:sldSz"))
642
807
  if sldsz is None:
643
- raise VerifyError('p:sldSz 를 찾지 못했다 — python-pptx 기본 템플릿이 예상과 다르다.')
644
- sldsz.attrib.pop('type', None)
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 # wrap="square"
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('rtlCol', '0')
830
+ body_pr.set("rtlCol", "0")
664
831
  tf.vertical_anchor = mod.MSO_ANCHOR.TOP # anchor="t"
665
- for tag in ('a:spAutoFit', 'a:normAutofit', 'a:noAutofit'):
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('indent', '0')
676
- p_pr.set('marL', '0')
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('a:buNone')) is None:
680
- p_pr.append(p_pr.makeelement(mod.qn('a:buNone'), {}))
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 ('a:latin', 'a:ea', 'a:cs'):
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('typeface', typeface)
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('meta') or {}
725
- if meta.get('title'):
726
- value, truncated = clamp_core_title(meta['title'])
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({'code': 'TITLE_TRUNCATED', 'from': 'meta.title',
729
- 'detail': '{0} -> {1}'.format(len(meta['title']), CORE_TITLE_MAX)})
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('meta.title 을 core property 로 쓸 수 없다: {0}'.format(err))
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['slides']:
915
+ for s in laid["slides"]:
742
916
  slide = prs.slides.add_slide(blank)
743
- for sh in s['shapes']:
917
+ for sh in s["shapes"]:
744
918
  box = slide.shapes.add_textbox(
745
- mod.Emu(sh['x']), mod.Emu(sh['y']), mod.Emu(sh['cx']), mod.Emu(sh['cy']))
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['text']).split('\n')):
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(paragraph.add_run(), line, sh['pt'], sh['bold'],
755
- sh['color'], font['name'], mod)
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['grid']
764
- shapes = sum(len(s['shapes']) for s in laid['slides'])
765
- pages = len(set(s['pageIndex'] for s in laid['slides']))
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
- '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']),
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 = '' 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)))
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('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']
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(' {0} {1} {2}'.format(w['code'], where, w.get('detail') or '').rstrip())
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(' 출력 {0}'.format(out_path))
791
- return '\n'.join(lines)
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
- '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=(',', ':'))
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 = '\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
- ])
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 = {'in': '', 'out': '', 'fontArg': '', 'help': False}
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('{0} 에 값이 필요하다.'.format(name))
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 == '--in':
850
- opts['in'] = need('--in')
1050
+ if a == "--in":
1051
+ opts["in"] = need("--in")
851
1052
  i += 1
852
- elif a == '--out':
853
- opts['out'] = need('--out')
1053
+ elif a == "--out":
1054
+ opts["out"] = need("--out")
854
1055
  i += 1
855
- elif a == '--font':
856
- opts['fontArg'] = need('--font')
1056
+ elif a == "--font":
1057
+ opts["fontArg"] = need("--font")
857
1058
  i += 1
858
- elif a in ('-h', '--help'):
859
- opts['help'] = True
1059
+ elif a in ("-h", "--help"):
1060
+ opts["help"] = True
860
1061
  else:
861
- raise UsageError('알 수 없는 인자: {0}\n\n{1}'.format(a, USAGE))
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, 'r', encoding='utf-8') as fh:
1069
+ with open(path, "r", encoding="utf-8") as fh:
869
1070
  text = fh.read()
870
1071
  except OSError as exc:
871
- raise UsageError('deck JSON 을 읽지 못했다: {0}'.format(exc))
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('deck JSON 파싱 실패 ({0}): {1}'.format(path, exc))
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['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 <경로> 가 필요하다.')
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['in'])
1087
+ deck = read_deck_file(opts["in"])
887
1088
  laid = layout(deck) # validate_deck 포함 — 스키마 위반은 여기서 exit 3
888
- warnings = list(laid['warnings'])
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('meta') or {}
895
- font = resolve_font(opts['fontArg'], meta.get('font'))
896
- warnings.extend(font['warnings'])
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['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')
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('{0}\n'.format(err))
1112
+ stream.write("{0}\n".format(err))
912
1113
  return err.exit_code
913
1114
 
914
1115
 
915
- if __name__ == '__main__':
1116
+ if __name__ == "__main__":
916
1117
  sys.exit(main(sys.argv[1:]))