genetics-mcp 2.0.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/LICENSE +43 -0
- package/genetics.mjs +2141 -0
- package/package.json +23 -0
- package/skills/genetics-report/SKILL.md +290 -0
- package/skills/genetics-report/assets/example/cases.json +24 -0
- package/skills/genetics-report/assets/example/report.json +55 -0
- package/skills/genetics-report/assets/generate-illustrations.py +159 -0
- package/skills/genetics-report/assets/qa.py +183 -0
- package/skills/genetics-report/assets/render.py +161 -0
- package/skills/genetics-report/assets/subset_font.py +93 -0
- package/skills/genetics-report/assets/visual-baseline.py +459 -0
- package/skills/genetics-report/references/content-pipeline.md +891 -0
- package/skills/genetics-report/references/design-system.md +733 -0
- package/skills/genetics-report/references/font-embedding.md +161 -0
- package/skills/genetics-report/references/illustrations.md +473 -0
- package/skills/genetics-report/references/lim-portal-format.md +272 -0
- package/skills/genetics-report/references/process.md +634 -0
- package/skills/genetics-report/references/qa.md +722 -0
- package/skills/genetics-report/references/textbook-mode.md +111 -0
- package/skills/genetics-report/references/wysiwyg.md +751 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""산출물 자동 검수. 빌드 직후 반드시 돌린다.
|
|
3
|
+
|
|
4
|
+
python3 qa.py report.json
|
|
5
|
+
|
|
6
|
+
여기서 잡는 것은 **눈으로는 안 보이는 결함**이다. 실제로 이 검사기가 잡아낸 것들:
|
|
7
|
+
· 강조색을 흰 바탕 글자로 써서 2.25:1 — 화면으로는 "좀 연하네" 정도로만 보인다
|
|
8
|
+
· 괘선용 회색(--mid)이 본문 색으로 새어 들어감
|
|
9
|
+
· 건 하나에 "확인 방법" 블록이 빠짐 — 18건 중 1건은 눈으로 못 찾는다
|
|
10
|
+
· 원고 마커(§)와 내부 티켓 번호가 고객본에 남음
|
|
11
|
+
|
|
12
|
+
통과 기준: 실패 0. 경고는 판단해서 넘긴다.
|
|
13
|
+
사람이 봐야 하는 것(톤·독자 적합성·실제 렌더)은 이 검사기가 대신하지 못한다 —
|
|
14
|
+
references/qa.md 의 시각 검수 목록을 따로 돌 것.
|
|
15
|
+
"""
|
|
16
|
+
import json, pathlib, re, sys
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, str(pathlib.Path(__file__).parent))
|
|
19
|
+
import palette as P
|
|
20
|
+
|
|
21
|
+
FONT_ALLOW = {'Pretendard', 'Pretendard Variable', 'Inter', 'Lora', 'Poppins', 'Georgia',
|
|
22
|
+
'맑은 고딕', 'Malgun Gothic', 'JetBrains Mono', '구름 산스 코드',
|
|
23
|
+
'-apple-system', 'BlinkMacSystemFont', 'Apple SD Gothic Neo',
|
|
24
|
+
'Noto Sans KR', 'system-ui', 'sans-serif', 'serif', 'monospace',
|
|
25
|
+
'Segoe UI', 'ui-monospace', 'Menlo'}
|
|
26
|
+
|
|
27
|
+
# 편집 레이어 CSS 가 시작되는 지점. 색 검사에서 이 구간만 도려낸다.
|
|
28
|
+
EDITOR_CSS_MARK = '/* ── 편집 레이어'
|
|
29
|
+
|
|
30
|
+
SLOP_KO = ['혁신적인', '차세대', '재정의하는', '한 차원 높은', '최적화된 솔루션',
|
|
31
|
+
'완벽한 경험', '새로운 패러다임', '고도화된']
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run(cfg_path):
|
|
35
|
+
cfg = json.loads(pathlib.Path(cfg_path).read_text(encoding='utf-8'))
|
|
36
|
+
base = pathlib.Path(cfg_path).parent
|
|
37
|
+
doc = (base / cfg['out']).read_text(encoding='utf-8')
|
|
38
|
+
# 편집기 소스는 본문이 아니다 — 검사에서 뺀다
|
|
39
|
+
body = doc.split('<body', 1)[1].split('<script', 1)[0]
|
|
40
|
+
text = re.sub(r'<[^>]+>', ' ', body)
|
|
41
|
+
ok, warn, err = [], [], []
|
|
42
|
+
|
|
43
|
+
# 1. 미치환 플레이스홀더
|
|
44
|
+
ph = re.findall(r'\{[A-Za-z_][^}]{0,40}\}', body)
|
|
45
|
+
(ok if not ph else err).append(f'플레이스홀더 잔존 {len(ph)}건 {ph[:3] if ph else ""}')
|
|
46
|
+
|
|
47
|
+
# 2. 가로 스크롤 유발 고정 폭
|
|
48
|
+
# lookbehind 가 없으면 `max-width:1080px` 같은 정상 반응형 코드가 전부 걸린다
|
|
49
|
+
fixed = re.findall(r'(?<![a-z-])width:\s*\d{3,}px', body)
|
|
50
|
+
(ok if not fixed else warn).append(f'고정 px 폭 요소 {len(fixed)}건')
|
|
51
|
+
|
|
52
|
+
# 3. 색 대비 — 문서에 박힌 :root 를 그대로 읽어 잰다.
|
|
53
|
+
# 빌드가 유도한 값이 아니라 **실제로 나간 값**을 재는 것이 요점이다.
|
|
54
|
+
root = re.search(r':root \{(.*?)\n\}', doc, re.S)
|
|
55
|
+
tok = dict(re.findall(r'--([a-z-]+):\s*([^;]+);', root.group(1))) if root else {}
|
|
56
|
+
if not tok:
|
|
57
|
+
err.append(':root 토큰을 찾지 못함')
|
|
58
|
+
else:
|
|
59
|
+
for name, fg, bg, r, need, good in P.audit(tok):
|
|
60
|
+
(ok if good else err).append(f'대비 {name}: {r}:1 (기준 {need})')
|
|
61
|
+
|
|
62
|
+
# 4. 괘선 전용 색이 글자로 샜는가
|
|
63
|
+
# `border-color:var(--mid)` 는 괘선이라 정상 — 앞에 하이픈이 오면 제외한다
|
|
64
|
+
leak = re.findall(r'(?<![a-z-])color:\s*var\(--mid\)', doc)
|
|
65
|
+
(ok if not leak else err).append(f'--mid 를 글자색으로 사용 {len(leak)}건')
|
|
66
|
+
# `border-left-color:var(--accent)` 는 괘선이라 정상이다 — 앞에 하이픈이 오면 제외
|
|
67
|
+
leak2 = re.findall(r'(?<![-a-z])color:\s*var\(--accent\)(?![-a-z])', doc)
|
|
68
|
+
(ok if not leak2 else err).append(f'--accent 를 글자색으로 사용 {len(leak2)}건 (배경 전용)')
|
|
69
|
+
|
|
70
|
+
# 4-b. 팔레트 밖 하드코딩 색 — 인라인 SVG 와 인쇄용 각주가 새는 자리다.
|
|
71
|
+
# 편집기 UI 는 문서 활자와 색 계열을 일부러 분리하므로 그 구간만 도려낸다.
|
|
72
|
+
i, j = doc.find(EDITOR_CSS_MARK), doc.find('</style>', doc.find(EDITOR_CSS_MARK))
|
|
73
|
+
if i < 0:
|
|
74
|
+
warn.append(f'편집기 CSS 마커 «{EDITOR_CSS_MARK}» 를 못 찾음 — 색 검사 범위 확인 필요')
|
|
75
|
+
scan = doc
|
|
76
|
+
else:
|
|
77
|
+
scan = doc[:i] + doc[j:]
|
|
78
|
+
scan = re.sub(r'base64,[A-Za-z0-9+/=]+', 'base64,', scan) # 폰트 페이로드만 지운다
|
|
79
|
+
# ↑ split 으로 자르면 문서 대부분이 함께 날아가 검사가 무의미해진다. 실제로 그랬다.
|
|
80
|
+
hexes = set()
|
|
81
|
+
for h3 in re.findall(r'#([0-9a-fA-F]{3})\b(?![0-9a-fA-F])', scan):
|
|
82
|
+
hexes.add(''.join(c * 2 for c in h3).upper())
|
|
83
|
+
hexes |= {x.upper() for x in re.findall(r'#([0-9a-fA-F]{6})\b', scan)}
|
|
84
|
+
allowed = {v.lstrip('#').upper() for v in tok.values() if v.startswith('#')} | {'FFFFFF', '000000'}
|
|
85
|
+
stray = sorted(hexes - allowed)
|
|
86
|
+
(ok if not stray else warn).append(f'팔레트 밖 하드코딩 색 {stray or "없음"}')
|
|
87
|
+
rgba = re.findall(r'rgba?\([^)]*\)', scan)
|
|
88
|
+
(ok if not rgba else warn).append(f'rgb/rgba 리터럴 {len(rgba)}건 (팔레트 대조 불가)')
|
|
89
|
+
|
|
90
|
+
# 5. 폰트 화이트리스트
|
|
91
|
+
fams = set()
|
|
92
|
+
for m in re.finditer(r'font-family:([^;}]+)', doc):
|
|
93
|
+
for f in m.group(1).split(','):
|
|
94
|
+
fams.add(f.strip().strip('"\''))
|
|
95
|
+
bad = {f for f in fams if f and f not in FONT_ALLOW}
|
|
96
|
+
(ok if not bad else warn).append(f'비표준 폰트 {sorted(bad) or "없음"}')
|
|
97
|
+
|
|
98
|
+
# 6. 폰트 임베드
|
|
99
|
+
if cfg.get('font', {}).get('embed', True):
|
|
100
|
+
m = re.search(r'@font-face \{[^}]*format\("([^"]+)"\)', doc)
|
|
101
|
+
(ok if m else err).append('@font-face 임베드 존재')
|
|
102
|
+
if m:
|
|
103
|
+
(ok if m.group(1) == 'woff2' else err).append(
|
|
104
|
+
f'format 문자열 "{m.group(1)}" (woff2 여야 함 — 다른 값은 조용히 무시된다)')
|
|
105
|
+
|
|
106
|
+
# 7. 구조 일관성 — 모든 건이 같은 구성요소를 갖는가
|
|
107
|
+
n = len(re.findall(r'<article class="case"', body))
|
|
108
|
+
for pat, label, adj in [(r'class="num"', '절 번호', 0),
|
|
109
|
+
(r'class="row r-sym"', '증상', 0),
|
|
110
|
+
(r'class="row r-cause"', '원인', 0),
|
|
111
|
+
(r'class="row r-fix"', '조치', 0),
|
|
112
|
+
(r'class="row r-check"', '확인 방법', 0)]:
|
|
113
|
+
c = len(re.findall(pat, body)) - adj
|
|
114
|
+
(ok if c == n else err).append(f'{label} {c}/{n}')
|
|
115
|
+
# 뺄셈으로 세지 않는다. 범례는 **실제 쓰인 등급만** 렌더하므로 선언 개수를 빼면
|
|
116
|
+
# 어떤 등급이 0건일 때 조용히 틀린다. 세는 자리를 좁히면 틀어질 것이 없다.
|
|
117
|
+
chips = sum(x.count('class="method ')
|
|
118
|
+
for x in re.findall(r'<div class="case-top">.*?</div>', body, re.S))
|
|
119
|
+
(ok if chips == n else err).append(f'건별 확인 등급 칩 {chips}/{n}')
|
|
120
|
+
used = len({c for c in re.findall(r'<td class="c-m"><span class="method [a-z-]+">([^<]+)<', body)})
|
|
121
|
+
legend_n = len(re.findall(r'<div class="lg">', body))
|
|
122
|
+
(ok if legend_n == used else err).append(f'범례 항목 {legend_n}/{used} (실제 쓰인 등급 수)')
|
|
123
|
+
|
|
124
|
+
# 8. 목차·판정표가 본문과 일치하는가 (편집기가 재생성하기 전 초기 상태)
|
|
125
|
+
toc_n = len(re.findall(r'<li><a href="#s\d+-\d+"', doc))
|
|
126
|
+
(ok if toc_n == n else err).append(f'목차 항목 {toc_n}/{n}')
|
|
127
|
+
v_n = len(re.findall(r'<td class="c-sec">', body))
|
|
128
|
+
(ok if v_n == n else err).append(f'판정표 행 {v_n}/{n}')
|
|
129
|
+
|
|
130
|
+
# 9. 절 참조 링크가 실제 존재하는 절을 가리키는가 (죽은 링크는 검수자를 헤매게 한다)
|
|
131
|
+
ids = set(re.findall(r'id="(s\d+-\d+)"', body))
|
|
132
|
+
dead = sorted({h for h in re.findall(r'href="#(s\d+-\d+)"', doc)} - ids)
|
|
133
|
+
(ok if not dead else err).append(f'죽은 절 참조 {dead or "없음"}')
|
|
134
|
+
|
|
135
|
+
# 10. 고객본 원칙
|
|
136
|
+
(ok if '§' not in doc else err).append(f'원고 마커 § {doc.count("§")}건')
|
|
137
|
+
for pat in cfg.get('forbid', []):
|
|
138
|
+
hits = re.findall(pat, doc)
|
|
139
|
+
(ok if not hits else err).append(f'금지 표현 /{pat}/ {len(hits)}건')
|
|
140
|
+
|
|
141
|
+
# 11. 카피 게이트 — 대시 대비 헤드라인 / AI 슬롭
|
|
142
|
+
heads = [re.sub(r'<[^>]+>', '', x).strip()
|
|
143
|
+
for x in re.findall(r'<h[123][^>]*>(.*?)</h[123]>', body, re.S)]
|
|
144
|
+
dash = [h for h in heads if ' — ' in h or ' – ' in h]
|
|
145
|
+
(ok if not dash else err).append(f'대시 대비 헤드라인 {dash or "없음"}')
|
|
146
|
+
slop = [s for s in SLOP_KO if s in text]
|
|
147
|
+
(ok if not slop else err).append(f'AI 슬롭 어휘 {slop or "없음"}')
|
|
148
|
+
|
|
149
|
+
# 12. 접근성 · 단일 파일
|
|
150
|
+
(ok if f'lang="{cfg.get("lang", "ko")}"' in doc else err).append('lang 선언')
|
|
151
|
+
imgs = re.findall(r'<img(?![^>]*\balt=)[^>]*>', body)
|
|
152
|
+
(ok if not imgs else err).append(f'alt 없는 <img> {len(imgs)}건')
|
|
153
|
+
allow = cfg.get('allow_external', [])
|
|
154
|
+
ext = [u for u in re.findall(r'(?:src|href)="(https?://[^"]+)"', doc)
|
|
155
|
+
if not any(a in u for a in allow)]
|
|
156
|
+
(ok if not ext else err).append(f'외부 리소스 {ext[:3] or "없음"}')
|
|
157
|
+
|
|
158
|
+
# 13. 고객본 내보내기 시뮬레이션 — editor.js 의 serialize(customer=true) 와 같은 규칙
|
|
159
|
+
# 표식을 안 붙이면 내보내기 코드는 **조용히 아무것도 못 지운다**. 실제로 그랬다.
|
|
160
|
+
cust = re.sub(r'<!-- REPORT-EDITOR:BEGIN -->.*?<!-- REPORT-EDITOR:END -->', '', doc, flags=re.S)
|
|
161
|
+
cust = re.sub(r'<(style|script)\b[^>]*\bdata-rte-src\b.*?</\1>', '', cust, flags=re.S)
|
|
162
|
+
cust = re.sub(r'\sdata-report-edit="[^"]*"', '', cust, count=1) # body 속성 제거
|
|
163
|
+
cust = re.sub(r'<[^>]*\bdata-internal="true".*?</\w+>', '', cust, flags=re.S)
|
|
164
|
+
residue = [w for w in ('data-rte-ui', 'showSaveFilePicker', 'syncVerdict',
|
|
165
|
+
'data-report-edit', 'data-internal="true"') if w in cust]
|
|
166
|
+
(ok if not residue else err).append(f'고객본에 남는 편집기 흔적 {residue or "없음"}')
|
|
167
|
+
(ok if len(cust) < len(doc) * 0.98 else err).append(
|
|
168
|
+
f'고객본 크기 {len(cust.encode()):,}B (원본 대비 {len(cust)/len(doc):.0%})')
|
|
169
|
+
|
|
170
|
+
size = len(doc.encode())
|
|
171
|
+
print(f'■ QA — {cfg["out"]} {size:,}B 건수 {n}\n')
|
|
172
|
+
for x in err:
|
|
173
|
+
print(' ❌', x)
|
|
174
|
+
for x in warn:
|
|
175
|
+
print(' ⚠️ ', x)
|
|
176
|
+
for x in ok:
|
|
177
|
+
print(' ✅', x)
|
|
178
|
+
print(f'\n통과 {len(ok)} · 경고 {len(warn)} · 실패 {len(err)}')
|
|
179
|
+
return 1 if err else 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == '__main__':
|
|
183
|
+
sys.exit(run(sys.argv[1] if len(sys.argv) > 1 else 'report.json'))
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""단일 HTML 렌더 — **레포의 발행 렌더러를 부른다. 사본을 두지 않는다.**
|
|
3
|
+
|
|
4
|
+
## 왜 이 파일이 생겼나 (2026-08-18)
|
|
5
|
+
|
|
6
|
+
`assets/` 에 `build.py`·`report.css`·`palette.py`·`editor.js` 사본이 있었다.
|
|
7
|
+
커밋 f1a2725(08-13) "발행 렌더러를 레포로 들인다" 가 SSOT 를 레포로 옮겼는데
|
|
8
|
+
**옛 사본을 안 지웠고**, 그 뒤 레포만 나흘 자랐다:
|
|
9
|
+
|
|
10
|
+
build.py 20,361B(08-14) → 51,754B(08-18) 887줄 차이
|
|
11
|
+
report.css 23,226B → 19,582B 459줄
|
|
12
|
+
palette.py 10,800B → 14,940B 65줄
|
|
13
|
+
editor.js 27,126B → 28,117B 31줄
|
|
14
|
+
lim-report.css **스킬에 아예 없음**
|
|
15
|
+
|
|
16
|
+
그래서 이 경로로 만든 고객 문서에는 **브랜드 서식이 없었다** —
|
|
17
|
+
cover-frame·ch-head·cindex·eof-endcard·Spoqa 가 전부 0회로 나갔다.
|
|
18
|
+
둘을 대조하는 관문이 없어서 아무도 몰랐다.
|
|
19
|
+
|
|
20
|
+
## 규칙
|
|
21
|
+
|
|
22
|
+
렌더러는 **레포가 유일한 원본**이다. 여기서 사본을 만들지 마라.
|
|
23
|
+
저작 도구(qa.py·generate-illustrations.py·subset_font.py·visual-baseline.py)는
|
|
24
|
+
렌더러가 아니므로 이 폴더에 남는다.
|
|
25
|
+
|
|
26
|
+
## 쓰는 법
|
|
27
|
+
|
|
28
|
+
python3 render.py report.json # 기본 서식
|
|
29
|
+
python3 render.py report.json --template lim-report # 브랜드 서식
|
|
30
|
+
|
|
31
|
+
레포 위치는 `LIM_PORTAL` 로 덮어쓸 수 있다.
|
|
32
|
+
"""
|
|
33
|
+
import argparse
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import pathlib
|
|
37
|
+
import re
|
|
38
|
+
import subprocess
|
|
39
|
+
import sys
|
|
40
|
+
|
|
41
|
+
DEFAULT_REPO = pathlib.Path.home() / 'project/FCG/report-portal'
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
FORKABLE = ('build.py', 'report.css', 'palette.py', 'editor.js', 'editor.css', 'fonts')
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def refuse_fork() -> None:
|
|
48
|
+
"""사본이 다시 생겼으면 **멈춘다.**
|
|
49
|
+
|
|
50
|
+
이 스킬은 2026-08-18 에 사본을 지웠다. 다시 생기면 같은 일이 반복된다 —
|
|
51
|
+
나흘 만에 887줄이 갈라졌고 그동안 고객 문서가 브랜드 서식 없이 나갔다.
|
|
52
|
+
조용히 무시하면 관문이 아니라 장식이다.
|
|
53
|
+
"""
|
|
54
|
+
here = pathlib.Path(__file__).parent
|
|
55
|
+
back = [f for f in FORKABLE if (here / f).exists()]
|
|
56
|
+
if back:
|
|
57
|
+
sys.exit(
|
|
58
|
+
'렌더러 사본이 다시 생겼습니다: ' + ', '.join(back) + '\n'
|
|
59
|
+
'\n'
|
|
60
|
+
' 렌더러의 원본은 레포 하나입니다(private/builder/).\n'
|
|
61
|
+
' 고칠 것이 있으면 레포에서 고치고 배포하세요 — 여기 두면 갈라집니다.\n'
|
|
62
|
+
f' 지우려면: rm -rf {" ".join(str(here / f) for f in back)}\n'
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def find_builder() -> pathlib.Path:
|
|
67
|
+
"""레포의 발행 렌더러를 찾는다. 없으면 **크게 실패한다** — 조용히 사본으로 안 돌아간다."""
|
|
68
|
+
repo = pathlib.Path(os.environ.get('LIM_PORTAL', DEFAULT_REPO)).expanduser()
|
|
69
|
+
builder = repo / 'private/builder/build.py'
|
|
70
|
+
if builder.exists():
|
|
71
|
+
return builder
|
|
72
|
+
sys.exit(
|
|
73
|
+
f'발행 렌더러를 못 찾았습니다: {builder}\n'
|
|
74
|
+
'\n'
|
|
75
|
+
' 이 스킬은 렌더러 사본을 두지 않습니다 — 레포가 유일한 원본입니다.\n'
|
|
76
|
+
' (사본을 두었다가 나흘 만에 887줄이 갈라졌고, 그동안 고객 문서가\n'
|
|
77
|
+
' 브랜드 서식 없이 나갔습니다. 2026-08-18)\n'
|
|
78
|
+
'\n'
|
|
79
|
+
' 고치는 법:\n'
|
|
80
|
+
f' · 레포를 {repo} 에 두거나\n'
|
|
81
|
+
' · LIM_PORTAL=/경로/report-portal 로 알려 주세요.\n'
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def templates_from_server(repo: pathlib.Path) -> dict:
|
|
86
|
+
"""**서식 정의를 서버 코드에서 읽는다.** 여기 베껴 두면 그게 다음 사본이 된다.
|
|
87
|
+
|
|
88
|
+
`report_publish.php` 의 `REPORT_TEMPLATES` 가 정본이다. 파싱이 실패하면
|
|
89
|
+
추측하지 않고 멈춘다 — 틀린 서식으로 고객 문서를 굽느니 안 굽는 편이 낫다.
|
|
90
|
+
"""
|
|
91
|
+
php = repo / 'www/portal/lib/report_publish.php'
|
|
92
|
+
if not php.exists():
|
|
93
|
+
sys.exit(f'서식 정의를 못 찾았습니다: {php}')
|
|
94
|
+
src = php.read_text(encoding='utf-8')
|
|
95
|
+
block = re.search(r"const REPORT_TEMPLATES = \[(.*?)\n\];", src, re.S)
|
|
96
|
+
if not block:
|
|
97
|
+
sys.exit('REPORT_TEMPLATES 를 못 읽었습니다 — report_publish.php 모양이 바뀌었습니다.')
|
|
98
|
+
body = block.group(1)
|
|
99
|
+
out = {}
|
|
100
|
+
for name in re.findall(r"'([a-z-]+)'\s*=>", body):
|
|
101
|
+
out[name] = None
|
|
102
|
+
if 'lim-report' not in out:
|
|
103
|
+
sys.exit("서버에 'lim-report' 서식이 없습니다.")
|
|
104
|
+
# css 목록과 폰트면은 값이 필요하므로 따로 뽑는다.
|
|
105
|
+
lim = re.search(r"'lim-report'\s*=>\s*\[(.*?)\n \],", body, re.S)
|
|
106
|
+
css = re.findall(r"'([\w.-]+\.css)'", lim.group(1)) if lim else []
|
|
107
|
+
family = re.search(r"'family'\s*=>\s*'([^']+)'", lim.group(1)) if lim else None
|
|
108
|
+
faces = re.findall(r"'file'\s*=>\s*'([^']+)'.*?'weight'\s*=>\s*'(\d+)'", lim.group(1)) if lim else []
|
|
109
|
+
lic = re.search(r"'license'\s*=>\s*'([^']+)'", lim.group(1)) if lim else None
|
|
110
|
+
return {
|
|
111
|
+
'names': list(out),
|
|
112
|
+
'lim-report': {
|
|
113
|
+
'css': css,
|
|
114
|
+
'font': {
|
|
115
|
+
'family': family.group(1) if family else '',
|
|
116
|
+
'license': (lic.group(1) if lic else ''),
|
|
117
|
+
'faces': [{'file': f, 'weight': w} for f, w in faces],
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main() -> None:
|
|
124
|
+
ap = argparse.ArgumentParser(description='단일 HTML 렌더 (레포 렌더러 호출)')
|
|
125
|
+
ap.add_argument('config', help='report.json')
|
|
126
|
+
# 기본은 브랜드 서식이다 — 서버의 REPORT_TEMPLATE_DEFAULT 와 같은 값으로 맞춘다.
|
|
127
|
+
# 어긋나면 「스킬로 만든 문서」와 「MCP 로 올린 문서」의 서식이 갈린다.
|
|
128
|
+
ap.add_argument('--template', default='lim-report',
|
|
129
|
+
help='lim-report(기본·브랜드 서식)')
|
|
130
|
+
ap.add_argument('--palette', default=None, help='팔레트 이름(lim-report 는 기본 lim)')
|
|
131
|
+
args = ap.parse_args()
|
|
132
|
+
|
|
133
|
+
refuse_fork()
|
|
134
|
+
builder = find_builder()
|
|
135
|
+
repo = builder.parent.parent.parent
|
|
136
|
+
cfg_path = pathlib.Path(args.config).resolve()
|
|
137
|
+
cfg = json.loads(cfg_path.read_text(encoding='utf-8'))
|
|
138
|
+
|
|
139
|
+
# 서식 이름은 **서버가 가진 목록**으로만 검사한다 — 여기 사본을 두면 둘이 갈라진다.
|
|
140
|
+
t = templates_from_server(repo)
|
|
141
|
+
if args.template not in t['names']:
|
|
142
|
+
sys.exit(f"모르는 서식입니다: {args.template} (있는 것: {'|'.join(t['names'])})")
|
|
143
|
+
if args.template == 'lim-report':
|
|
144
|
+
cfg.update(t['lim-report'])
|
|
145
|
+
cfg.setdefault('palette', 'lim')
|
|
146
|
+
if args.palette:
|
|
147
|
+
cfg['palette'] = args.palette
|
|
148
|
+
|
|
149
|
+
# 서식을 얹은 설정으로 새로 쓴다 — 원본 report.json 은 안 건드린다.
|
|
150
|
+
merged = cfg_path.parent / f'.render-{args.template}.json'
|
|
151
|
+
merged.write_text(json.dumps(cfg, ensure_ascii=False), encoding='utf-8')
|
|
152
|
+
try:
|
|
153
|
+
r = subprocess.run([sys.executable, str(builder), str(merged)],
|
|
154
|
+
cwd=str(cfg_path.parent))
|
|
155
|
+
finally:
|
|
156
|
+
merged.unlink(missing_ok=True)
|
|
157
|
+
sys.exit(r.returncode)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == '__main__':
|
|
161
|
+
main()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pretendard 서브셋 생성 — 문서에 심을 woff2 한 벌을 만든다.
|
|
3
|
+
|
|
4
|
+
폰트는 **레포**(`private/builder/fonts/`)에 있으므로 **보통은 실행할 필요가
|
|
5
|
+
없다.** 다른 폰트로 바꾸거나 커버리지를 넓힐 때만 쓴다.
|
|
6
|
+
|
|
7
|
+
왜 이런 모양인가
|
|
8
|
+
· 산출물은 외부 리소스 0(단일 HTML)이 요건이라 웹폰트 CDN 을 쓸 수 없다.
|
|
9
|
+
그래서 base64 로 문서 안에 심는다 — 용량이 곧 파일 크기다.
|
|
10
|
+
· **가변 폰트를 wght 400–700 으로 잘라** 한 벌로 쓴다. 정적 4종(400/500/600/700)을
|
|
11
|
+
각각 심으면 651KB 인데 가변 한 벌은 300KB 다. 게다가 CSS 의 `font-weight:650`
|
|
12
|
+
같은 중간값이 실제로 그 굵기로 나온다(정적 2종이면 700 으로 튄다).
|
|
13
|
+
· 커버리지는 **KS X 1001 완성형 2,350자**. 문서에 지금 쓰인 글자만 넣으면 60KB 로
|
|
14
|
+
줄지만, 이 문서는 위지윅으로 **편집되므로** 새로 타이핑한 글자가 다른 폰트로
|
|
15
|
+
떨어진다. 반대로 현대 한글 11,172자를 다 넣으면 1.19MB 로 4배가 된다.
|
|
16
|
+
2,350자는 일반 한국어 산문을 사실상 전부 덮는다.
|
|
17
|
+
· `tnum` 피처를 반드시 남긴다. 지표·표에서 `font-variant-numeric:tabular-nums`
|
|
18
|
+
를 쓰는데, 레이아웃 피처를 통째로 버리면 자릿수 정렬이 조용히 죽는다.
|
|
19
|
+
|
|
20
|
+
준비
|
|
21
|
+
python3 -m venv .fontenv && .fontenv/bin/pip install fonttools brotli
|
|
22
|
+
# 원본: https://github.com/orioncactus/pretendard (SIL OFL 1.1)
|
|
23
|
+
curl -sLO https://raw.githubusercontent.com/orioncactus/pretendard/v1.3.9/\
|
|
24
|
+
packages/pretendard/dist/public/variable/PretendardVariable.ttf
|
|
25
|
+
|
|
26
|
+
사용
|
|
27
|
+
.fontenv/bin/python subset_font.py PretendardVariable.ttf \
|
|
28
|
+
~/project/FCG/report-portal/private/builder/fonts/pretendard-report.woff2
|
|
29
|
+
.fontenv/bin/python subset_font.py PretendardVariable.ttf out.woff2 --coverage=full
|
|
30
|
+
"""
|
|
31
|
+
import argparse, pathlib, subprocess, sys, tempfile
|
|
32
|
+
|
|
33
|
+
LAYOUT = 'ccmp,locl,kern,mark,mkmk,calt,clig,liga,rlig,tnum,case'
|
|
34
|
+
|
|
35
|
+
def ksx1001():
|
|
36
|
+
"""KS X 1001 완성형 한글 2,350자. EUC-KR 2바이트 · 선행바이트 0xB0–0xC8 로 판별."""
|
|
37
|
+
out = []
|
|
38
|
+
for cp in range(0xAC00, 0xD7A4):
|
|
39
|
+
c = chr(cp)
|
|
40
|
+
try:
|
|
41
|
+
b = c.encode('euc-kr')
|
|
42
|
+
except UnicodeEncodeError:
|
|
43
|
+
continue
|
|
44
|
+
if len(b) == 2 and 0xB0 <= b[0] <= 0xC8:
|
|
45
|
+
out.append(c)
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
EXTRA = (''.join(chr(c) for c in range(0x20, 0x7F))
|
|
49
|
+
+ '·–—“”‘’↑→↓↗←−…※○●◦「」『』€₩©®°±×÷≤≥≠№㎡')
|
|
50
|
+
|
|
51
|
+
def main():
|
|
52
|
+
ap = argparse.ArgumentParser()
|
|
53
|
+
ap.add_argument('src'); ap.add_argument('dst')
|
|
54
|
+
ap.add_argument('--coverage', choices=['ksx1001', 'full', 'used'], default='ksx1001')
|
|
55
|
+
ap.add_argument('--used-from', help='coverage=used 일 때 글자를 긁어올 파일')
|
|
56
|
+
ap.add_argument('--wght', default='400:700', help='가변 축 범위. 단일 굵기면 "400"')
|
|
57
|
+
ap.add_argument('--python', default=sys.executable)
|
|
58
|
+
a = ap.parse_args()
|
|
59
|
+
|
|
60
|
+
if a.coverage == 'used':
|
|
61
|
+
if not a.used_from:
|
|
62
|
+
sys.exit('coverage=used 에는 --used-from 이 필요하다')
|
|
63
|
+
chars = sorted(set(pathlib.Path(a.used_from).read_text(encoding='utf-8')) | set(EXTRA))
|
|
64
|
+
elif a.coverage == 'full':
|
|
65
|
+
chars = [chr(c) for c in range(0xAC00, 0xD7A4)] + list(EXTRA)
|
|
66
|
+
else:
|
|
67
|
+
chars = ksx1001() + list(EXTRA)
|
|
68
|
+
|
|
69
|
+
with tempfile.TemporaryDirectory() as td:
|
|
70
|
+
td = pathlib.Path(td)
|
|
71
|
+
txt = td / 'charset.txt'
|
|
72
|
+
txt.write_text(''.join(sorted(set(chars))), encoding='utf-8')
|
|
73
|
+
|
|
74
|
+
src = a.src
|
|
75
|
+
if a.wght:
|
|
76
|
+
# 축을 자르면 variation delta 가 줄어 서브셋이 30% 작아진다.
|
|
77
|
+
inst = td / 'axis.ttf'
|
|
78
|
+
subprocess.run([a.python, '-m', 'fontTools.varLib.instancer', src,
|
|
79
|
+
f'wght={a.wght}', '-o', str(inst)], check=True,
|
|
80
|
+
stdout=subprocess.DEVNULL)
|
|
81
|
+
src = str(inst)
|
|
82
|
+
|
|
83
|
+
subprocess.run([a.python, '-m', 'fontTools.subset', src,
|
|
84
|
+
f'--text-file={txt}', f'--layout-features={LAYOUT}',
|
|
85
|
+
'--no-hinting', '--drop-tables+=DSIG,MATH,BASE,JSTF',
|
|
86
|
+
'--flavor=woff2', f'--output-file={a.dst}'], check=True)
|
|
87
|
+
|
|
88
|
+
n = pathlib.Path(a.dst).stat().st_size
|
|
89
|
+
print(f'{a.dst} {n:,}B ({len(set(chars)):,}자 · wght {a.wght})')
|
|
90
|
+
print(f' base64 로 심으면 약 {n * 4 // 3:,}B 가 문서에 더해진다')
|
|
91
|
+
|
|
92
|
+
if __name__ == '__main__':
|
|
93
|
+
main()
|