genetics-mcp 2.9.1 → 2.9.3
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/bin/aarch64-apple-darwin/gcell +0 -0
- package/bin/aarch64-unknown-linux-musl/gcell +0 -0
- package/bin/manifest.json +4 -4
- package/bin/x86_64-apple-darwin/gcell +0 -0
- package/bin/x86_64-unknown-linux-musl/gcell +0 -0
- package/genetics.mjs +658 -26
- package/package.json +1 -1
- package/skills/genetics-cell-id/SKILL.md +204 -0
- package/skills/genetics-report/SKILL.md +217 -93
- package/skills/genetics-report/assets/example/cases.json +19 -22
- package/skills/genetics-report/assets/example/report.json +198 -53
- package/skills/genetics-report/assets/render.py +21 -10
- package/skills/genetics-report/assets/subset_font.py +24 -8
- package/skills/genetics-report/references/content-pipeline.md +316 -733
- package/skills/genetics-report/references/design-system.md +20 -723
- package/skills/genetics-report/references/font-embedding.md +132 -108
- package/skills/genetics-report/references/illustrations.md +20 -462
- package/skills/genetics-report/references/lim-portal-format.md +756 -182
- package/skills/genetics-report/references/process.md +414 -403
- package/skills/genetics-report/references/qa.md +28 -712
- package/skills/genetics-report/assets/generate-illustrations.py +0 -159
- package/skills/genetics-report/assets/qa.py +0 -198
- package/skills/genetics-report/assets/visual-baseline.py +0 -459
- package/skills/genetics-report/references/textbook-mode.md +0 -111
- package/skills/genetics-report/references/wysiwyg.md +0 -751
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""브리프 표를 읽어 그 문서의 일러스트를 만든다.
|
|
3
|
-
|
|
4
|
-
python3 generate-illustrations.py --brief <문서>/image-briefs.md --out <폴더>
|
|
5
|
-
|
|
6
|
-
**이 스크립트는 장면을 모른다.** 장면과 비율은 전부 브리프 표에서 온다.
|
|
7
|
-
그래야 다른 고객사·다른 도메인의 문서에도 같은 스크립트가 쓰인다.
|
|
8
|
-
|
|
9
|
-
여기 박힌 것은 **스타일 계약**뿐이고, 그 계약은 정본 8장 실측에서 나왔다.
|
|
10
|
-
앞 판들이 실패한 자리를 그대로 적어 둔다 — 지우면 같은 실패가 돌아온다:
|
|
11
|
-
|
|
12
|
-
· 장면을 스크립트에 박아 넣어 문서가 바뀌어도 같은 그림이 나왔다(2026-08-16 반려)
|
|
13
|
-
· 정본을 안 보고 만든 규칙(「큰 면으로」·「표면을 비워라」)이 조형 문법을 지웠다
|
|
14
|
-
· **한쪽을 비우라고 줬다**(2026-08-17~18 반려). 글이 그림 **위에** 얹힌다고 오독한
|
|
15
|
-
결과였다. 실제 배치는 하나의 테두리 안에서 글과 그림이 **좌우로 따로** 서는 것이라
|
|
16
|
-
(계약 4.2절 v1.4) 그림 위에는 아무것도 앉지 않는다 — 비우면 칸의 절반이 죽는다.
|
|
17
|
-
|
|
18
|
-
판정은 `visual-baseline.py check 그림.png` 로 한다.
|
|
19
|
-
"""
|
|
20
|
-
import argparse
|
|
21
|
-
import os
|
|
22
|
-
import re
|
|
23
|
-
import subprocess
|
|
24
|
-
import sys
|
|
25
|
-
import time
|
|
26
|
-
|
|
27
|
-
# ── 스타일 계약 — 정본 `image-briefs.md` 「공통 스타일 계약」의 조형 문법을 영어로 옮긴 것.
|
|
28
|
-
# 요약하지 않는다. 요약이 2026-08-17 사고의 시작이었다.
|
|
29
|
-
STYLE = """Flat line illustration in one consistent set. Clean uniform thin dark outlines (#191A23)
|
|
30
|
-
on pure white, with light gray (#F3F3F3) fills. People have black hair, white shirts with a small
|
|
31
|
-
lime accent garment, and simple drawn faces; keep figure proportions identical across the set.
|
|
32
|
-
Build the scene as a small diagram: icons inside outlined circles, connected by dotted and solid
|
|
33
|
-
curved lines, with small badges - a dark circle holding an X, a lime circle holding a check.
|
|
34
|
-
The one accent colour is lime, exactly the hex #B9FF66 - a bright yellow-green highlighter tone,
|
|
35
|
-
much lighter and more yellow than grass green. Never use a darker or duller green, and never wash it
|
|
36
|
-
out toward pale mint, sage or pastel yellow-green: it must stay vivid and fully saturated. Place the
|
|
37
|
-
lime in several small spots - a garment accent, a filled badge, a small figure, a ground shadow -
|
|
38
|
-
never as one large filled area.
|
|
39
|
-
Draw the outlines confidently thick and solid - the line weight of a printed editorial illustration,
|
|
40
|
-
not a faint hairline sketch. The strokes must be clearly heavy, closer to a bold marker than a pencil.
|
|
41
|
-
Add enough elements that the drawing carries real visual weight: extra small dotted connectors, thin
|
|
42
|
-
arcs and tiny diamonds clustered around the main objects.
|
|
43
|
-
Use the light gray fill only inside a few small shapes such as a badge or a single icon body.
|
|
44
|
-
Never fill a background, a panel, a board, a large circle or a garment with gray - those stay pure
|
|
45
|
-
white with an outline. Gray must cover only a small fraction of the picture.
|
|
46
|
-
Draw the people at a readable size: cropped at the waist, each figure about half the frame height,
|
|
47
|
-
with visible hands and facial features. Keep every figure on the SAME side of the frame - never one
|
|
48
|
-
at each edge.
|
|
49
|
-
The drawing reaches across the whole frame - this is not a half-empty picture.
|
|
50
|
-
No text, no numbers, no logos, no watermarks, no photographic or 3D style, no gradients, no neon,
|
|
51
|
-
no fake UI."""
|
|
52
|
-
|
|
53
|
-
# ── 구도 — 그림은 **자기 칸을 꽉 채운다.**
|
|
54
|
-
#
|
|
55
|
-
# ⚠ 여기 한때 「글이 앉는 쪽 46% 를 연하게 비워 두라」는 문단이 있었다. 글을 그림 위에
|
|
56
|
-
# 얹는 배치를 전제한 것인데, 그 전제가 **오독이었다**(사용자 정정 2026-08-18):
|
|
57
|
-
# 글과 그림은 하나의 테두리 안에서 **좌우로 따로** 선다(계약 4.2절 v1.4).
|
|
58
|
-
# 그림 위에는 아무것도 앉지 않으므로 한쪽을 비울 이유가 없다 — 비우면 칸의 절반이 죽는다.
|
|
59
|
-
COMPOSITION = """Composition: the drawing fills its frame edge to edge as one balanced scene.
|
|
60
|
-
Do not leave one half of the frame empty - there is no text on top of this picture."""
|
|
61
|
-
|
|
62
|
-
MODEL = 'gpt_image_2' # 6종 비교에서 유일하게 정본 선화 품질에 도달 (§2.3)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def rows(brief):
|
|
66
|
-
"""브리프의 자산 표만 읽는다. 네 번째 칸(배치 쪽)은 사람이 대조할 때만 쓴다."""
|
|
67
|
-
out = []
|
|
68
|
-
with open(brief, encoding='utf-8') as fh:
|
|
69
|
-
for ln in fh:
|
|
70
|
-
if not ln.startswith('| **'):
|
|
71
|
-
continue
|
|
72
|
-
c = [x.strip() for x in ln.strip().strip('|').split('|')]
|
|
73
|
-
if len(c) < 4:
|
|
74
|
-
continue
|
|
75
|
-
name = re.sub(r'\*|\s', '', c[0]).lower().replace('chapter', 'ch')
|
|
76
|
-
ar = '16:9' if '16:9' in c[1] else '4:3'
|
|
77
|
-
# 「**왼쪽** — 인물은 오른쪽에」처럼 **한 칸에 두 단어가 다 있다.** 그래서
|
|
78
|
-
# `in` 으로 찾으면 앞에 쓴 쪽이 이겨 3/7 이 반대로 나간다(2026-08-18 드라이런이
|
|
79
|
-
# 잡았다 — 그대로 돌렸으면 21크레딧을 버렸다). **굵게 표시된 첫 토큰**만 읽는다.
|
|
80
|
-
head = re.match(r'\*\*(왼쪽|오른쪽)\*\*', c[3])
|
|
81
|
-
side = None if not head else ('l' if head.group(1) == '왼쪽' else 'r')
|
|
82
|
-
out.append((name, ar, c[2], side))
|
|
83
|
-
return out
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def gen(name, ar, scene, side, out_dir, ref):
|
|
87
|
-
dst = os.path.join(out_dir, f'{name}.png')
|
|
88
|
-
if os.path.exists(dst) and os.path.getsize(dst) > 0:
|
|
89
|
-
print(f' [{name}] 이미 있음')
|
|
90
|
-
return dst
|
|
91
|
-
# `side` 는 **렌더러가 정하는 배치**(홀수 왼쪽/짝수 오른쪽)라 그림에는 영향이 없다.
|
|
92
|
-
# 브리프에 남겨 두는 것은 사람이 장 순서를 바꿀 때 대조하기 위해서다.
|
|
93
|
-
prompt = f'{STYLE}\n\nScene: {scene}\n\n{COMPOSITION}'
|
|
94
|
-
cmd = ['higgsfield', 'generate', 'create', MODEL, '--wait',
|
|
95
|
-
'--aspect_ratio', ar, '--resolution', '2k', '--json']
|
|
96
|
-
if ref:
|
|
97
|
-
cmd += ['--image', ref]
|
|
98
|
-
t = time.time()
|
|
99
|
-
# `rate_limit_reached`(동시 작업 한도 8)는 **기다리면 풀린다.** 재시도가 없던 판은
|
|
100
|
-
# 3장이 통째로 비었다(2026-08-17) — 교보재에 적혀 있던 함정을 스크립트가 안 지켰다.
|
|
101
|
-
blob = ''
|
|
102
|
-
for attempt in range(4):
|
|
103
|
-
r = subprocess.run(cmd + ['--prompt', prompt], capture_output=True, text=True)
|
|
104
|
-
blob = (r.stdout or '') + (r.stderr or '')
|
|
105
|
-
if 'rate_limit_reached' not in blob:
|
|
106
|
-
break
|
|
107
|
-
wait = 45 * (attempt + 1)
|
|
108
|
-
print(f' [{name}] 한도에 걸렸다 — {wait}초 뒤 재시도 ({attempt + 1}/4)', flush=True)
|
|
109
|
-
time.sleep(wait)
|
|
110
|
-
with open(os.path.join(out_dir, f'{name}.json'), 'w', encoding='utf-8') as fh:
|
|
111
|
-
fh.write(blob)
|
|
112
|
-
m = re.search(r'https://[^"\s]+\.(?:png|jpg|jpeg|webp)', blob)
|
|
113
|
-
if not m:
|
|
114
|
-
print(f' [{name}] 실패 {time.time() - t:.0f}초: {blob.strip()[:150]}')
|
|
115
|
-
return None
|
|
116
|
-
subprocess.run(['curl', '-sL', '-o', dst, m.group(0)], check=True)
|
|
117
|
-
print(f' [{name}] 완료 {time.time() - t:.0f}초 {os.path.getsize(dst):,}B', flush=True)
|
|
118
|
-
return dst
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def upload_ref(path):
|
|
122
|
-
u = subprocess.run(['higgsfield', 'upload', 'create', path, '--json'],
|
|
123
|
-
capture_output=True, text=True)
|
|
124
|
-
m = re.search(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', u.stdout or '')
|
|
125
|
-
return m.group(0) if m else None
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def main():
|
|
129
|
-
ap = argparse.ArgumentParser()
|
|
130
|
-
ap.add_argument('--brief', required=True, help='그 문서의 image-briefs.md')
|
|
131
|
-
ap.add_argument('--out', required=True, help='그림을 받을 폴더')
|
|
132
|
-
ap.add_argument('--only', default='', help='쉼표로 끊은 이름만 만든다 (예: ch02,ch05)')
|
|
133
|
-
ap.add_argument('--ref', default='', help='세트 참조 UUID. 주면 첫 장을 올리지 않는다')
|
|
134
|
-
a = ap.parse_args()
|
|
135
|
-
|
|
136
|
-
os.makedirs(a.out, exist_ok=True)
|
|
137
|
-
os.chmod(a.out, 0o700) # 고객 문서용 그림이다. 남이 읽게 두지 않는다.
|
|
138
|
-
rs = rows(a.brief)
|
|
139
|
-
only = {x.strip() for x in a.only.split(',') if x.strip()}
|
|
140
|
-
print(f'브리프에서 {len(rs)}장을 읽었다 — 스크립트는 장면을 모른다')
|
|
141
|
-
print(' 배치(참고): ' + ' '.join(f'{n}:{s or "-"}' for n, _, _, s in rs))
|
|
142
|
-
|
|
143
|
-
ref = a.ref or None
|
|
144
|
-
for i, (name, ar, scene, side) in enumerate(rs):
|
|
145
|
-
if only and name not in only:
|
|
146
|
-
continue
|
|
147
|
-
p = gen(name, ar, scene, side, a.out, ref)
|
|
148
|
-
# 첫 장을 세트 기준으로 올린다. **단 관문을 통과한 뒤에.**
|
|
149
|
-
# 6차에서 hero 의 흐린 라임(#C8E997·거리 55.8)이 참조로 올라가 뒤 6장이 전부
|
|
150
|
-
# 그 색을 물려받았다 — 참조 UUID 는 첫 장의 결함까지 나른다.
|
|
151
|
-
if i == 0 and p and not ref and not only:
|
|
152
|
-
print(f' ↑ 이 장을 먼저 판정할 것: visual-baseline.py check {p}')
|
|
153
|
-
ref = upload_ref(p)
|
|
154
|
-
print(f' 참조 UUID: {ref or "없음(세트 일관성 없이 진행)"}', flush=True)
|
|
155
|
-
print('끝')
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if __name__ == '__main__':
|
|
159
|
-
sys.exit(main())
|
|
@@ -1,198 +0,0 @@
|
|
|
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
|
-
|
|
20
|
-
# ── palette 는 **레포에서** 가져온다 ────────────────────────────────
|
|
21
|
-
#
|
|
22
|
-
# 예전에는 `assets/` 에 `palette.py` 사본이 있어서 위 한 줄이면 됐다. 2026-08-18 에 그 사본을
|
|
23
|
-
# 지웠고(렌더러의 원본은 레포 하나 — render.py 의 refuse_fork 가 `palette.py` 를 금지 목록에
|
|
24
|
-
# 넣어 두었다), **render.py 는 새 방식으로 갔는데 qa.py 는 안 갔다.** 그래서 이 파일은 그날부터
|
|
25
|
-
# `ModuleNotFoundError: No module named 'palette'` 로 죽어 있었다 — 이 저장소의 어느 커밋에도
|
|
26
|
-
# `assets/palette.py` 가 없다(전 역사 0개, 2026-08-22 확인).
|
|
27
|
-
#
|
|
28
|
-
# 찾는 규칙을 **여기서 새로 쓰지 않는다.** render.py 의 것을 그대로 부른다 —
|
|
29
|
-
# 두 벌로 두면 한쪽만 고쳐져 조용히 갈라진다(GENETICS_REPO 로 덮는 길도 함께 따라온다).
|
|
30
|
-
from render import find_builder, refuse_fork # noqa: E402 (경로를 넣은 뒤여야 한다)
|
|
31
|
-
|
|
32
|
-
refuse_fork() # 사본이 다시 생겼으면 여기서 멈춘다
|
|
33
|
-
sys.path.insert(0, str(find_builder().parent)) # <레포>/private/builder
|
|
34
|
-
import palette as P # noqa: E402
|
|
35
|
-
|
|
36
|
-
FONT_ALLOW = {'Pretendard', 'Pretendard Variable', 'Inter', 'Lora', 'Poppins', 'Georgia',
|
|
37
|
-
'맑은 고딕', 'Malgun Gothic', 'JetBrains Mono', '구름 산스 코드',
|
|
38
|
-
'-apple-system', 'BlinkMacSystemFont', 'Apple SD Gothic Neo',
|
|
39
|
-
'Noto Sans KR', 'system-ui', 'sans-serif', 'serif', 'monospace',
|
|
40
|
-
'Segoe UI', 'ui-monospace', 'Menlo'}
|
|
41
|
-
|
|
42
|
-
# 편집 레이어 CSS 가 시작되는 지점. 색 검사에서 이 구간만 도려낸다.
|
|
43
|
-
EDITOR_CSS_MARK = '/* ── 편집 레이어'
|
|
44
|
-
|
|
45
|
-
SLOP_KO = ['혁신적인', '차세대', '재정의하는', '한 차원 높은', '최적화된 솔루션',
|
|
46
|
-
'완벽한 경험', '새로운 패러다임', '고도화된']
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def run(cfg_path):
|
|
50
|
-
cfg = json.loads(pathlib.Path(cfg_path).read_text(encoding='utf-8'))
|
|
51
|
-
base = pathlib.Path(cfg_path).parent
|
|
52
|
-
doc = (base / cfg['out']).read_text(encoding='utf-8')
|
|
53
|
-
# 편집기 소스는 본문이 아니다 — 검사에서 뺀다
|
|
54
|
-
body = doc.split('<body', 1)[1].split('<script', 1)[0]
|
|
55
|
-
text = re.sub(r'<[^>]+>', ' ', body)
|
|
56
|
-
ok, warn, err = [], [], []
|
|
57
|
-
|
|
58
|
-
# 1. 미치환 플레이스홀더
|
|
59
|
-
ph = re.findall(r'\{[A-Za-z_][^}]{0,40}\}', body)
|
|
60
|
-
(ok if not ph else err).append(f'플레이스홀더 잔존 {len(ph)}건 {ph[:3] if ph else ""}')
|
|
61
|
-
|
|
62
|
-
# 2. 가로 스크롤 유발 고정 폭
|
|
63
|
-
# lookbehind 가 없으면 `max-width:1080px` 같은 정상 반응형 코드가 전부 걸린다
|
|
64
|
-
fixed = re.findall(r'(?<![a-z-])width:\s*\d{3,}px', body)
|
|
65
|
-
(ok if not fixed else warn).append(f'고정 px 폭 요소 {len(fixed)}건')
|
|
66
|
-
|
|
67
|
-
# 3. 색 대비 — 문서에 박힌 :root 를 그대로 읽어 잰다.
|
|
68
|
-
# 빌드가 유도한 값이 아니라 **실제로 나간 값**을 재는 것이 요점이다.
|
|
69
|
-
root = re.search(r':root \{(.*?)\n\}', doc, re.S)
|
|
70
|
-
tok = dict(re.findall(r'--([a-z-]+):\s*([^;]+);', root.group(1))) if root else {}
|
|
71
|
-
if not tok:
|
|
72
|
-
err.append(':root 토큰을 찾지 못함')
|
|
73
|
-
else:
|
|
74
|
-
for name, fg, bg, r, need, good in P.audit(tok):
|
|
75
|
-
(ok if good else err).append(f'대비 {name}: {r}:1 (기준 {need})')
|
|
76
|
-
|
|
77
|
-
# 4. 괘선 전용 색이 글자로 샜는가
|
|
78
|
-
# `border-color:var(--mid)` 는 괘선이라 정상 — 앞에 하이픈이 오면 제외한다
|
|
79
|
-
leak = re.findall(r'(?<![a-z-])color:\s*var\(--mid\)', doc)
|
|
80
|
-
(ok if not leak else err).append(f'--mid 를 글자색으로 사용 {len(leak)}건')
|
|
81
|
-
# `border-left-color:var(--accent)` 는 괘선이라 정상이다 — 앞에 하이픈이 오면 제외
|
|
82
|
-
leak2 = re.findall(r'(?<![-a-z])color:\s*var\(--accent\)(?![-a-z])', doc)
|
|
83
|
-
(ok if not leak2 else err).append(f'--accent 를 글자색으로 사용 {len(leak2)}건 (배경 전용)')
|
|
84
|
-
|
|
85
|
-
# 4-b. 팔레트 밖 하드코딩 색 — 인라인 SVG 와 인쇄용 각주가 새는 자리다.
|
|
86
|
-
# 편집기 UI 는 문서 활자와 색 계열을 일부러 분리하므로 그 구간만 도려낸다.
|
|
87
|
-
i, j = doc.find(EDITOR_CSS_MARK), doc.find('</style>', doc.find(EDITOR_CSS_MARK))
|
|
88
|
-
if i < 0:
|
|
89
|
-
warn.append(f'편집기 CSS 마커 «{EDITOR_CSS_MARK}» 를 못 찾음 — 색 검사 범위 확인 필요')
|
|
90
|
-
scan = doc
|
|
91
|
-
else:
|
|
92
|
-
scan = doc[:i] + doc[j:]
|
|
93
|
-
scan = re.sub(r'base64,[A-Za-z0-9+/=]+', 'base64,', scan) # 폰트 페이로드만 지운다
|
|
94
|
-
# ↑ split 으로 자르면 문서 대부분이 함께 날아가 검사가 무의미해진다. 실제로 그랬다.
|
|
95
|
-
hexes = set()
|
|
96
|
-
for h3 in re.findall(r'#([0-9a-fA-F]{3})\b(?![0-9a-fA-F])', scan):
|
|
97
|
-
hexes.add(''.join(c * 2 for c in h3).upper())
|
|
98
|
-
hexes |= {x.upper() for x in re.findall(r'#([0-9a-fA-F]{6})\b', scan)}
|
|
99
|
-
allowed = {v.lstrip('#').upper() for v in tok.values() if v.startswith('#')} | {'FFFFFF', '000000'}
|
|
100
|
-
stray = sorted(hexes - allowed)
|
|
101
|
-
(ok if not stray else warn).append(f'팔레트 밖 하드코딩 색 {stray or "없음"}')
|
|
102
|
-
rgba = re.findall(r'rgba?\([^)]*\)', scan)
|
|
103
|
-
(ok if not rgba else warn).append(f'rgb/rgba 리터럴 {len(rgba)}건 (팔레트 대조 불가)')
|
|
104
|
-
|
|
105
|
-
# 5. 폰트 화이트리스트
|
|
106
|
-
fams = set()
|
|
107
|
-
for m in re.finditer(r'font-family:([^;}]+)', doc):
|
|
108
|
-
for f in m.group(1).split(','):
|
|
109
|
-
fams.add(f.strip().strip('"\''))
|
|
110
|
-
bad = {f for f in fams if f and f not in FONT_ALLOW}
|
|
111
|
-
(ok if not bad else warn).append(f'비표준 폰트 {sorted(bad) or "없음"}')
|
|
112
|
-
|
|
113
|
-
# 6. 폰트 임베드
|
|
114
|
-
if cfg.get('font', {}).get('embed', True):
|
|
115
|
-
m = re.search(r'@font-face \{[^}]*format\("([^"]+)"\)', doc)
|
|
116
|
-
(ok if m else err).append('@font-face 임베드 존재')
|
|
117
|
-
if m:
|
|
118
|
-
(ok if m.group(1) == 'woff2' else err).append(
|
|
119
|
-
f'format 문자열 "{m.group(1)}" (woff2 여야 함 — 다른 값은 조용히 무시된다)')
|
|
120
|
-
|
|
121
|
-
# 7. 구조 일관성 — 모든 건이 같은 구성요소를 갖는가
|
|
122
|
-
n = len(re.findall(r'<article class="case"', body))
|
|
123
|
-
for pat, label, adj in [(r'class="num"', '절 번호', 0),
|
|
124
|
-
(r'class="row r-sym"', '증상', 0),
|
|
125
|
-
(r'class="row r-cause"', '원인', 0),
|
|
126
|
-
(r'class="row r-fix"', '조치', 0),
|
|
127
|
-
(r'class="row r-check"', '확인 방법', 0)]:
|
|
128
|
-
c = len(re.findall(pat, body)) - adj
|
|
129
|
-
(ok if c == n else err).append(f'{label} {c}/{n}')
|
|
130
|
-
# 뺄셈으로 세지 않는다. 범례는 **실제 쓰인 등급만** 렌더하므로 선언 개수를 빼면
|
|
131
|
-
# 어떤 등급이 0건일 때 조용히 틀린다. 세는 자리를 좁히면 틀어질 것이 없다.
|
|
132
|
-
chips = sum(x.count('class="method ')
|
|
133
|
-
for x in re.findall(r'<div class="case-top">.*?</div>', body, re.S))
|
|
134
|
-
(ok if chips == n else err).append(f'건별 확인 등급 칩 {chips}/{n}')
|
|
135
|
-
used = len({c for c in re.findall(r'<td class="c-m"><span class="method [a-z-]+">([^<]+)<', body)})
|
|
136
|
-
legend_n = len(re.findall(r'<div class="lg">', body))
|
|
137
|
-
(ok if legend_n == used else err).append(f'범례 항목 {legend_n}/{used} (실제 쓰인 등급 수)')
|
|
138
|
-
|
|
139
|
-
# 8. 목차·판정표가 본문과 일치하는가 (편집기가 재생성하기 전 초기 상태)
|
|
140
|
-
toc_n = len(re.findall(r'<li><a href="#s\d+-\d+"', doc))
|
|
141
|
-
(ok if toc_n == n else err).append(f'목차 항목 {toc_n}/{n}')
|
|
142
|
-
v_n = len(re.findall(r'<td class="c-sec">', body))
|
|
143
|
-
(ok if v_n == n else err).append(f'판정표 행 {v_n}/{n}')
|
|
144
|
-
|
|
145
|
-
# 9. 절 참조 링크가 실제 존재하는 절을 가리키는가 (죽은 링크는 검수자를 헤매게 한다)
|
|
146
|
-
ids = set(re.findall(r'id="(s\d+-\d+)"', body))
|
|
147
|
-
dead = sorted({h for h in re.findall(r'href="#(s\d+-\d+)"', doc)} - ids)
|
|
148
|
-
(ok if not dead else err).append(f'죽은 절 참조 {dead or "없음"}')
|
|
149
|
-
|
|
150
|
-
# 10. 고객본 원칙
|
|
151
|
-
(ok if '§' not in doc else err).append(f'원고 마커 § {doc.count("§")}건')
|
|
152
|
-
for pat in cfg.get('forbid', []):
|
|
153
|
-
hits = re.findall(pat, doc)
|
|
154
|
-
(ok if not hits else err).append(f'금지 표현 /{pat}/ {len(hits)}건')
|
|
155
|
-
|
|
156
|
-
# 11. 카피 게이트 — 대시 대비 헤드라인 / AI 슬롭
|
|
157
|
-
heads = [re.sub(r'<[^>]+>', '', x).strip()
|
|
158
|
-
for x in re.findall(r'<h[123][^>]*>(.*?)</h[123]>', body, re.S)]
|
|
159
|
-
dash = [h for h in heads if ' — ' in h or ' – ' in h]
|
|
160
|
-
(ok if not dash else err).append(f'대시 대비 헤드라인 {dash or "없음"}')
|
|
161
|
-
slop = [s for s in SLOP_KO if s in text]
|
|
162
|
-
(ok if not slop else err).append(f'AI 슬롭 어휘 {slop or "없음"}')
|
|
163
|
-
|
|
164
|
-
# 12. 접근성 · 단일 파일
|
|
165
|
-
(ok if f'lang="{cfg.get("lang", "ko")}"' in doc else err).append('lang 선언')
|
|
166
|
-
imgs = re.findall(r'<img(?![^>]*\balt=)[^>]*>', body)
|
|
167
|
-
(ok if not imgs else err).append(f'alt 없는 <img> {len(imgs)}건')
|
|
168
|
-
allow = cfg.get('allow_external', [])
|
|
169
|
-
ext = [u for u in re.findall(r'(?:src|href)="(https?://[^"]+)"', doc)
|
|
170
|
-
if not any(a in u for a in allow)]
|
|
171
|
-
(ok if not ext else err).append(f'외부 리소스 {ext[:3] or "없음"}')
|
|
172
|
-
|
|
173
|
-
# 13. 고객본 내보내기 시뮬레이션 — editor.js 의 serialize(customer=true) 와 같은 규칙
|
|
174
|
-
# 표식을 안 붙이면 내보내기 코드는 **조용히 아무것도 못 지운다**. 실제로 그랬다.
|
|
175
|
-
cust = re.sub(r'<!-- REPORT-EDITOR:BEGIN -->.*?<!-- REPORT-EDITOR:END -->', '', doc, flags=re.S)
|
|
176
|
-
cust = re.sub(r'<(style|script)\b[^>]*\bdata-rte-src\b.*?</\1>', '', cust, flags=re.S)
|
|
177
|
-
cust = re.sub(r'\sdata-report-edit="[^"]*"', '', cust, count=1) # body 속성 제거
|
|
178
|
-
cust = re.sub(r'<[^>]*\bdata-internal="true".*?</\w+>', '', cust, flags=re.S)
|
|
179
|
-
residue = [w for w in ('data-rte-ui', 'showSaveFilePicker', 'syncVerdict',
|
|
180
|
-
'data-report-edit', 'data-internal="true"') if w in cust]
|
|
181
|
-
(ok if not residue else err).append(f'고객본에 남는 편집기 흔적 {residue or "없음"}')
|
|
182
|
-
(ok if len(cust) < len(doc) * 0.98 else err).append(
|
|
183
|
-
f'고객본 크기 {len(cust.encode()):,}B (원본 대비 {len(cust)/len(doc):.0%})')
|
|
184
|
-
|
|
185
|
-
size = len(doc.encode())
|
|
186
|
-
print(f'■ QA — {cfg["out"]} {size:,}B 건수 {n}\n')
|
|
187
|
-
for x in err:
|
|
188
|
-
print(' ❌', x)
|
|
189
|
-
for x in warn:
|
|
190
|
-
print(' ⚠️ ', x)
|
|
191
|
-
for x in ok:
|
|
192
|
-
print(' ✅', x)
|
|
193
|
-
print(f'\n통과 {len(ok)} · 경고 {len(warn)} · 실패 {len(err)}')
|
|
194
|
-
return 1 if err else 0
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if __name__ == '__main__':
|
|
198
|
-
sys.exit(run(sys.argv[1] if len(sys.argv) > 1 else 'report.json'))
|