genetics-mcp 2.14.0 → 2.16.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/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 +232 -10
- package/package.json +1 -1
- package/skills/gazette-report/README.md +71 -0
- package/skills/gazette-report/SKILL.md +479 -0
- package/skills/gazette-report/scripts/boundary.py +97 -0
- package/skills/gazette-report/scripts/contentdiff.sh +36 -0
- package/skills/gazette-report/scripts/cssaudit.sh +78 -0
- package/skills/gazette-report/scripts/extract.sh +84 -0
- package/skills/gazette-report/scripts/flexcheck.mjs +28 -0
- package/skills/gazette-report/scripts/fontchk.mjs +62 -0
- package/skills/gazette-report/scripts/kcheck.mjs +146 -0
- package/skills/gazette-report/scripts/package-lock.json +24 -0
- package/skills/gazette-report/scripts/package.json +5 -0
- package/skills/gazette-report/scripts/pdfcheck.sh +34 -0
- package/skills/gazette-report/scripts/read.mjs +85 -0
- package/skills/gazette-report/scripts/sentbreak.py +142 -0
- package/skills/gazette-report/scripts/tone.mjs +172 -0
- package/skills/gazette-report/scripts/tone.test.mjs +118 -0
- package/skills/gazette-report/scripts/typesurvey.mjs +48 -0
- package/skills/gazette-report/template/README.md +89 -0
- package/skills/gazette-report/template/gazette.css +2120 -0
- package/skills/gazette-report/template/skeleton.html +477 -0
- package/skills/genetics-cell-id/SKILL.md +9 -7
- package/skills/genetics-report/SKILL.md +53 -6
- package/skills/genetics-report/assets/example/cases-briefing.json +32 -0
- package/skills/genetics-report/assets/example/report-briefing.json +271 -0
- package/skills/genetics-report/assets/example/report.json +1 -1
- package/skills/genetics-report/references/content-pipeline.md +20 -0
- package/skills/genetics-report/references/lim-portal-format.md +100 -7
- package/skills/genetics-report/references/process.md +11 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# cssaudit.sh — 조판 체계 정적 검사 + 작업 규모 판정
|
|
3
|
+
# 사용: ./cssaudit.sh <스타일시트.css>
|
|
4
|
+
#
|
|
5
|
+
# 브라우저 없이 도는 유일한 검사. 값이 「체계 안에」 있는지를 본다.
|
|
6
|
+
# 마지막에 route(light/standard/heavy)를 낸다 — 얼마나 큰 작업인지 시작 전에 알기 위함.
|
|
7
|
+
#
|
|
8
|
+
# 심각도: S1 한 번만 나와도 고친다 · S2 밀도 초과 시 고친다 · S3 겹칠 때만 본다
|
|
9
|
+
# (심각도 3단계와 route 3경로는 epoko77-ai/im-not-ai (MIT) 의 방법론에서 가져왔다)
|
|
10
|
+
set -uo pipefail
|
|
11
|
+
F="${1:?usage: cssaudit.sh <file.css>}"
|
|
12
|
+
FAIL=0; SCORE=0
|
|
13
|
+
say() { printf '%-4s %-24s %s\n' "$1" "$2" "$3"; }
|
|
14
|
+
|
|
15
|
+
# ── S1 · 체계 밖 값 ───────────────────────────────────────────
|
|
16
|
+
RAW=$(grep -oE 'font-size: *[0-9.]+px' "$F" | wc -l | tr -d ' ')
|
|
17
|
+
HALF=$(grep -cE '[0-9]\.5px' "$F" || true)
|
|
18
|
+
say S1 "활자 raw px" "$RAW (0 — 전부 --t-* 토큰이어야)"
|
|
19
|
+
say S1 "0.5px 값" "$HALF (0 — 눈대중의 증거)"
|
|
20
|
+
[ "$RAW" -ne 0 ] && { FAIL=1; SCORE=$((SCORE+RAW)); }
|
|
21
|
+
[ "$HALF" -ne 0 ] && { FAIL=1; SCORE=$((SCORE+HALF*3)); }
|
|
22
|
+
|
|
23
|
+
OFF=$(grep -oE '\b(padding|margin|gap|row-gap|column-gap)(-[a-z]+)?: *[^;{}]*' "$F" \
|
|
24
|
+
| grep -oE '(^| )-?[0-9]+px' | grep -vE '(^| )(0|-1)px' | sort -u | tr -d ' ' | tr '\n' ' ')
|
|
25
|
+
OFFN=$(echo "$OFF" | wc -w | tr -d ' ')
|
|
26
|
+
say S1 "격자 밖 간격" "${OFF:-없음}"
|
|
27
|
+
[ -n "$OFF" ] && { FAIL=1; SCORE=$((SCORE+OFFN*2)); }
|
|
28
|
+
|
|
29
|
+
# ── S1 · 표 마감 ──────────────────────────────────────────────
|
|
30
|
+
OPEN=$(grep -cE 'tr:last-child[^{]*\{[^}]*border-bottom: *none' "$F" || true)
|
|
31
|
+
EDGE=$(grep -c 'border-bottom: var(--rule-edge)' "$F" || true)
|
|
32
|
+
say S1 "안 닫힌 표" "$OPEN (0 — 없으면 표가 목록으로 읽힌다)"
|
|
33
|
+
say " " "맨 아래 굵은 줄" "$EDGE 곳"
|
|
34
|
+
[ "$OPEN" -ne 0 ] && { FAIL=1; SCORE=$((SCORE+OPEN*5)); }
|
|
35
|
+
|
|
36
|
+
# ── S1 · 미봉책 ───────────────────────────────────────────────
|
|
37
|
+
HACK=$(grep -cE 'box-shadow: *inset' "$F" || true)
|
|
38
|
+
say S1 "inset 띠" "$HACK (0 — 실제 보더를 써라)"
|
|
39
|
+
[ "$HACK" -ne 0 ] && { FAIL=1; SCORE=$((SCORE+HACK*3)); }
|
|
40
|
+
|
|
41
|
+
# ── S2 · 상자·라운드 (밀도) ───────────────────────────────────
|
|
42
|
+
BAD=$(grep -oE 'border-radius: *[^;]+' "$F" | grep -vcE 'var\(--r-(sm|md|lg|pill|ui)\)|: *0( |$)|50%' || true)
|
|
43
|
+
CIRC=$(grep -c 'border-radius: 50%' "$F" || true)
|
|
44
|
+
BOX=$(grep -c 'border: 1px solid' "$F" || true)
|
|
45
|
+
say S2 "허용 밖 반경" "$BAD (0 — 0/2px/50% 만)"
|
|
46
|
+
say " " "원(50%)" "$CIRC 곳 (의미 기호에만)"
|
|
47
|
+
say S2 "1px 테두리 상자" "$BOX (한 자릿수 — 관보는 괘선으로 만든다)"
|
|
48
|
+
[ "$BAD" -ne 0 ] && { FAIL=1; SCORE=$((SCORE+BAD)); }
|
|
49
|
+
[ "$BOX" -gt 9 ] && { FAIL=1; SCORE=$((SCORE+BOX)); }
|
|
50
|
+
|
|
51
|
+
# ── S2 · 활자·굵기 종수 ───────────────────────────────────────
|
|
52
|
+
STEPS=$(grep -oE 'font-size: *var\(--t-[a-z0-9]+\)|font-size: *[0-9.]+px' "$F" | sort -u | wc -l | tr -d ' ')
|
|
53
|
+
# ⚠ 이 둘은 **단일 HTML**(스타일 인라인)에도 걸도록 만들어졌다. 그래서 둘 다 필요하다.
|
|
54
|
+
# · `[^;}]` — 압축 CSS 는 `font-weight:var(--w-bold)}.part{…` 처럼 `;` 가 없어,
|
|
55
|
+
# `[^;]` 로 자르면 뒤 규칙까지 통째로 한 값이 되어 종수가 늘어난다.
|
|
56
|
+
# · `[0-9]+ +[0-9]+` 제외 — @font-face 의 `font-weight: 300 700` 은 가변 축 **범위**지
|
|
57
|
+
# 활자 굵기가 아니다. 폰트를 심은 문서에서만 나타나 없는 지적을 만든다.
|
|
58
|
+
WEIGHTS=$(grep -oE 'font-weight: *[^;}]+' "$F" | grep -vE 'font-weight: *[0-9]+ +[0-9]+' \
|
|
59
|
+
| tr -d ' ' | sort -u | wc -l | tr -d ' ')
|
|
60
|
+
say S2 "활자 단수" "$STEPS (7~10 — 16단이면 스케일이 없는 것)"
|
|
61
|
+
say S2 "굵기 종수" "$WEIGHTS (3 이하 — 많으면 위계가 죽는다)"
|
|
62
|
+
[ "$STEPS" -gt 11 ] && { FAIL=1; SCORE=$((SCORE+STEPS)); }
|
|
63
|
+
[ "$WEIGHTS" -gt 3 ] && { FAIL=1; SCORE=$((SCORE+WEIGHTS*2)); }
|
|
64
|
+
|
|
65
|
+
# ── S3 · 면 ──────────────────────────────────────────────────
|
|
66
|
+
TINT=$(grep -cE 'background: *var\(--(ch|pt)(-surf)?\)|background: *var\(--[a-z]+-(surface|surface-deep)\)' "$F" || true)
|
|
67
|
+
say S3 "배경 면" "$TINT 곳 (간지·표 머리·강조에만)"
|
|
68
|
+
[ "$TINT" -gt 20 ] && SCORE=$((SCORE+TINT/2))
|
|
69
|
+
|
|
70
|
+
# ── route 판정 ────────────────────────────────────────────────
|
|
71
|
+
echo
|
|
72
|
+
if [ "$SCORE" -eq 0 ]; then ROUTE="light — 체계는 서 있다. 마감만 손보면 된다"
|
|
73
|
+
elif [ "$SCORE" -lt 40 ]; then ROUTE="standard — 상자→괘선 + 부분 규격화"
|
|
74
|
+
else ROUTE="heavy — 조판 3체계부터 새로 세운다 (활자·간격·괘선)"
|
|
75
|
+
fi
|
|
76
|
+
echo "지적 점수 $SCORE → route: $ROUTE"
|
|
77
|
+
[ "$FAIL" -eq 0 ] && echo "== 조판 체계 검사: 통과 ==" || echo "== 조판 체계 검사: 지적 있음 =="
|
|
78
|
+
exit $FAIL
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# extract.sh — 레퍼런스 구현에서 template/gazette.css 를 다시 뽑는다.
|
|
3
|
+
#
|
|
4
|
+
# 왜 스크립트인가: 손으로 옮기면 어긋난다. 실제로 report-v2 에서 --t-xs 를 없애고
|
|
5
|
+
# 활자 배정을 고친 뒤 템플릿에는 12px 이 47곳 남아 있었다.
|
|
6
|
+
# 템플릿을 고치지 말고 **원본을 고치고 이걸 다시 돌린다.**
|
|
7
|
+
#
|
|
8
|
+
# 사용: ./extract.sh <원본.css> <나갈.css>
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
python3 - "${1:?원본 css}" "${2:?나갈 css}" <<'PY'
|
|
11
|
+
import io, re, sys
|
|
12
|
+
src, out = sys.argv[1], sys.argv[2]
|
|
13
|
+
s = io.open(src, encoding='utf-8').read()
|
|
14
|
+
|
|
15
|
+
HEAD = """/* gazette.css — 관보(정부간행물) 문서 문법 조판 뼈대
|
|
16
|
+
gazette-report 스킬 · 추출 원본: FI-soft/report-v2 (8종 검사를 통과한 구현)
|
|
17
|
+
※ 손으로 고치지 마라. 원본을 고치고 scripts/extract.sh 를 다시 돌린다.
|
|
18
|
+
|
|
19
|
+
담는 것 — 문서 무관한 것만:
|
|
20
|
+
조판 3체계(활자·굵기·간격) · 가구(표지·이력·차례·활용법·요약·간지·부록·판권지)
|
|
21
|
+
두 단 본문(여백 주석 단) · 위치 브레드크럼 머리띠 · 측면 색인 레일
|
|
22
|
+
표 4괘선 · 확인 강도 표식(●◐○─) · ※ 각주 · 인쇄 페이지 문법
|
|
23
|
+
|
|
24
|
+
고칠 것은 셋뿐:
|
|
25
|
+
1. 장 색 [data-chapter] · PART 색 [data-part-no]
|
|
26
|
+
— 팔레트를 베끼지 말고 **그 문서가 이미 쓰는 상태색**에서 뽑아라
|
|
27
|
+
2. --wide (문서 폭) · --marg (여백 주석 단 폭)
|
|
28
|
+
3. @page 마진 박스의 러닝 푸터 문구
|
|
29
|
+
|
|
30
|
+
외부 의존: Pretendard GOV 웹폰트 하나. JS 라이브러리 0개.
|
|
31
|
+
상호작용(처리 표시·머리띠 추적·진입 모션)은 문서마다 다르므로 여기 없다 —
|
|
32
|
+
skeleton.html 의 「스크립트 계약」 주석 참조.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
# 문서 고유물 — 뼈대에 넣으면 안 되는 것
|
|
38
|
+
# .age 7일 경과 표식 (그 문서만의 시간 축)
|
|
39
|
+
# .roll 숫자 롤 애니메이션 (JS 필요)
|
|
40
|
+
# .has-js JS 진입 모션 (뼈대는 스크립트를 싣지 않는다)
|
|
41
|
+
DROP = re.compile(r'(?:^|[\s,>+~])(?:\.age\b|\.roll\b|\.roll__|\.has-js\b)')
|
|
42
|
+
DROP_KF = {'roll-in', 'roll-out'}
|
|
43
|
+
dropped = []
|
|
44
|
+
|
|
45
|
+
def match_brace(t, i): # t[i] == '{' → 짝이 되는 '}' 위치
|
|
46
|
+
d = 0
|
|
47
|
+
while i < len(t):
|
|
48
|
+
if t[i] == '/' and t[i+1:i+2] == '*': i = t.index('*/', i) + 2; continue
|
|
49
|
+
if t[i] == '{': d += 1
|
|
50
|
+
elif t[i] == '}':
|
|
51
|
+
d -= 1
|
|
52
|
+
if d == 0: return i
|
|
53
|
+
i += 1
|
|
54
|
+
raise ValueError('중괄호가 안 닫힘')
|
|
55
|
+
|
|
56
|
+
def walk(t):
|
|
57
|
+
out, i = [], 0
|
|
58
|
+
while i < len(t):
|
|
59
|
+
if t[i] == '/' and t[i+1:i+2] == '*': # 주석은 그대로
|
|
60
|
+
j = t.index('*/', i) + 2; out.append(t[i:j]); i = j; continue
|
|
61
|
+
if t[i] in ' \t\r\n': out.append(t[i]); i += 1; continue
|
|
62
|
+
b, semi = t.find('{', i), t.find(';', i)
|
|
63
|
+
if b < 0 or (0 <= semi < b): # @import 같은 문장
|
|
64
|
+
j = (semi if semi >= 0 else len(t) - 1) + 1; out.append(t[i:j]); i = j; continue
|
|
65
|
+
e = match_brace(t, b)
|
|
66
|
+
head, body = t[i:b], t[b+1:e]
|
|
67
|
+
sel = head.strip()
|
|
68
|
+
if re.match(r'@(media|supports|layer|container)\b', sel):
|
|
69
|
+
out.append(head + '{' + walk(body) + '}')
|
|
70
|
+
elif sel.startswith('@keyframes') and sel.split()[-1] in DROP_KF:
|
|
71
|
+
dropped.append(sel)
|
|
72
|
+
elif not sel.startswith('@') and DROP.search(head):
|
|
73
|
+
dropped.append(sel.replace('\n', ' ')[:60])
|
|
74
|
+
else:
|
|
75
|
+
out.append(t[i:e+1])
|
|
76
|
+
i = e + 1
|
|
77
|
+
return ''.join(out)
|
|
78
|
+
|
|
79
|
+
res = walk(s)
|
|
80
|
+
res = re.sub(r'\n{3,}', '\n\n', res)
|
|
81
|
+
io.open(out, 'w', encoding='utf-8').write(HEAD + res)
|
|
82
|
+
print(f'추출 {len(s.splitlines())}줄 → {len((HEAD+res).splitlines())}줄 · 뺀 규칙 {len(dropped)}개')
|
|
83
|
+
for d in dropped: print(' -', d)
|
|
84
|
+
PY
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/* flexcheck.mjs — flex/grid 안에서 문장이 토막 나는 곳 탐지
|
|
2
|
+
사용: node flexcheck.mjs <URL>
|
|
3
|
+
flex 컨테이너 안의 <a>·<b> 는 별도 flex 항목이 되어 문장을 조각낸다.
|
|
4
|
+
※ 각주처럼 링크가 섞인 문단에 flex 를 쓰면 「04-2」가 「04-」/「2」로 쪼개진다. */
|
|
5
|
+
import { chromium } from 'playwright-core';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
const URL = process.argv[2];
|
|
8
|
+
if (!URL) { console.error('usage: node flexcheck.mjs <URL>'); process.exit(1); }
|
|
9
|
+
const EXE = process.env.CHROME
|
|
10
|
+
|| `${os.homedir()}/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing`;
|
|
11
|
+
const b = await chromium.launch({ executablePath: EXE });
|
|
12
|
+
const p = await b.newPage({ viewport:{width:1440,height:900} });
|
|
13
|
+
await p.goto(URL,{waitUntil:'networkidle'});
|
|
14
|
+
const bad = await p.evaluate(() => {
|
|
15
|
+
const out=[];
|
|
16
|
+
document.querySelectorAll('body *').forEach(el=>{
|
|
17
|
+
const d=getComputedStyle(el).display;
|
|
18
|
+
if(!/^(inline-)?(flex|grid)$/.test(d)) return;
|
|
19
|
+
const frags=[...el.childNodes].filter(n=>n.nodeType===3 && n.textContent.trim().length>2);
|
|
20
|
+
if (frags.length>=2) out.push({ sel: el.className||el.tagName, display:d,
|
|
21
|
+
frags: frags.length, sample: el.textContent.trim().replace(/\s+/g,' ').slice(0,48) });
|
|
22
|
+
});
|
|
23
|
+
return out;
|
|
24
|
+
});
|
|
25
|
+
console.log(bad.length ? `문장 토막 위험 ${bad.length}건:` : '문장 토막: 없음');
|
|
26
|
+
bad.forEach(x=>console.log(` ✗ .${x.sel} [${x.display}] 조각 ${x.frags} · "${x.sample}"`));
|
|
27
|
+
await b.close();
|
|
28
|
+
process.exit(bad.length ? 1 : 0);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/* fontchk.mjs — 활자 「렌더」 실측 (CSS 토큰화만으로는 안 잡히는 것)
|
|
2
|
+
사용: node fontchk.mjs <URL> [스케일값,쉼표구분]
|
|
3
|
+
예: node fontchk.mjs http://localhost:8780/ 12,14,17,20,24,29,35,42,51,61
|
|
4
|
+
|
|
5
|
+
CSS 가 전부 토큰이어도 화면은 들쭉날쭉할 수 있다. 원인은 셋:
|
|
6
|
+
① clamp 의 vw 구간 — 중간 폭에서 27 · 32.4 · 37.8px 같은 스케일 밖 값이 나온다
|
|
7
|
+
② em/% 상대 크기 — 문맥에 따라 12.9 / 15.6px 로 갈린다
|
|
8
|
+
③ 굵기 종류 과다 — 700 이 요소의 절반을 넘으면 굵기가 위계를 못 나른다
|
|
9
|
+
그래서 여러 폭에서 「렌더된 값」을 세어야 한다. */
|
|
10
|
+
import { chromium } from 'playwright-core';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
|
|
13
|
+
const URL = process.argv[2];
|
|
14
|
+
if (!URL) { console.error('usage: node fontchk.mjs <URL> [12,14,17,...]'); process.exit(1); }
|
|
15
|
+
const SCALE = (process.argv[3] || '').split(',').filter(Boolean).map(Number);
|
|
16
|
+
const EXE = process.env.CHROME
|
|
17
|
+
|| `${os.homedir()}/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing`;
|
|
18
|
+
|
|
19
|
+
const b = await chromium.launch({ executablePath: EXE });
|
|
20
|
+
let fail = 0;
|
|
21
|
+
|
|
22
|
+
for (const W of [1440, 1100, 900, 390]) {
|
|
23
|
+
const p = await b.newPage({ viewport: { width: W, height: 1000 } });
|
|
24
|
+
await p.goto(URL, { waitUntil: 'networkidle' });
|
|
25
|
+
await p.waitForTimeout(400);
|
|
26
|
+
const r = await p.evaluate(() => {
|
|
27
|
+
const size = new Map(), weight = new Map();
|
|
28
|
+
// 굵기 비율은 요소 「개수」가 아니라 「글자 수」로 잰다.
|
|
29
|
+
// 개수로 재면 라벨이 많은 문서(뼈대·차례)가 늘 높게 나온다 — 분모가 틀린 것.
|
|
30
|
+
let boldReading = 0, total = 0, chars = 0, boldChars = 0;
|
|
31
|
+
document.querySelectorAll('body *').forEach(el => {
|
|
32
|
+
if (el.closest('script,style')) return;
|
|
33
|
+
const t = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('').trim();
|
|
34
|
+
if (!t) return;
|
|
35
|
+
const cs = getComputedStyle(el);
|
|
36
|
+
if (cs.display === 'none') return;
|
|
37
|
+
total++;
|
|
38
|
+
const fs = Math.round(parseFloat(cs.fontSize) * 10) / 10;
|
|
39
|
+
size.set(fs, (size.get(fs) || 0) + 1);
|
|
40
|
+
const w = +cs.fontWeight;
|
|
41
|
+
weight.set(w, (weight.get(w) || 0) + 1);
|
|
42
|
+
chars += t.length;
|
|
43
|
+
if (w >= 600) { boldChars += t.length; if (t.length >= 25) boldReading++; }
|
|
44
|
+
});
|
|
45
|
+
return { size: [...size.entries()].sort((a, b) => a[0] - b[0]),
|
|
46
|
+
weight: [...weight.entries()].sort((a, b) => a[0] - b[0]),
|
|
47
|
+
boldReading, total, chars, boldChars };
|
|
48
|
+
});
|
|
49
|
+
const off = SCALE.length ? r.size.filter(([s]) => !SCALE.includes(s)) : [];
|
|
50
|
+
const boldPct = Math.round(r.boldChars / Math.max(1, r.chars) * 100);
|
|
51
|
+
console.log(`\n[${W}px] 크기 ${r.size.length}종 · 굵기 ${r.weight.length}종 · 요소 ${r.total} · 글자 ${r.chars}`);
|
|
52
|
+
console.log(' 크기:', r.size.map(([s, n]) => `${s}×${n}`).join(' '));
|
|
53
|
+
console.log(' 굵기:', r.weight.map(([w, n]) => `${w}×${n}`).join(' '), `— 굵은 글자 ${boldPct}%`);
|
|
54
|
+
if (SCALE.length) { console.log(' 스케일 밖:', off.length ? off.map(([s, n]) => `${s}×${n}`).join(' ') : '없음'); if (off.length) fail++; }
|
|
55
|
+
if (r.weight.length > 3) { console.log(' ✗ 굵기 3종 초과 — 300/400/700 로 줄여라'); fail++; }
|
|
56
|
+
if (boldPct > 30) { console.log(` ✗ 굵은 글자 ${boldPct}% — 굵기가 위계를 못 나른다 (한도 30%)`); fail++; }
|
|
57
|
+
if (r.boldReading > 0) console.log(` · 굵은 「읽는 글」(25자+) ${r.boldReading}건 — 제목·요지가 아니면 내려라`);
|
|
58
|
+
await p.close();
|
|
59
|
+
}
|
|
60
|
+
console.log(`\n== 활자 렌더 검사: ${fail ? '지적 있음' : '통과'} ==`);
|
|
61
|
+
await b.close();
|
|
62
|
+
process.exit(fail ? 1 : 0);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/* kcheck.mjs — 대비·구조·넘침 전수 검증
|
|
2
|
+
사용: node kcheck.mjs <URL> [상태전환셀렉터]
|
|
3
|
+
선택자를 하드코딩하지 않는다. 글자를 가진 요소를 DOM 에서 전부 모아
|
|
4
|
+
클래스 서명별 대표 하나씩 검사한다 — 어느 문서에나 그대로 쓸 수 있다. */
|
|
5
|
+
import { chromium } from 'playwright-core';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
|
|
8
|
+
const URL = process.argv[2];
|
|
9
|
+
if (!URL) { console.error('usage: node kcheck.mjs <URL>'); process.exit(1); }
|
|
10
|
+
const EXE = process.env.CHROME
|
|
11
|
+
|| `${os.homedir()}/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing`;
|
|
12
|
+
|
|
13
|
+
const PROBE = `
|
|
14
|
+
(() => {
|
|
15
|
+
const lum = ([r,g,b]) => { const f=c=>{c/=255;return c<=0.03928?c/12.92:Math.pow((c+0.055)/1.055,2.4);};
|
|
16
|
+
return 0.2126*f(r)+0.7152*f(g)+0.0722*f(b); };
|
|
17
|
+
const nums = s => (s.match(/-?\\d+(\\.\\d+)?/g)||[0,0,0]).map(Number);
|
|
18
|
+
const alpha = s => { const n=nums(s); return n.length>3 ? n[3] : 1; };
|
|
19
|
+
// 반투명 면은 부모 위에 실제로 합성한다. 알파를 버리면 흰↔검으로 오독한다.
|
|
20
|
+
const bgOf = el => {
|
|
21
|
+
const stack=[]; let e=el;
|
|
22
|
+
while(e){ const c=getComputedStyle(e).backgroundColor;
|
|
23
|
+
if(c && !/transparent/.test(c)){ const a=alpha(c); if(a>0){ stack.push([nums(c).slice(0,3),a]); if(a>=1) break; } }
|
|
24
|
+
e=e.parentElement; }
|
|
25
|
+
let out=[255,255,255];
|
|
26
|
+
for(let i=stack.length-1;i>=0;i--){ const [col,a]=stack[i]; out=[0,1,2].map(k=>col[k]*a+out[k]*(1-a)); }
|
|
27
|
+
return out;
|
|
28
|
+
};
|
|
29
|
+
const ratio=(a,b)=>{const L1=lum(a),L2=lum(b);return (Math.max(L1,L2)+0.05)/(Math.min(L1,L2)+0.05);};
|
|
30
|
+
|
|
31
|
+
// 조상의 opacity / filter:grayscale 은 글자 색을 실제로 바꾼다.
|
|
32
|
+
// 계산에 넣지 않으면 「가라앉힌」 요소의 대비 미달을 통째로 놓친다.
|
|
33
|
+
const effAlpha = el => { let a=1,e=el; while(e && e!==document.documentElement){ a*=parseFloat(getComputedStyle(e).opacity||1); e=e.parentElement; } return a; };
|
|
34
|
+
const effGray = el => { let g=0,e=el; while(e && e!==document.documentElement){
|
|
35
|
+
const m=/grayscale\(([\d.]+)%?\)/.exec(getComputedStyle(e).filter||''); if(m) g=Math.max(g, m[1]>1?m[1]/100:+m[1]); e=e.parentElement; } return g; };
|
|
36
|
+
const gray = (c,g) => { if(!g) return c; const y=0.2126*c[0]+0.7152*c[1]+0.0722*c[2]; return c.map(v=>v*(1-g)+y*g); };
|
|
37
|
+
|
|
38
|
+
const seen = new Map();
|
|
39
|
+
document.querySelectorAll('body *').forEach(el => {
|
|
40
|
+
if (el.closest('script,style,head')) return;
|
|
41
|
+
const own = [...el.childNodes].some(n => n.nodeType===3 && n.textContent.trim().length>0);
|
|
42
|
+
if (!own) return;
|
|
43
|
+
const cs = getComputedStyle(el);
|
|
44
|
+
if (cs.display==='none' || cs.visibility==='hidden' || +cs.opacity===0) return;
|
|
45
|
+
// 서명에 부모 클래스를 포함한다. 안 그러면 클래스 없는 <span> 이 문서 전체에서
|
|
46
|
+
// 한 번만 표집되어, 「밴드를 걷었는데 흰 글자가 남은」 부류를 통째로 놓친다.
|
|
47
|
+
const cls = (el.className && typeof el.className==='string') ? el.className.trim().split(/\\s+/).join('.') : el.tagName.toLowerCase();
|
|
48
|
+
const par = (el.parentElement && typeof el.parentElement.className==='string' && el.parentElement.className.trim())
|
|
49
|
+
? el.parentElement.className.trim().split(/\\s+/)[0] + ' > ' : '';
|
|
50
|
+
const sig = par + cls;
|
|
51
|
+
if (seen.has(sig)) return;
|
|
52
|
+
const size=parseFloat(cs.fontSize), bold=+cs.fontWeight>=700;
|
|
53
|
+
const a = effAlpha(el), g = effGray(el);
|
|
54
|
+
const bg = bgOf(el);
|
|
55
|
+
let fg = gray(nums(cs.color).slice(0,3), g);
|
|
56
|
+
if (a < 1) fg = [0,1,2].map(k => fg[k]*a + bg[k]*(1-a)); // 배경 위로 합성
|
|
57
|
+
seen.set(sig, { sig, a:+a.toFixed(2), g:+g.toFixed(2),
|
|
58
|
+
r:+ratio(fg, bg).toFixed(2),
|
|
59
|
+
need:(size>=24||(size>=18.66&&bold))?3:4.5, size:+size.toFixed(1) });
|
|
60
|
+
});
|
|
61
|
+
return [...seen.values()];
|
|
62
|
+
})()`;
|
|
63
|
+
|
|
64
|
+
const b = await chromium.launch({ executablePath: EXE });
|
|
65
|
+
let fails = 0;
|
|
66
|
+
|
|
67
|
+
async function contrast(page, label) {
|
|
68
|
+
const rows = await page.evaluate(PROBE);
|
|
69
|
+
const bad = rows.filter(r => r.r < r.need);
|
|
70
|
+
fails += bad.length;
|
|
71
|
+
console.log(`대비[${label}] 표본 ${rows.length} · AA 미달 ${bad.length}`);
|
|
72
|
+
bad.forEach(r => console.log(` ✗ .${r.sig} ${r.r} (기준 ${r.need}, ${r.size}px`
|
|
73
|
+
+ (r.a<1?`, opacity ${r.a}`:'') + (r.g>0?`, grayscale ${r.g}`:'') + ')'));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const p = await b.newPage({ viewport:{width:1440,height:900} });
|
|
77
|
+
await p.goto(URL,{waitUntil:'networkidle'});
|
|
78
|
+
// IntersectionObserver 진입 모션이 있는 문서: 끝까지 훑고 **멈춘 뒤** 되돌아와야 한다.
|
|
79
|
+
// 곧바로 0 으로 되돌리면 배치된 IO 콜백이 「보이지 않음」으로 뒤집혀 진입 클래스가 붙지 않고,
|
|
80
|
+
// 요소들이 opacity 0 인 채로 남아 대비 미달로 오독된다.
|
|
81
|
+
await p.evaluate(async()=>{
|
|
82
|
+
for(let y=0;y<document.body.scrollHeight;y+=500){ window.scrollTo(0,y); await new Promise(r=>setTimeout(r,60)); }
|
|
83
|
+
await new Promise(r=>setTimeout(r,500)); // IO 콜백이 소진되도록 멈춘다
|
|
84
|
+
window.scrollTo(0,0);
|
|
85
|
+
});
|
|
86
|
+
await p.waitForTimeout(700);
|
|
87
|
+
// 남은 진입 애니메이션은 끝 프레임으로 밀어 놓는다
|
|
88
|
+
await p.evaluate(()=>document.getAnimations().forEach(a=>{ try{a.finish();}catch(e){} }));
|
|
89
|
+
await p.waitForTimeout(200);
|
|
90
|
+
await contrast(p,'기본 1440');
|
|
91
|
+
|
|
92
|
+
// 상태 토글이 있으면 상태별로도 잰다
|
|
93
|
+
const toggles = await p.$$('button[aria-pressed]');
|
|
94
|
+
if (toggles.length) {
|
|
95
|
+
for (const t of toggles.slice(0, Math.ceil(toggles.length/2))) await t.click();
|
|
96
|
+
await p.waitForTimeout(400); await contrast(p,'절반 토글');
|
|
97
|
+
for (const t of toggles) if (await t.getAttribute('aria-pressed') !== 'true') await t.click();
|
|
98
|
+
await p.waitForTimeout(400); await contrast(p,'전체 토글');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const st = await p.evaluate(() => {
|
|
102
|
+
const hs=[...document.querySelectorAll('h1,h2,h3,h4,h5,h6')].map(h=>+h.tagName[1]);
|
|
103
|
+
const jumps=[]; for(let i=1;i<hs.length;i++) if(hs[i]-hs[i-1]>1) jumps.push(`${hs[i-1]}→${hs[i]}`);
|
|
104
|
+
const links=[...document.querySelectorAll('a[href^="#"]')];
|
|
105
|
+
const broken=links.filter(a=>!document.getElementById(a.getAttribute('href').slice(1))).map(a=>a.getAttribute('href'));
|
|
106
|
+
const ids=new Set(),dup=[]; document.querySelectorAll('[id]').forEach(e=>{if(ids.has(e.id))dup.push(e.id);ids.add(e.id);});
|
|
107
|
+
const tables=[...document.querySelectorAll('table')];
|
|
108
|
+
const named=tables.filter(t=>t.getAttribute('aria-labelledby')||t.getAttribute('aria-label')||t.querySelector('caption'));
|
|
109
|
+
const btns=[...document.querySelectorAll('button')];
|
|
110
|
+
return { jumps, links:links.length, broken, dup, tables:tables.length, named:named.length,
|
|
111
|
+
landmarks:['header','nav','main','footer'].map(t=>document.querySelectorAll(t).length).join('/'),
|
|
112
|
+
btns:btns.length, btnsNamed:btns.filter(x=>x.textContent.trim()||x.getAttribute('aria-label')).length,
|
|
113
|
+
lang:document.documentElement.lang, title:!!document.title };
|
|
114
|
+
});
|
|
115
|
+
console.log('레벨 건너뜀', st.jumps.length?st.jumps:'없음', '| 앵커', st.links, '| 깨짐', st.broken.length?st.broken:'없음', '| 중복 id', st.dup.length?st.dup:'없음');
|
|
116
|
+
console.log('표 접근명', `${st.named}/${st.tables}`, '| 버튼 이름', `${st.btnsNamed}/${st.btns}`,
|
|
117
|
+
'| landmark', st.landmarks, '| lang', st.lang, '| title', st.title);
|
|
118
|
+
if (st.named !== st.tables) fails++;
|
|
119
|
+
if (st.jumps.length || st.broken.length || st.dup.length) fails++;
|
|
120
|
+
|
|
121
|
+
await p.emulateMedia({ media:'print' });
|
|
122
|
+
const pr = await p.evaluate(()=>({
|
|
123
|
+
faded:[...document.querySelectorAll('article,li,section')].filter(n=>parseFloat(getComputedStyle(n).opacity)<0.9).length,
|
|
124
|
+
hidden:[...document.querySelectorAll('[class*=rail],[class*=topbar],[class*=notice]')].filter(n=>getComputedStyle(n).display==='none').length }));
|
|
125
|
+
console.log('인쇄: 흐린 본문', pr.faded, '· 크롬 숨김', pr.hidden);
|
|
126
|
+
if (pr.faded) fails++;
|
|
127
|
+
await p.close();
|
|
128
|
+
|
|
129
|
+
const rm = await b.newPage({ viewport:{width:1440,height:900}, reducedMotion:'reduce' });
|
|
130
|
+
await rm.goto(URL,{waitUntil:'networkidle'}); await rm.waitForTimeout(500);
|
|
131
|
+
const h = await rm.evaluate(()=>[...document.querySelectorAll('article,li,section')].filter(n=>parseFloat(getComputedStyle(n).opacity)<0.9).length);
|
|
132
|
+
console.log('reduced-motion: 숨은 본문', h); if (h) fails++;
|
|
133
|
+
await rm.close();
|
|
134
|
+
|
|
135
|
+
for (const w of [360,390,768,1024,1280,1440,1920]) {
|
|
136
|
+
const q = await b.newPage({ viewport:{width:w,height:800} });
|
|
137
|
+
await q.goto(URL,{waitUntil:'networkidle'}); await q.waitForTimeout(300);
|
|
138
|
+
const o = await q.evaluate(()=>({sw:document.documentElement.scrollWidth,cw:document.documentElement.clientWidth}));
|
|
139
|
+
const ov = o.sw>o.cw; if (ov) fails++;
|
|
140
|
+
console.log(`${w}px 넘침 ${ov?'✗ '+o.sw+'/'+o.cw:'✓'}`);
|
|
141
|
+
await q.close();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
console.log(`\n== 지적 합계: ${fails} ==`);
|
|
145
|
+
await b.close();
|
|
146
|
+
process.exit(fails ? 1 : 0);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "scripts",
|
|
3
|
+
"lockfileVersion": 3,
|
|
4
|
+
"requires": true,
|
|
5
|
+
"packages": {
|
|
6
|
+
"": {
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"playwright-core": "^1.62.1"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"node_modules/playwright-core": {
|
|
12
|
+
"version": "1.62.1",
|
|
13
|
+
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
|
14
|
+
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
|
15
|
+
"license": "Apache-2.0",
|
|
16
|
+
"bin": {
|
|
17
|
+
"playwright-core": "cli.js"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# pdfcheck.sh — 실제 PDF 를 만들어 쪽 구조를 확인한다
|
|
3
|
+
# 사용: ./pdfcheck.sh <URL> [출력.pdf]
|
|
4
|
+
# 필요: node + playwright-core, poppler(pdfinfo/pdftotext/pdftoppm)
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
URL="${1:?usage: pdfcheck.sh <URL> [out.pdf]}"
|
|
7
|
+
OUT="${2:-print.pdf}"
|
|
8
|
+
CHROME="${CHROME:-$HOME/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing}"
|
|
9
|
+
|
|
10
|
+
CHROME="$CHROME" URL="$URL" OUT="$OUT" node --input-type=module -e "
|
|
11
|
+
import { chromium } from 'playwright-core';
|
|
12
|
+
const b = await chromium.launch({ executablePath: process.env.CHROME });
|
|
13
|
+
const p = await b.newPage();
|
|
14
|
+
await p.goto(process.env.URL, { waitUntil: 'networkidle' });
|
|
15
|
+
await p.waitForTimeout(800);
|
|
16
|
+
// preferCSSPageSize 를 켜야 @page 의 size/margin 이 그대로 적용된다
|
|
17
|
+
await p.pdf({ path: process.env.OUT, printBackground: true, preferCSSPageSize: true, displayHeaderFooter: false });
|
|
18
|
+
await b.close();
|
|
19
|
+
"
|
|
20
|
+
|
|
21
|
+
N=$(pdfinfo "$OUT" | awk '/^Pages/{print $2}')
|
|
22
|
+
echo "쪽수: $N · $(pdfinfo "$OUT" | grep 'Page size')"
|
|
23
|
+
echo "── 쪽별 첫 줄 / 쪽번호 ──"
|
|
24
|
+
for i in $(seq 1 "$N"); do
|
|
25
|
+
H=$(pdftotext -f "$i" -l "$i" -layout "$OUT" - 2>/dev/null | grep -v '^\s*$' | head -1 | cut -c1-46)
|
|
26
|
+
T=$(pdftotext -f "$i" -l "$i" -layout "$OUT" - 2>/dev/null | grep -v '^\s*$' | tail -1 | cut -c1-46)
|
|
27
|
+
printf 'p%-3s %-48s | %s\n' "$i" "$H" "$T"
|
|
28
|
+
done
|
|
29
|
+
echo "── 확인할 것 ──"
|
|
30
|
+
echo "· 표지가 1쪽 단독인가 · 쪽번호가 없는가"
|
|
31
|
+
echo "· 절/PART 간지가 각각 새 쪽에서 시작하는가"
|
|
32
|
+
echo "· 쪽번호+러닝 푸터가 (쪽수-1) 개인가"
|
|
33
|
+
echo "· 표가 쪽을 넘을 때 thead 가 반복되는가"
|
|
34
|
+
echo "이미지로 보려면: pdftoppm -png -r 96 -f <쪽> -l <쪽> $OUT page"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/* read.mjs — 가독성 전수 실측 (글자/줄 · 행간 · 대비)
|
|
2
|
+
사용: node read.mjs <URL> [폭=1440]
|
|
3
|
+
한글 자폭을 브라우저에서 직접 재서 「글자/줄」을 계산한다.
|
|
4
|
+
em 기준 토큰은 한글에서 어긋난다 — 44em 은 44자가 아니라 51자다. */
|
|
5
|
+
import { chromium } from 'playwright-core';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
const URL = process.argv[2], W = Number(process.argv[3] || 1440);
|
|
8
|
+
if (!URL) { console.error('usage: node read.mjs <URL> [width]'); process.exit(1); }
|
|
9
|
+
const EXE = process.env.CHROME
|
|
10
|
+
|| `${os.homedir()}/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing`;
|
|
11
|
+
|
|
12
|
+
const b = await chromium.launch({ executablePath: EXE });
|
|
13
|
+
const p = await b.newPage({ viewport:{width:W,height:900} });
|
|
14
|
+
await p.goto(URL,{waitUntil:'networkidle'});
|
|
15
|
+
await p.evaluate(async()=>{
|
|
16
|
+
for(let y=0;y<document.body.scrollHeight;y+=500){ window.scrollTo(0,y); await new Promise(r=>setTimeout(r,60)); }
|
|
17
|
+
await new Promise(r=>setTimeout(r,500)); // IO 콜백 소진 후에 되돌아온다
|
|
18
|
+
window.scrollTo(0,0);
|
|
19
|
+
});
|
|
20
|
+
await p.waitForTimeout(700);
|
|
21
|
+
await p.evaluate(()=>document.getAnimations().forEach(a=>{ try{a.finish();}catch(e){} }));
|
|
22
|
+
await p.waitForTimeout(200);
|
|
23
|
+
|
|
24
|
+
const rows = await p.evaluate(() => {
|
|
25
|
+
const lum = ([r,g,b]) => { const f=c=>{c/=255;return c<=0.03928?c/12.92:Math.pow((c+0.055)/1.055,2.4);};
|
|
26
|
+
return 0.2126*f(r)+0.7152*f(g)+0.0722*f(b); };
|
|
27
|
+
const nums = s => (s.match(/-?\d+(\.\d+)?/g)||[0,0,0]).map(Number);
|
|
28
|
+
const alpha = s => { const n=nums(s); return n.length>3?n[3]:1; };
|
|
29
|
+
const bgOf = el => { const st=[]; let e=el;
|
|
30
|
+
while(e){ const c=getComputedStyle(e).backgroundColor;
|
|
31
|
+
if(c&&!/transparent/.test(c)){ const a=alpha(c); if(a>0){ st.push([nums(c).slice(0,3),a]); if(a>=1)break; } } e=e.parentElement; }
|
|
32
|
+
let o=[255,255,255]; for(let i=st.length-1;i>=0;i--){const[c,a]=st[i]; o=[0,1,2].map(k=>c[k]*a+o[k]*(1-a));} return o; };
|
|
33
|
+
const ratio=(a,b)=>{const L1=lum(a),L2=lum(b);return (Math.max(L1,L2)+0.05)/(Math.min(L1,L2)+0.05);};
|
|
34
|
+
|
|
35
|
+
const seen = new Map();
|
|
36
|
+
document.querySelectorAll('body *').forEach(el => {
|
|
37
|
+
if (el.closest('script,style,head,nav')) return;
|
|
38
|
+
const txt = [...el.childNodes].filter(n=>n.nodeType===3).map(n=>n.textContent).join('').trim();
|
|
39
|
+
if (txt.length < 25) return; // 「읽는 글」만 — 라벨·칩 제외
|
|
40
|
+
const cs = getComputedStyle(el);
|
|
41
|
+
if (cs.display==='none' || +cs.opacity===0) return;
|
|
42
|
+
// 경로·코드는 한 덩어리로 읽는 것이라 글줄 지표가 무의미하다
|
|
43
|
+
if (/mono|Menlo|Consolas|Courier/i.test(cs.fontFamily)) return;
|
|
44
|
+
// 서명에 부모 클래스를 포함한다. 안 그러면 클래스 없는 <span> 이 문서 전체에서
|
|
45
|
+
// 한 번만 표집되어, 「밴드를 걷었는데 흰 글자가 남은」 부류를 통째로 놓친다.
|
|
46
|
+
const own = (typeof el.className==='string' && el.className.trim())
|
|
47
|
+
? el.className.trim().split(/\s+/).join('.') : el.tagName.toLowerCase();
|
|
48
|
+
const par = el.parentElement && typeof el.parentElement.className==='string' && el.parentElement.className.trim()
|
|
49
|
+
? el.parentElement.className.trim().split(/\s+/)[0] + ' > ' : '';
|
|
50
|
+
const sig = par + own;
|
|
51
|
+
const prev = seen.get(sig);
|
|
52
|
+
if (prev && prev.len >= el.textContent.trim().length) return;
|
|
53
|
+
|
|
54
|
+
const fs = parseFloat(cs.fontSize), lh = parseFloat(cs.lineHeight)/fs;
|
|
55
|
+
const w = el.getBoundingClientRect().width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
|
56
|
+
const probe = document.createElement('span');
|
|
57
|
+
probe.textContent = '가나다라마바사아자차카타파하가나다라마바'; // 20자
|
|
58
|
+
probe.style.cssText = `position:absolute;visibility:hidden;white-space:nowrap;font:${cs.font}`;
|
|
59
|
+
document.body.appendChild(probe);
|
|
60
|
+
const cw = probe.getBoundingClientRect().width/20; probe.remove();
|
|
61
|
+
seen.set(sig, { sig, n:1, fs:+fs.toFixed(1), lh:+lh.toFixed(2), w:Math.round(w),
|
|
62
|
+
cpl: Math.round(w/cw), ratio:+ratio(nums(cs.color).slice(0,3), bgOf(el)).toFixed(2),
|
|
63
|
+
len: el.textContent.trim().length });
|
|
64
|
+
});
|
|
65
|
+
return [...seen.values()];
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
console.log(`\n══ 가독성 실측 (폭 ${W}px · 표본 ${rows.length}) ══`);
|
|
69
|
+
console.log('선택자'.padEnd(30),'크기','행간','단폭','글자/줄','대비');
|
|
70
|
+
let bad = 0;
|
|
71
|
+
for (const r of rows.sort((a,b)=>b.cpl-a.cpl)) {
|
|
72
|
+
const f=[];
|
|
73
|
+
if (r.cpl > 45) f.push('줄길다');
|
|
74
|
+
if (r.cpl < 14) f.push('줄짧다');
|
|
75
|
+
// 제목은 좁은 행간이 정상이다. 본문 크기(<18px)에만 1.6 을 요구한다.
|
|
76
|
+
if (r.fs < 18 && r.lh < 1.6) f.push('행간좁다');
|
|
77
|
+
if (r.fs >= 18 && r.lh < 1.3) f.push('행간좁다');
|
|
78
|
+
if (r.ratio < 4.5) f.push('대비');
|
|
79
|
+
if (f.length) bad++;
|
|
80
|
+
console.log(('.'+r.sig).slice(0,30).padEnd(30), String(r.fs).padStart(4), String(r.lh).padStart(5),
|
|
81
|
+
String(r.w).padStart(5), String(r.cpl).padStart(6), String(r.ratio).padStart(6), f.length?' ← '+f.join(' '):'');
|
|
82
|
+
}
|
|
83
|
+
console.log(`\n지적: ${bad}건 (기준 한글 14~45자/줄 · 본문 행간 1.6+ / 제목 1.3+ · 대비 4.5+)`);
|
|
84
|
+
await b.close();
|
|
85
|
+
process.exit(bad ? 1 : 0);
|