genetics-mcp 2.18.0 → 2.18.1
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/manifest.json +2 -2
- package/bin/x86_64-apple-darwin/gcell +0 -0
- package/package.json +1 -1
- package/skills/genetics-report/SKILL.md +140 -133
- package/skills/genetics-report/assets/example/cases-briefing-designs.json +32 -0
- package/skills/genetics-report/assets/example/plate-pair-after.png +0 -0
- package/skills/genetics-report/assets/example/plate-pair-before.png +0 -0
- package/skills/genetics-report/assets/example/plate-pair.html +25 -0
- package/skills/genetics-report/assets/example/report-briefing-designs.json +736 -0
- package/skills/genetics-report/assets/example/report-briefing.json +27 -22
- package/skills/genetics-report/assets/example/report.json +4 -4
- package/skills/genetics-report/assets/figure.mjs +390 -63
- package/skills/genetics-report/assets/mockshot.mjs +3 -2
- package/skills/genetics-report/assets/pair.mjs +138 -0
- package/skills/genetics-report/assets/shot.mjs +87 -19
- package/skills/genetics-report/references/content-pipeline.md +34 -33
- package/skills/genetics-report/references/history.md +117 -0
- package/skills/genetics-report/references/illustrations.md +16 -12
- package/skills/genetics-report/references/lim-portal-format.md +171 -89
- package/skills/genetics-report/references/linear-read-test.workflow.js +47 -0
- package/skills/genetics-report/references/process.md +141 -91
- package/skills/genetics-report/references/readability.md +46 -23
- package/skills/genetics-report/references/reader-review.workflow.js +157 -29
- package/skills/genetics-report/references/visual-first.md +94 -34
|
@@ -1,54 +1,126 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// 개념도 렌더 + 가독성 게이트 — HTML 한 장을 헤드리스 크롬으로 PNG 로 찍고, **열람 화면(본문 단 760px)에
|
|
3
|
-
// 앉혔을 때 읽히는가**를 references/readability.md 의 규칙표(
|
|
3
|
+
// 앉혔을 때 읽히는가**를 references/readability.md 의 규칙표(도판 층 R0~R10·R13 — 글 층 R11·R12 는 렌더러 몫)로 잰다. 값의 정본은 그 표와 아래 계약 상수다.
|
|
4
4
|
//
|
|
5
|
-
// node figure.mjs <in.html> <out.png> [
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// node figure.mjs <in.html> <out.png> [인자…] (인자 목록은 KNOWN 한 곳 — 인자 없이 돌리면 usage 가 찍힌다)
|
|
6
|
+
// node figure.mjs --batch <plates.json> [--report <report.json>] [--json] (매니페스트 일괄 판정 + 예산 합계)
|
|
7
|
+
// node figure.mjs --spec (계약 상수 JSON — pair.mjs·시험이 읽는다)
|
|
8
8
|
//
|
|
9
9
|
// 왜 이렇게 넓게 재는가: 2026-09-08 「글씨 작아서 문제」 반려의 처방이 「표시 14px 게이트」였는데, 그것을 통과한
|
|
10
|
-
// 그림이 2026-09-09 「가독성에 하자 있는 상태로 만드는 짓을 왜 계속 하느냐」로 다시 반려됐다(
|
|
10
|
+
// 그림이 2026-09-09 「가독성에 하자 있는 상태로 만드는 짓을 왜 계속 하느냐」로 다시 반려됐다(고객사 결정 브리핑의 개념도).
|
|
11
11
|
// 라벨 15px · 행간 1.3 · 라벨 간격 2px · 표식 간 7px · 띠 대비 1.2:1 · 뜻 색 5 · 그림 안 문단 150자 · 1x 손실 인코딩 —
|
|
12
12
|
// 글자 크기 하나만 재고 나머지 아홉을 안 잰 것이다. 그린 뒤 눈으로 보면 이미 늦다. 여기서 전부 센다.
|
|
13
|
+
// 2026-09-14 「A/B 대칭으로 보여줘야 되는 거 아님?」 — 위아래로 쌓이고 틀 안에 글자를 얹은 비교 컷이 ✗0 으로 나갔다.
|
|
14
|
+
// 그래서 캡처 틀의 문법(R13)을 선언 없이 자동 탐지해 잰다(frameOf 한 함수).
|
|
13
15
|
//
|
|
14
|
-
// 규칙(readability.md §1):
|
|
15
|
-
//
|
|
16
|
+
// 규칙(readability.md §1): R0 입력(표시 폭 근거·빈 캡처) · R1 글자 크기(R1c 캡처 안 글자·밀도 미기재) · R2 위계 · R3 행간 ·
|
|
17
|
+
// R4 그림 안 문장 금지 · R5 근접성 · R6 대비 · R7 뜻 색 ≤ 3 · R8 선명도 · R9 세로(78vh) · R10 금칙어 ·
|
|
18
|
+
// R13 캡처 틀 문법(a 좌우 같은 틀 · b 틀 안 글자 0 · c 표식 색 · d 칩 이름표).
|
|
16
19
|
// ✗ 는 실패(exit 3), ⚠ 는 경고. 정당한 예외는 --allow <규칙> 으로 규칙 단위로만 면제한다(면제 사유가 출력에 남는다).
|
|
20
|
+
// 출력은 <out>.tmp.png 로 찍고 ✗0 이면 <out> 으로, ✗ 가 있으면 <out 이름>.fail.png 로 남긴다 — <out> 은 건드리지 않는다.
|
|
17
21
|
// playwright-core 는 gazette-report 스킬의 검사기와 같은 것을 찾아 쓴다 — 여기서 따로 설치하지 않는다.
|
|
18
22
|
import fs from 'node:fs';
|
|
19
23
|
import os from 'node:os';
|
|
20
24
|
import path from 'node:path';
|
|
21
25
|
import { spawnSync } from 'node:child_process';
|
|
22
|
-
import { pathToFileURL } from 'node:url';
|
|
26
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
23
27
|
|
|
24
|
-
// ──
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
// ── 계약 상수 — readability.md §1 · gazette.py PLATE_* · genetics.mjs ASSET_LONG_EDGE/ASSET_MAX_ONE · report_publish.php REPORT_ASSET_MAX_ALL 과 같은 값 ──
|
|
29
|
+
const COLUMN = 760, WIDE = 1030, NARROW_COLUMN = 682, LONG_EDGE = 1400, VH_RATIO = 0.78, VH_BASE = 900;
|
|
30
|
+
const BUDGET_ONE = 80000, BUDGET_ALL = 400000, BUDGET_WARN = 0.9;
|
|
31
|
+
const DENSITY_MIN = 1.8; // R8 출력 px 폭 ÷ 표시 폭 — 하한은 min(이 값, LONG_EDGE ÷ 표시 폭) − 0.01 로 파생
|
|
32
|
+
const SHOT_QUALITY_LADDER = [82, 72, 62, 52]; // genetics.mjs encodeShot 과 같은 사다리(--batch 예산 셈)
|
|
33
|
+
// R13 비교 틀 — 칸 폭은 (표시 폭 − 좌우 여백×2 − 사이) ÷ 2 로 파생한다. 숫자를 문서에 되풀이하지 않는다.
|
|
34
|
+
const PAIR_BASE = { wide: { display: WIDE, side: 10, gap: 16 }, column: { display: COLUMN, side: 20, gap: 20 } };
|
|
35
|
+
const FRAME_BORDER = 1;
|
|
36
|
+
const PAIR = Object.fromEntries(Object.entries(PAIR_BASE).map(([k, v]) => {
|
|
37
|
+
const cell = (v.display - 2 * v.side - v.gap) / 2;
|
|
38
|
+
return [k, { ...v, cell, border: FRAME_BORDER, img: cell - 2 * FRAME_BORDER }];
|
|
39
|
+
}));
|
|
40
|
+
const R13 = { heightTol: 2, widthTol: 2, scaleTol: 0.01, aspectTol: 0.01, bandRatio: 3, chipReach: 64, chipMinLum: 0.45 };
|
|
41
|
+
const R0_TOL = 2; // 소스 폭 ↔ 표시 폭 허용 차(px)
|
|
42
|
+
const PHONE_3X_WIDTHS = [1080, 1125, 1170, 1179, 1242, 1284, 1290, 1320]; // 흔한 휴대폰 3배 캡처 폭
|
|
43
|
+
const BLANK = { sample: 64, ratio: 0.98 }; // 빈 캡처 — 64×64 축소 샘플의 최빈 색 비율
|
|
44
|
+
const CIRCLED = /^[①-⑳]$/u; // ①~⑳ 한 글자 — 틀 안 글자의 유일한 예외(읽는 순서)
|
|
45
|
+
const SPEC = { COLUMN, WIDE, NARROW_COLUMN, LONG_EDGE, VH_RATIO, VH_BASE, maxDisplayH: Math.round(VH_RATIO * VH_BASE),
|
|
46
|
+
BUDGET_ONE, BUDGET_ALL, BUDGET_WARN, DENSITY_MIN, SHOT_QUALITY_LADDER, PAIR, R13, R0_TOL, PHONE_3X_WIDTHS, BLANK };
|
|
47
|
+
|
|
48
|
+
// ── 인자 — 알려진 인자는 이 표 하나. usage 도 여기서 만든다 ──
|
|
49
|
+
const KNOWN = {
|
|
50
|
+
'--width': ['num', 'CSS px(뷰포트 소스 폭, 기본 = 표시 폭)'],
|
|
51
|
+
'--scale': ['scale', 'auto|N'],
|
|
52
|
+
'--selector': ['str', '.wrap'],
|
|
53
|
+
'--display': ['num', `${COLUMN}`],
|
|
54
|
+
'--wide': ['flag', `표시 폭 ${WIDE}`],
|
|
55
|
+
'--min-font': ['num', '15'],
|
|
56
|
+
'--main-font': ['num', '17'],
|
|
57
|
+
'--vh': ['num', `${VH_BASE}`],
|
|
58
|
+
'--img-text': ['num', '캡처 안 가장 작은 글자 CSS px — img data-min-text 가 우선'],
|
|
59
|
+
'--img-dpr': ['num', '캡처 밀도 — img data-dpr 가 우선'],
|
|
60
|
+
'--caption': ['str', '캡션 — 칩 이름표 대조(R13d)'],
|
|
61
|
+
'--forbid': ['str', '정규식'],
|
|
62
|
+
'--allow': ['str', '규칙,…'],
|
|
63
|
+
'--allow-small': ['flag', '= --allow R1'],
|
|
64
|
+
'--json': ['flag', '요약 JSON 한 줄'],
|
|
65
|
+
'--batch': ['str', 'plates.json'],
|
|
66
|
+
'--report': ['str', 'report.json — --batch 의 provider 대조'],
|
|
67
|
+
'--spec': ['flag', '계약 상수 JSON'],
|
|
68
|
+
};
|
|
69
|
+
const USAGE = 'usage: node figure.mjs <in.html> <out.png> '
|
|
70
|
+
+ Object.entries(KNOWN).filter(([k]) => !['--batch', '--report', '--spec'].includes(k))
|
|
71
|
+
.map(([k, [t, h]]) => t === 'flag' ? `[${k}]` : `[${k} <${h}>]`).join(' ')
|
|
72
|
+
+ '\n node figure.mjs --batch <plates.json> [--report <report.json>] [--json]\n node figure.mjs --spec';
|
|
73
|
+
const SHELL_HINT = '셸이 여러 플래그를 한 낱말로 넘겼을 수 있음(zsh 는 $VAR 를 가르지 않는다), 플래그를 그대로 적는다';
|
|
74
|
+
|
|
75
|
+
function parseArgs(argv) {
|
|
76
|
+
const o = {}, pos = [], allowList = [];
|
|
77
|
+
for (let i = 0; i < argv.length; i++) {
|
|
78
|
+
const a = argv[i];
|
|
79
|
+
if (a.startsWith('--')) {
|
|
80
|
+
if (/\s/.test(a) || !KNOWN[a]) return { error: `모르는 인자 「${a}」 — ${SHELL_HINT}` };
|
|
81
|
+
const [kind] = KNOWN[a];
|
|
82
|
+
if (kind === 'flag') { o[a] = true; continue; }
|
|
83
|
+
const v = argv[++i];
|
|
84
|
+
if (v === undefined) return { error: `인자 ${a} 의 값이 없음 — ${a} 뒤에 값을 적는다` };
|
|
85
|
+
if (kind === 'num' && !Number.isFinite(Number(v))) return { error: `인자 ${a} 의 값 「${v}」 이 수가 아님 — ${SHELL_HINT}` };
|
|
86
|
+
if (kind === 'scale' && v !== 'auto' && !Number.isFinite(Number(v))) return { error: `인자 ${a} 의 값 「${v}」 이 auto 도 수도 아님 — ${SHELL_HINT}` };
|
|
87
|
+
if (a === '--allow') allowList.push(v); else o[a] = v;
|
|
88
|
+
} else {
|
|
89
|
+
if (/(^|\s)--[a-z]/.test(a)) return { error: `모르는 인자 「${a}」 — ${SHELL_HINT}` };
|
|
90
|
+
pos.push(a);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { o, pos, allowList };
|
|
34
94
|
}
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
95
|
+
|
|
96
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
97
|
+
if (parsed.error) { console.error(parsed.error); console.error(USAGE); process.exit(2); }
|
|
98
|
+
const { o: A, pos, allowList } = parsed;
|
|
99
|
+
if (A['--spec']) { console.log(JSON.stringify(SPEC)); process.exit(0); }
|
|
100
|
+
if (A['--batch']) process.exit(runBatch(A['--batch'], A['--report'], !!A['--json']));
|
|
101
|
+
const [inHtml, outPng] = pos;
|
|
102
|
+
if (!inHtml || !outPng || pos.length > 2) { console.error(pos.length > 2 ? `위치 인자가 ${pos.length}개 — <in.html> <out.png> 둘만 받는다. ${SHELL_HINT}` : USAGE); process.exit(2); }
|
|
103
|
+
|
|
104
|
+
const has = (k) => !!A[k];
|
|
105
|
+
const num = (k, d) => (A[k] === undefined ? d : Number(A[k]));
|
|
106
|
+
const display = has('--wide') ? WIDE : num('--display', COLUMN);
|
|
107
|
+
const displayBasis = has('--wide') ? '--wide(전폭)' : (A['--display'] !== undefined ? `--display ${A['--display']}` : '기본값(본문 단)');
|
|
108
|
+
const width = num('--width', display); // 소스 폭 기본 = 표시 폭 (1:1)
|
|
109
|
+
const scaleOpt = A['--scale'] ?? 'auto';
|
|
41
110
|
const scale = scaleOpt === 'auto' ? Math.min(2, LONG_EDGE / width) : Number(scaleOpt);
|
|
42
|
-
const selector =
|
|
43
|
-
const minFont =
|
|
44
|
-
const mainFont =
|
|
45
|
-
const vh =
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
111
|
+
const selector = A['--selector'] ?? '.wrap';
|
|
112
|
+
const minFont = num('--min-font', 15);
|
|
113
|
+
const mainFont = num('--main-font', 17);
|
|
114
|
+
const vh = num('--vh', VH_BASE);
|
|
115
|
+
const cliImgText = A['--img-text'] === undefined ? null : Number(A['--img-text']); // 캡처 안 가장 작은 글자의 CSS px
|
|
116
|
+
const cliImgDpr = A['--img-dpr'] === undefined ? null : Number(A['--img-dpr']); // 캡처 밀도(원본 px ÷ CSS px)
|
|
117
|
+
const caption = A['--caption'] ?? null;
|
|
118
|
+
const forbid = A['--forbid'] ? new RegExp(A['--forbid'], 'u') : null;
|
|
119
|
+
const allow = new Set(allowList.join(',').split(',').map(s => s.trim()).filter(Boolean));
|
|
50
120
|
if (has('--allow-small')) allow.add('R1'); // 옛 이름 호환
|
|
51
121
|
const wantJson = has('--json');
|
|
122
|
+
const stem = outPng.replace(/\.png$/i, '');
|
|
123
|
+
const tmpPng = `${stem}.tmp.png`, failPng = `${stem}.fail.png`;
|
|
52
124
|
|
|
53
125
|
// ── 브라우저 ──
|
|
54
126
|
const CANDIDATES = [
|
|
@@ -69,10 +141,9 @@ await pg.goto(pathToFileURL(path.resolve(inHtml)).href);
|
|
|
69
141
|
await pg.waitForTimeout(300);
|
|
70
142
|
const el = await pg.$(selector);
|
|
71
143
|
if (!el) { console.error(`선택자 ${selector} 가 없음`); await b.close(); process.exit(2); }
|
|
72
|
-
await el.screenshot({ path:
|
|
73
|
-
const box = await el.boundingBox();
|
|
144
|
+
await el.screenshot({ path: tmpPng });
|
|
74
145
|
|
|
75
|
-
// ── 탐침:
|
|
146
|
+
// ── 탐침: 글자·도형·표식·캡처·캡처 틀을 색·크기·자리와 함께 읽는다 ──
|
|
76
147
|
const probe = await el.evaluate((root) => {
|
|
77
148
|
const R = root.getBoundingClientRect();
|
|
78
149
|
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); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
|
|
@@ -94,13 +165,54 @@ const probe = await el.evaluate((root) => {
|
|
|
94
165
|
const ratio = (a, b) => { const L1 = lum(a), L2 = lum(b); return (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05); };
|
|
95
166
|
const rgbOf = (s) => { const n = nums(s); return n.length >= 3 ? [n[0], n[1], n[2]] : null; };
|
|
96
167
|
const rel = (r) => ({ x: r.left - R.left, y: r.top - R.top, w: r.width, h: r.height });
|
|
168
|
+
const hidden = (cs) => cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0;
|
|
169
|
+
const SIDES = ['Top', 'Right', 'Bottom', 'Left'];
|
|
170
|
+
const borderOn = (cs) => SIDES.some(s => parseFloat(cs[`border${s}Width`]) > 0 && cs[`border${s}Style`] !== 'none' && alpha(cs[`border${s}Color`]) > 0);
|
|
171
|
+
const borderColor = (cs) => { const s = SIDES.find(s => parseFloat(cs[`border${s}Width`]) > 0 && cs[`border${s}Style`] !== 'none' && alpha(cs[`border${s}Color`]) > 0); return s ? cs[`border${s}Color`] : null; };
|
|
172
|
+
const clips = (cs) => cs.overflowX !== 'visible' || cs.overflowY !== 'visible';
|
|
173
|
+
// R13 캡처 틀 — img 에서 위로 올라가며 **테두리가 보이는 첫 조상(자기 포함)**. 테두리가 없으면 가장 바깥 자르개(overflow),
|
|
174
|
+
// 그것도 없으면 img 자신. 가장 가까운 자르개가 아니라 바깥 자르개인 까닭: 한 틀 안에서 띠 둘로 이어 붙인 재현 컷이 틀 둘로 세지지 않게.
|
|
175
|
+
const frameOf = (img) => {
|
|
176
|
+
let clip = null;
|
|
177
|
+
for (let e = img; e && e !== root; e = e.parentElement) {
|
|
178
|
+
const cs = getComputedStyle(e);
|
|
179
|
+
if (borderOn(cs)) return e;
|
|
180
|
+
if (e !== img && clips(cs)) clip = e;
|
|
181
|
+
}
|
|
182
|
+
return clip || img;
|
|
183
|
+
};
|
|
184
|
+
const shownImgs = [...root.querySelectorAll('img')].filter(e => { for (let x = e; x && x !== root.parentElement; x = x.parentElement) if (hidden(getComputedStyle(x))) return false; return true; });
|
|
185
|
+
const frameEls = [...new Set(shownImgs.map(frameOf))];
|
|
186
|
+
const frames = frameEls.map(f => ({ ...rel(f.getBoundingClientRect()), imgs: [] }));
|
|
187
|
+
const fdescOf = (e) => frameEls.findIndex(f => f !== e && f.contains(e));
|
|
188
|
+
// 요소가 칠한 색(배경·테두리) — 뒤 색에 합성해 보이는 색으로
|
|
189
|
+
const paintsOf = (e, cs) => {
|
|
190
|
+
const out = [];
|
|
191
|
+
const bgc = cs.backgroundColor;
|
|
192
|
+
if (bgc && !/transparent/.test(bgc) && alpha(bgc) > 0) { const a = alpha(bgc), c = rgbOf(bgc), bh = bgOf(e, true); out.push(a >= 1 ? c : [0, 1, 2].map(k => c[k] * a + bh[k] * (1 - a))); }
|
|
193
|
+
const bc = borderColor(cs);
|
|
194
|
+
if (bc) { const a = alpha(bc), c = rgbOf(bc), bh = bgOf(e, true); out.push(a >= 1 ? c : [0, 1, 2].map(k => c[k] * a + bh[k] * (1 - a))); }
|
|
195
|
+
return out;
|
|
196
|
+
};
|
|
197
|
+
|
|
97
198
|
const texts = [], shapes = [], markers = [], imgs = [];
|
|
98
199
|
for (const e of root.querySelectorAll('*')) {
|
|
99
200
|
if (e.closest('script,style')) continue;
|
|
100
201
|
const cs = getComputedStyle(e);
|
|
101
|
-
if (
|
|
202
|
+
if (hidden(cs)) continue;
|
|
102
203
|
const r = e.getBoundingClientRect();
|
|
103
|
-
if (e.tagName === 'IMG') {
|
|
204
|
+
if (e.tagName === 'IMG') {
|
|
205
|
+
if (!shownImgs.includes(e)) continue;
|
|
206
|
+
const fi = frameEls.indexOf(frameOf(e));
|
|
207
|
+
const d = e.dataset, px = (k) => parseFloat(cs[k]) || 0;
|
|
208
|
+
// 그림이 앉는 폭은 내용 상자다 — img 에 테두리를 두르면(가로 띠 캡처) 테두리만큼 배율·가로세로비가 틀려 보인다
|
|
209
|
+
imgs.push({ ...rel(r), w: r.width - px('borderLeftWidth') - px('borderRightWidth') - px('paddingLeft') - px('paddingRight'),
|
|
210
|
+
h: r.height - px('borderTopWidth') - px('borderBottomWidth') - px('paddingTop') - px('paddingBottom'), nat: e.naturalWidth, natH: e.naturalHeight, url: e.currentSrc || e.src || '',
|
|
211
|
+
src: (e.getAttribute('src') || '').split('/').pop().slice(0, 40), frame: fi,
|
|
212
|
+
dpr: d.dpr === undefined ? null : Number(d.dpr), minText: d.minText === undefined ? null : Number(d.minText) });
|
|
213
|
+
frames[fi].imgs.push(imgs.length - 1);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
104
216
|
const own = [...e.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('').replace(/\s+/g, ' ').trim();
|
|
105
217
|
if (own) {
|
|
106
218
|
const px = parseFloat(cs.fontSize);
|
|
@@ -113,8 +225,11 @@ const probe = await el.evaluate((root) => {
|
|
|
113
225
|
let gr = null;
|
|
114
226
|
for (const n of e.childNodes) { if (n.nodeType !== 3 || !n.textContent.trim()) continue; const rg = document.createRange(); rg.selectNodeContents(n); const b = rg.getBoundingClientRect(); if (!b.width) continue; gr = gr ? { left: Math.min(gr.left, b.left), top: Math.min(gr.top, b.top), right: Math.max(gr.right, b.right), bottom: Math.max(gr.bottom, b.bottom) } : { left: b.left, top: b.top, right: b.right, bottom: b.bottom }; }
|
|
115
227
|
const gbox = gr ? { left: gr.left, top: gr.top, width: gr.right - gr.left, height: gr.bottom - gr.top } : r;
|
|
228
|
+
const ownBgRaw = cs.backgroundColor;
|
|
229
|
+
const ownBg = ownBgRaw && !/transparent/.test(ownBgRaw) && alpha(ownBgRaw) > 0 ? bgOf(e, false) : null;
|
|
116
230
|
texts.push({ tag: e.tagName.toLowerCase(), text: own, chars: own.length, px, lh: lh / px, weight: parseInt(cs.fontWeight, 10) || 400,
|
|
117
|
-
color: fgc, bg, contrast: ratio(fgc, bg), lines: Math.max(1, Math.round(gbox.height / lh)), ...rel(gbox)
|
|
231
|
+
color: fgc, bg, contrast: ratio(fgc, bg), lines: Math.max(1, Math.round(gbox.height / lh)), ...rel(gbox),
|
|
232
|
+
box: rel(r), ownBg, paints: paintsOf(e, cs), fdesc: fdescOf(e) });
|
|
118
233
|
} else {
|
|
119
234
|
// 글자 없는 상자 — 배경이나 테두리가 보이면 도형, 가늘면 표식(눈금·핀·점)
|
|
120
235
|
const bgc = cs.backgroundColor, bc = cs.borderTopColor;
|
|
@@ -125,13 +240,40 @@ const probe = await el.evaluate((root) => {
|
|
|
125
240
|
const a = (hasBg ? alpha(bgc) : alpha(bc)) * (parseFloat(cs.opacity) || 1); // 배경 알파 × 요소 불투명도
|
|
126
241
|
const behind = bgOf(e, true);
|
|
127
242
|
const shown = a >= 1 ? col : [0, 1, 2].map(k => col[k] * a + behind[k] * (1 - a));
|
|
128
|
-
const item = { color: shown, alpha: a, contrast: ratio(shown, behind), ...rel(r)
|
|
243
|
+
const item = { color: shown, alpha: a, contrast: ratio(shown, behind), ...rel(r),
|
|
244
|
+
paints: paintsOf(e, cs), fdesc: fdescOf(e), isFrame: frameEls.includes(e), hasImg: !!e.querySelector('img') };
|
|
129
245
|
const thin = (r.width <= 8 || r.height <= 8) && cs.position === 'absolute';
|
|
130
246
|
(thin ? markers : shapes).push(item);
|
|
131
247
|
}
|
|
132
248
|
}
|
|
133
|
-
return { W: R.width, H: R.height, texts, shapes, markers, imgs };
|
|
249
|
+
return { W: R.width, H: R.height, texts, shapes, markers, imgs, frames };
|
|
134
250
|
});
|
|
251
|
+
|
|
252
|
+
// 빈 캡처(R0b) — 원본 바이트를 data: 로 넘겨 64×64 로 줄여 그리고 최빈 색 비율을 센다(file: 이미지를 그대로 그리면 캔버스가 오염된다)
|
|
253
|
+
const blankOf = new Map();
|
|
254
|
+
for (const url of new Set(probe.imgs.map(im => im.url).filter(Boolean))) {
|
|
255
|
+
let dataUrl = null;
|
|
256
|
+
try {
|
|
257
|
+
if (url.startsWith('data:')) dataUrl = url;
|
|
258
|
+
else if (url.startsWith('file:')) {
|
|
259
|
+
const f = fileURLToPath(url); const ext = path.extname(f).toLowerCase();
|
|
260
|
+
const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : ext === '.gif' ? 'image/gif' : 'image/jpeg';
|
|
261
|
+
dataUrl = `data:${mime};base64,${fs.readFileSync(f).toString('base64')}`;
|
|
262
|
+
}
|
|
263
|
+
} catch { dataUrl = null; }
|
|
264
|
+
if (!dataUrl) continue; // 원격 주소 — 세지 않는다
|
|
265
|
+
const share = await pg.evaluate(async ({ dataUrl, n }) => {
|
|
266
|
+
const im = new Image(); im.src = dataUrl;
|
|
267
|
+
try { await im.decode(); } catch { return null; }
|
|
268
|
+
const c = document.createElement('canvas'); c.width = n; c.height = n;
|
|
269
|
+
const x = c.getContext('2d'); x.imageSmoothingEnabled = true; x.imageSmoothingQuality = 'high';
|
|
270
|
+
x.drawImage(im, 0, 0, n, n);
|
|
271
|
+
const d = x.getImageData(0, 0, n, n).data; const count = new Map();
|
|
272
|
+
for (let i = 0; i < d.length; i += 4) { const k = (d[i] >> 3) << 15 | (d[i + 1] >> 3) << 10 | (d[i + 2] >> 3) << 5 | (d[i + 3] >> 3); count.set(k, (count.get(k) || 0) + 1); }
|
|
273
|
+
return Math.max(...count.values()) / (n * n);
|
|
274
|
+
}, { dataUrl, n: BLANK.sample });
|
|
275
|
+
blankOf.set(url, share);
|
|
276
|
+
}
|
|
135
277
|
await b.close();
|
|
136
278
|
|
|
137
279
|
// ── 표시 배율 (readability.md §1 R1·R8·R9) ──
|
|
@@ -141,11 +283,14 @@ const capScale = Math.min(1, LONG_EDGE / Math.max(outW, outH)); // 서버가
|
|
|
141
283
|
const servedW = Math.round(outW * capScale), servedH = Math.round(outH * capScale);
|
|
142
284
|
let eff = display / W; // CSS px → 표시 px (폭 기준)
|
|
143
285
|
const dispH = H * eff, maxH = VH_RATIO * vh;
|
|
286
|
+
const allowSourceH = Math.floor(maxH / eff); // 이 폭에서 R9 를 넘지 않는 소스 높이
|
|
144
287
|
const tall = dispH > maxH; // R9: 포털 max-height 78vh 가 폭까지 깎는다
|
|
145
288
|
if (tall) eff *= maxH / dispH;
|
|
146
289
|
const shownW = Math.round(W * eff);
|
|
147
|
-
const narrowEff = eff * (
|
|
290
|
+
const narrowEff = eff * (NARROW_COLUMN / display); // 창 1000px 일 때 본문 단 682px
|
|
148
291
|
const density = servedW / shownW; // R8: 표시 CSS px 당 원본 px
|
|
292
|
+
const densityMin = Math.min(DENSITY_MIN, LONG_EDGE / display) - 0.01;
|
|
293
|
+
const tallNote = tall ? ' (R9 세로 초과로 폭이 깎인 결과 — 높이부터 줄인다)' : '';
|
|
149
294
|
|
|
150
295
|
// ── 판정 ──
|
|
151
296
|
const fails = [], warns = [], lines = [];
|
|
@@ -156,16 +301,33 @@ const say = (rule, level, msg) => {
|
|
|
156
301
|
if (level === 'fail' && !allowed) fails.push(rule); else warns.push(rule);
|
|
157
302
|
};
|
|
158
303
|
const d1 = (x) => +x.toFixed(1);
|
|
304
|
+
// R13d 칩 ↔ 캡션 대조의 정규화 — 표기(공백·가운뎃점·괄호·줄표·따옴표)를 걷고 견준다. 칩이 「이름 · 부분」이면 앞 이름만으로도 맞다고 본다.
|
|
305
|
+
const norm13 = (s) => String(s).replace(/[\s·・()()「」『』"'“”‘’—–\-~/]/g, '');
|
|
306
|
+
const chipInCaption = (chip, cap) => {
|
|
307
|
+
const c = norm13(cap);
|
|
308
|
+
if (c.includes(norm13(chip))) return true;
|
|
309
|
+
// 칩이 「이름 · 부분」·「위/아래」처럼 여러 조각이면 **조각이 모두** 캡션에 있을 때 맞다고 본다
|
|
310
|
+
// (칩 「도안 C · 위」 ↔ 캡션 「… 도안 C — 한 화면의 위쪽과 아래쪽」). 이름 자체가 다르면 한 조각이 빠져 ⚠ 가 남는다.
|
|
311
|
+
const parts = String(chip).split(/[\s·・/~]+/).map(norm13).filter(Boolean);
|
|
312
|
+
return parts.length > 1 && parts.every(p => c.includes(p));
|
|
313
|
+
};
|
|
159
314
|
const T = probe.texts.map(t => ({ ...t, disp: d1(t.px * eff) }));
|
|
160
315
|
|
|
316
|
+
// R0 입력 — 표시 폭의 근거가 소스와 맞는가(--wide 누락·760 도판에 --wide 는 글자를 부풀리거나 줄여 판정을 뒤집는다)
|
|
317
|
+
if (Math.abs(W - display) > R0_TOL) say('R0', 'fail', `소스 폭 ${Math.round(W)}px 이 표시 폭 ${display}px(${displayBasis}) 과 다릅니다 — 전폭 도판이면 --wide 로, 본문 단이면 ${selector} 폭을 ${COLUMN}px 로`);
|
|
318
|
+
|
|
319
|
+
// R9 세로 — 폭을 깎아 R1 을 뒤집으므로 먼저 본다
|
|
320
|
+
if (tall) say('R9', 'warn', `표시 높이 ${Math.round(dispH)}px > ${Math.round(maxH)}px(78vh) — 포털이 폭을 ${shownW}px 로 깎는다. 이 폭에서 허용 소스 높이 ${allowSourceH}px. 위아래 두 장으로(비교 컷은 가르지 않고 두 칸을 같은 구간으로 자른다 — R13a)`);
|
|
321
|
+
if (H > W * 1.8) say('R9', 'warn', `세로가 폭의 ${(H / W).toFixed(2)}배 — 1.8 배를 넘으면 서버 상한이 폭을 깎는다`);
|
|
322
|
+
|
|
161
323
|
// R1 글자 — 주 글자(글자 수 가중 최빈)와 최소
|
|
162
324
|
const byPx = new Map();
|
|
163
325
|
for (const t of T) byPx.set(t.disp, (byPx.get(t.disp) || 0) + t.chars);
|
|
164
326
|
const mainDisp = [...byPx.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 0;
|
|
165
327
|
const minDisp = T.length ? Math.min(...T.map(t => t.disp)) : 0;
|
|
166
328
|
const smalls = T.filter(t => t.disp < minFont);
|
|
167
|
-
if (T.length && mainDisp < mainFont) say('R1', 'fail', `주 글자 ${mainDisp}px < 본문 ${mainFont}px (소스 ${d1(mainDisp / eff)}px × 배율 ${eff.toFixed(3)})`);
|
|
168
|
-
if (smalls.length) say('R1', 'fail', `표시 ${minFont}px 미만 글자 ${smalls.length}곳 — ` + [...new Set(smalls.map(t => `${t.px}→${t.disp}px「${t.text.slice(0, 14)}」`))].slice(0, 4).join(' · '));
|
|
329
|
+
if (T.length && mainDisp < mainFont) say('R1', 'fail', `주 글자 ${mainDisp}px < 본문 ${mainFont}px (소스 ${d1(mainDisp / eff)}px × 배율 ${eff.toFixed(3)})${tallNote}`);
|
|
330
|
+
if (smalls.length) say('R1', 'fail', `표시 ${minFont}px 미만 글자 ${smalls.length}곳 — ` + [...new Set(smalls.map(t => `${t.px}→${t.disp}px「${t.text.slice(0, 14)}」`))].slice(0, 4).join(' · ') + tallNote);
|
|
169
331
|
|
|
170
332
|
// R2 위계 — 그림 안 제목 금지, 크기 단계, 강조 비율
|
|
171
333
|
const heads = T.filter(t => /^h[1-3]$/.test(t.tag));
|
|
@@ -230,25 +392,108 @@ const hues = new Set();
|
|
|
230
392
|
for (const c of [...T.map(t => t.color), ...probe.shapes.map(s => s.color), ...M.map(m => m.color)]) if (isSaturated(c)) hues.add(Math.floor(hueOf(c) / 30));
|
|
231
393
|
if (hues.size > 3) say('R7', 'fail', `뜻 색 ${hues.size}가지 — 셋 안으로(검정·회색·흰은 세지 않음)`);
|
|
232
394
|
|
|
233
|
-
// R8 선명도 — 밀도
|
|
395
|
+
// R8 선명도 — 밀도(하한은 서버 긴 변 상한에서 파생 — 전폭은 1400 ÷ 1030 이 끝이다)
|
|
234
396
|
if (density < 1) say('R8', 'fail', `원본 ${servedW}px 이 표시 ${shownW}px 보다 작아 늘려 그려진다(흐림) — --scale 2 또는 소스 폭을 표시 폭으로`);
|
|
235
|
-
else if (density <
|
|
236
|
-
|
|
237
|
-
// R9 세로
|
|
238
|
-
if (tall) say('R9', 'warn', `표시 높이 ${Math.round(dispH)}px > ${Math.round(maxH)}px(78vh) — 포털이 폭을 ${shownW}px 로 깎는다. 위아래 두 장으로`);
|
|
239
|
-
if (H > W * 1.8) say('R9', 'warn', `세로가 폭의 ${(H / W).toFixed(2)}배 — 1.8 배를 넘으면 서버 상한이 폭을 깎는다`);
|
|
397
|
+
else if (density < densityMin) say('R8', 'warn', `밀도 ${density.toFixed(2)}x < ${densityMin.toFixed(2)} — --scale auto(=${Math.min(2, LONG_EDGE / W).toFixed(2)}) 로 굽는다`);
|
|
240
398
|
|
|
241
399
|
// R10 금칙어
|
|
242
400
|
if (forbid) for (const t of T) if (forbid.test(t.text)) say('R10', 'fail', `금칙 표기 「${t.text.slice(0, 20)}」`);
|
|
243
401
|
|
|
244
|
-
// 캡처 <img> — 안의 글자는 셀 수 없으니
|
|
245
|
-
const imgRows = probe.imgs.map(im => {
|
|
246
|
-
|
|
402
|
+
// 캡처 <img> — 안의 글자는 셀 수 없으니 그림마다 밀도(data-dpr)와 최소 글자(data-min-text, CSS px)로
|
|
403
|
+
const imgRows = probe.imgs.map(im => {
|
|
404
|
+
const dpr = im.dpr ?? cliImgDpr, minText = im.minText ?? cliImgText;
|
|
405
|
+
const r = im.nat && dpr ? (im.w / (im.nat / dpr)) * eff : null;
|
|
406
|
+
return { ...im, dpr, minText, r, glyph: r === null || minText === null ? null : d1(minText * r) };
|
|
407
|
+
});
|
|
408
|
+
for (const im of imgRows) {
|
|
409
|
+
if (im.dpr === null) say('R1c', 'fail', `캡처 ${im.src}: 캡처 밀도 미기재 — mockshot·shot 2, 휴대폰 실기기 캡처는 기기 배율(<img data-dpr> 또는 --img-dpr)`);
|
|
410
|
+
if (im.minText === null) say('R1c', 'fail', `캡처 ${im.src}: 캡처 안 최소 글자 미기재 — 화면의 가장 작은 글자 CSS px 를 <img data-min-text> 또는 --img-text 로`);
|
|
411
|
+
if (im.dpr !== null && im.dpr !== 3 && PHONE_3X_WIDTHS.includes(im.nat)) say('R1c', 'warn', `캡처 ${im.src}: 원본 폭 ${im.nat}px 은 휴대폰 3배 캡처 폭입니다 — 실기기 캡처면 data-dpr 3 으로(지금 ${im.dpr})`);
|
|
412
|
+
if (im.glyph !== null && im.glyph < minFont) say('R1c', 'fail', `캡처 ${im.src}: 원본 ${im.nat}px ÷ 밀도 ${im.dpr} → 배율 ${im.r.toFixed(3)} → 안의 ${im.minText}px 글자가 ${im.glyph}px — 뷰포트를 좁혀 다시 찍거나 자리만 잘라 싣는다${tallNote}`);
|
|
413
|
+
}
|
|
414
|
+
for (const im of probe.imgs) {
|
|
415
|
+
const share = blankOf.get(im.url);
|
|
416
|
+
if (share !== undefined && share !== null && share >= BLANK.ratio) say('R0b', 'fail', `캡처 ${im.src}: 거의 한 색(${(share * 100).toFixed(1)}%) — 자르기 도구가 백지를 냈을 수 있음. 도판 HTML 틀(overflow·img 오프셋)이나 shot.mjs --clip 으로 다시 자른다`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// R13 캡처 틀 문법 — 틀은 frameOf 가 찾은 것. 비교(틀 둘 이상)면 a·d, 틀이 하나라도 있으면 b·c
|
|
420
|
+
const F = probe.frames;
|
|
421
|
+
const center = (bx) => ({ x: bx.x + bx.w / 2, y: bx.y + bx.h / 2 });
|
|
422
|
+
const inside = (p, f) => p.x >= f.x && p.x <= f.x + f.w && p.y >= f.y && p.y <= f.y + f.h;
|
|
423
|
+
const meets = (a, f) => !(a.x + a.w <= f.x || f.x + f.w <= a.x || a.y + a.h <= f.y || f.y + f.h <= a.y);
|
|
424
|
+
const frameIndexOf = (item) => (item.fdesc >= 0 && meets(item, F[item.fdesc])) ? item.fdesc : F.findIndex(f => inside(center(item), f));
|
|
425
|
+
const fname = (i) => `틀 ${i + 1}`;
|
|
426
|
+
if (F.length >= 2) {
|
|
427
|
+
// a — 좌우로 나란히(세로로 겹치는 틀끼리 한 줄), 같은 높이·폭
|
|
428
|
+
const rows = [];
|
|
429
|
+
for (const i of F.map((_, i) => i).sort((p, q) => F[p].y - F[q].y)) {
|
|
430
|
+
const row = rows.find(r => r.some(j => !(F[i].y + F[i].h <= F[j].y || F[j].y + F[j].h <= F[i].y)));
|
|
431
|
+
if (row) row.push(i); else rows.push([i]);
|
|
432
|
+
}
|
|
433
|
+
if (rows.length > 1) {
|
|
434
|
+
const bands = F.every(f => f.w >= R13.bandRatio * f.h);
|
|
435
|
+
const ws = F.map(f => f.w);
|
|
436
|
+
if (!(bands && Math.max(...ws) - Math.min(...ws) <= R13.widthTol)) {
|
|
437
|
+
say('R13a', 'fail', `비교 틀 ${F.length}개가 위아래로 쌓였습니다(${rows.map(r => r.map(i => `${Math.round(F[i].w)}×${Math.round(F[i].h)}`).join('|')).join(' / ')}px) — 좌우 두 칸 같은 틀로(pair.mjs), 짧은 쪽은 흰 여백으로`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
for (const r of rows.filter(r => r.length >= 2)) {
|
|
441
|
+
const hs = r.map(i => F[i].h), ws = r.map(i => F[i].w);
|
|
442
|
+
if (Math.max(...hs) - Math.min(...hs) > R13.heightTol) say('R13a', 'fail', `나란한 틀 높이가 ${d1(Math.max(...hs) - Math.min(...hs))}px 다릅니다(${hs.map(Math.round).join(' · ')}px) — 큰 쪽 높이에 맞추고 짧은 쪽은 흰 여백으로`);
|
|
443
|
+
if (Math.max(...ws) - Math.min(...ws) > R13.widthTol) say('R13a', 'fail', `나란한 틀 폭이 ${d1(Math.max(...ws) - Math.min(...ws))}px 다릅니다(${ws.map(Math.round).join(' · ')}px) — 칸 폭은 pair.mjs 의 PAIR 칸 폭으로`);
|
|
444
|
+
}
|
|
445
|
+
const scales = imgRows.filter(im => im.nat).map(im => ({ src: im.src, s: im.w / (im.nat / (im.dpr ?? 1)) }));
|
|
446
|
+
if (scales.length >= 2) {
|
|
447
|
+
const lo = Math.min(...scales.map(x => x.s)), hi = Math.max(...scales.map(x => x.s));
|
|
448
|
+
if (hi / lo - 1 > R13.scaleTol) say('R13a', 'fail', `비교 틀의 이미지 배율이 ${((hi / lo - 1) * 100).toFixed(1)}% 다릅니다(${scales.map(x => `${x.src} ${x.s.toFixed(3)}`).join(' · ')}) — 두 칸을 같은 배율로(원본 CSS 폭을 맞춰 찍는다)`);
|
|
449
|
+
}
|
|
450
|
+
// d — 틀마다 위에 밝은 무채색 칩 이름표
|
|
451
|
+
for (let i = 0; i < F.length; i++) {
|
|
452
|
+
const f = F[i];
|
|
453
|
+
const chips = T.filter(t => t.ownBg && t.box.y + t.box.h <= f.y + 1 && t.box.y + t.box.h >= f.y - R13.chipReach && !(t.box.x + t.box.w <= f.x || f.x + f.w <= t.box.x))
|
|
454
|
+
.sort((p, q) => (q.box.y + q.box.h) - (p.box.y + p.box.h));
|
|
455
|
+
const chip = chips[0];
|
|
456
|
+
if (!chip) { say('R13d', 'warn', `${fname(i)} 위에 칩 이름표가 없습니다 — 밝은 무채색 칩 하나에 문서가 그 대상을 부르는 이름을`); continue; }
|
|
457
|
+
if (isSaturated(chip.ownBg)) say('R13d', 'warn', `칩 이름표 「${chip.text.slice(0, 12)}」 가 유채색(${hex(chip.ownBg)})입니다 — 밝은 무채색 바탕으로`);
|
|
458
|
+
else if (lumOf(chip.ownBg) < R13.chipMinLum) say('R13d', 'warn', `칩 이름표 「${chip.text.slice(0, 12)}」 가 검정(${hex(chip.ownBg)})입니다 — 밝은 무채색 바탕으로`);
|
|
459
|
+
// 캡션 대조는 **표기를 걷고** 본다 — 공백·가운뎃점·괄호·줄표만 다른 같은 이름(칩 「성인용 · 8쪽」 ↔ 캡션 「성인용(8쪽)」)에 헛경고가
|
|
460
|
+
// 났다(2026-09-14 실측: 캡션을 준 여덟 장 가운데 다섯 장). 칩이 「이름 · 부분」이면 **「 · 」 앞 이름**만으로도 맞다고 본다
|
|
461
|
+
// (칩 「도안 C · 위」 ↔ 캡션 「… 도안 C — 한 화면의 위쪽과 아래쪽」). 이름 자체가 다른 것(칩 「비표시 뒤」 ↔ 캡션 「빠진 뒤」)은 그대로 ⚠.
|
|
462
|
+
if (caption !== null && !chipInCaption(chip.text, caption)) say('R13d', 'warn', `칩 이름표 「${chip.text}」 가 캡션에 없습니다 — 칩 글자는 문서가 그 대상을 부르는 이름 그대로`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (F.length >= 1) {
|
|
466
|
+
// b — 틀 안 글자 0(원 번호 한 글자만 예외)
|
|
467
|
+
for (const t of T) {
|
|
468
|
+
const fi = frameIndexOf(t);
|
|
469
|
+
if (fi < 0) continue;
|
|
470
|
+
if (!CIRCLED.test(t.text)) say('R13b', 'fail', `${fname(fi)} 안에 글자 「${t.text.slice(0, 16)}」 가 있습니다 — 빈 자리는 회색 점선이 말하고, 이름은 틀 위 칩으로(원 번호 ①~⑳ 한 글자만 예외)`);
|
|
471
|
+
}
|
|
472
|
+
// c — 틀 안 표식은 무채색(검정 실선 = 있는·바뀔 자리, 회색 점선 = 빠진 자리, 원 번호 = 읽는 순서)
|
|
473
|
+
const coloredOf = (item, extra = []) => [...item.paints, ...extra].find(isSaturated);
|
|
474
|
+
for (const s of [...probe.shapes, ...M]) {
|
|
475
|
+
if (s.isFrame || s.hasImg) continue;
|
|
476
|
+
const fi = frameIndexOf(s); if (fi < 0) continue;
|
|
477
|
+
const c = coloredOf(s);
|
|
478
|
+
if (c) say('R13c', 'fail', `${fname(fi)} 안 표식이 유채색(${hex(c)})입니다 — 있는·바뀔 자리는 검정 실선, 빠진 자리는 회색 점선으로(유채색은 캡처 없는 개념도에만)`);
|
|
479
|
+
}
|
|
480
|
+
for (const t of T) {
|
|
481
|
+
const fi = frameIndexOf(t); if (fi < 0 || !CIRCLED.test(t.text)) continue;
|
|
482
|
+
const c = coloredOf(t, [t.color]);
|
|
483
|
+
if (c) say('R13c', 'fail', `${fname(fi)} 안 번호 표식 「${t.text}」 가 유채색(${hex(c)})입니다 — 번호 배지는 검정 원(shot.mjs 기본)으로`);
|
|
484
|
+
}
|
|
485
|
+
// a — 가로세로비 왜곡(틀 하나여도)
|
|
486
|
+
for (const im of imgRows) {
|
|
487
|
+
if (!im.nat || !im.natH || !im.h) continue;
|
|
488
|
+
const distort = Math.abs((im.w / im.h) / (im.nat / im.natH) - 1);
|
|
489
|
+
if (distort > R13.aspectTol) say('R13a', 'fail', `캡처 ${im.src}: 가로세로비가 ${(distort * 100).toFixed(1)}% 늘어났습니다 — img 에 폭만 주고 높이는 비워 둔다(자르기는 틀 overflow 로)`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
247
492
|
|
|
248
493
|
// 예산·provider — cwebp 가 있으면 무손실/손실 크기를 재서 권고
|
|
249
494
|
let lossless = null, lossy = null;
|
|
250
495
|
try {
|
|
251
|
-
const png = fs.readFileSync(
|
|
496
|
+
const png = fs.readFileSync(tmpPng);
|
|
252
497
|
const ll = spawnSync('cwebp', ['-quiet', '-lossless', '-exact', '-o', '-', '--', '-'], { input: png, maxBuffer: 64 * 1024 * 1024 });
|
|
253
498
|
const ly = spawnSync('cwebp', ['-quiet', '-q', '82', '-sharp_yuv', '-o', '-', '--', '-'], { input: png, maxBuffer: 64 * 1024 * 1024 });
|
|
254
499
|
if (ll.status === 0) lossless = ll.stdout.length;
|
|
@@ -261,25 +506,107 @@ if (lossless !== null) {
|
|
|
261
506
|
if (isCapture && lossless <= BUDGET_ONE && lossy !== null && lossless <= lossy) provider = 'deterministic 권고 — 무손실이 손실보다 작다(평면 그림). 캡처가 실제 사진이면 user-provided';
|
|
262
507
|
}
|
|
263
508
|
|
|
264
|
-
// ── 출력 ──
|
|
509
|
+
// ── 출력 확정 — ✗0 일 때만 <out> 을 바꾼다 ──
|
|
510
|
+
const written = fails.length ? failPng : outPng;
|
|
511
|
+
fs.renameSync(tmpPng, written);
|
|
265
512
|
const summary = {
|
|
266
|
-
out: outPng, sourceCss: [Math.round(W), Math.round(H)], outputPx: [outW, outH], servedPx: [servedW, servedH],
|
|
267
|
-
display, shownW, eff: +eff.toFixed(3), narrowEff: +narrowEff.toFixed(3), density: +density.toFixed(2),
|
|
268
|
-
mainFont: mainDisp, minFont: minDisp, mainFontNarrow: d1(mainDisp *
|
|
269
|
-
texts: T.length, shapes: probe.shapes.length, markers: M.length, hues: hues.size, overlaps,
|
|
513
|
+
out: outPng, written, passed: !fails.length, sourceCss: [Math.round(W), Math.round(H)], outputPx: [outW, outH], servedPx: [servedW, servedH],
|
|
514
|
+
display, displayBasis, shownW, eff: +eff.toFixed(3), narrowEff: +narrowEff.toFixed(3), density: +density.toFixed(2), allowSourceH,
|
|
515
|
+
mainFont: mainDisp, minFont: minDisp, mainFontNarrow: d1(mainDisp * NARROW_COLUMN / display), sizeSteps: steps,
|
|
516
|
+
texts: T.length, shapes: probe.shapes.length, markers: M.length, hues: hues.size, overlaps, frames: F.length, capture: isCapture,
|
|
270
517
|
lossless, lossy, provider, fails, warns, allow: [...allow],
|
|
271
518
|
};
|
|
272
|
-
console.log(
|
|
273
|
-
console.log(
|
|
274
|
-
console.log(
|
|
275
|
-
|
|
519
|
+
console.log(`표시 폭 ${display}px — 근거 ${displayBasis}`);
|
|
520
|
+
console.log(`${written} ${outW}x${outH} ${fs.statSync(written).size}B (서버 상한 뒤 ${servedW}x${servedH})`);
|
|
521
|
+
console.log(`판독 — 소스 ${Math.round(W)}×${Math.round(H)} CSS px → 표시 ${shownW}px(배율 ${eff.toFixed(3)}${has('--wide') ? ', 전폭' : ''}) · 밀도 ${density.toFixed(2)}x · 허용 소스 높이 ${allowSourceH}px · 좁은 창(${NARROW_COLUMN}px)이면 배율 ${narrowEff.toFixed(3)}`);
|
|
522
|
+
console.log(` 글자 — 주 ${mainDisp}px(좁은 창 ${summary.mainFontNarrow}) · 최소 ${minDisp}px · 단계 ${steps.join('/')}px · 글자 상자 ${T.length} · 도형 ${probe.shapes.length} · 표식 ${M.length} · 뜻 색 ${hues.size} · 캡처 틀 ${F.length}`);
|
|
523
|
+
for (const im of imgRows) console.log(` 캡처 ${im.src}: 원본 ${im.nat}px · 밀도 ${im.dpr ?? '?'} → 표시 배율 ${im.r === null ? '?' : im.r.toFixed(3)} → 안의 ${im.minText ?? '?'}px 글자 ≈ ${im.glyph ?? '?'}px`);
|
|
276
524
|
if (lossless !== null) console.log(` 인코딩 — 무손실 ${lossless.toLocaleString()}B · 손실 q82 ${lossy?.toLocaleString() ?? '?'}B · 예산 ${BUDGET_ONE.toLocaleString()}B → provider: ${provider}`);
|
|
277
525
|
else console.log(` 인코딩 — cwebp 없음(brew install webp) → provider 권고: ${provider}`);
|
|
278
526
|
for (const l of lines) console.log(' ' + l);
|
|
279
|
-
console.log(fails.length ? `✗ 실패 ${fails.length} · ⚠ 경고 ${warns.length} — readability.md §1 의 규칙 번호를
|
|
527
|
+
console.log(fails.length ? `✗ 실패 ${fails.length} · ⚠ 경고 ${warns.length} — readability.md §1 의 규칙 번호를 보라. ${outPng} 는 건드리지 않았고 판정한 그림은 ${failPng}` : `통과 · ⚠ 경고 ${warns.length}`);
|
|
528
|
+
if (has('--wide')) console.log('이 PNG 는 assets[].wide: true 로 실어야 이 판정이 성립함');
|
|
280
529
|
if (wantJson) console.log(JSON.stringify(summary));
|
|
281
530
|
process.exit(fails.length ? 3 : 0);
|
|
282
531
|
|
|
532
|
+
// ── 일괄 판정 — 매니페스트 [{html, out, args:[…]}] 를 장마다 굽고, provider 대로 인코딩한 크기의 누계를 예산에 댄다 ──
|
|
533
|
+
function runBatch(manifestPath, reportPath, json) {
|
|
534
|
+
let list;
|
|
535
|
+
try { list = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch (e) { console.error(`매니페스트를 못 읽음 — ${manifestPath}: ${e.message}`); return 2; }
|
|
536
|
+
if (!Array.isArray(list) || !list.length) { console.error('매니페스트는 [{html, out, args:[…]}] 배열이어야 함'); return 2; }
|
|
537
|
+
const base = path.dirname(path.resolve(manifestPath));
|
|
538
|
+
for (const [i, p] of list.entries()) {
|
|
539
|
+
if (!p || typeof p.html !== 'string' || typeof p.out !== 'string') { console.error(`매니페스트 ${i + 1}번: html·out 이 문자열이어야 함`); return 2; }
|
|
540
|
+
if (p.args !== undefined && (!Array.isArray(p.args) || p.args.some(a => typeof a !== 'string'))) { console.error(`매니페스트 ${i + 1}번: args 는 문자열 배열이어야 함 — 셸 낱말이 아니라 JSON 배열로`); return 2; }
|
|
541
|
+
const chk = parseArgs(p.args ?? []);
|
|
542
|
+
if (chk.error) { console.error(`매니페스트 ${i + 1}번: ${chk.error}`); return 2; }
|
|
543
|
+
if (chk.pos.length || ['--batch', '--report', '--spec'].some(k => chk.o[k])) { console.error(`매니페스트 ${i + 1}번: args 에는 판정 인자만 — 위치 인자·--batch·--report·--spec 은 넣지 않는다`); return 2; }
|
|
544
|
+
}
|
|
545
|
+
let assets = [];
|
|
546
|
+
if (reportPath) {
|
|
547
|
+
try { assets = JSON.parse(fs.readFileSync(reportPath, 'utf8')).assets ?? []; } catch (e) { console.error(`report.json 을 못 읽음 — ${reportPath}: ${e.message}`); return 2; }
|
|
548
|
+
}
|
|
549
|
+
const self = fileURLToPath(import.meta.url);
|
|
550
|
+
const rows = [];
|
|
551
|
+
let total = 0, cwebpMissing = false, errored = 0;
|
|
552
|
+
for (const p of list) {
|
|
553
|
+
const html = path.resolve(base, p.html), out = path.resolve(base, p.out);
|
|
554
|
+
const r = spawnSync(process.execPath, [self, html, out, ...(p.args ?? []), '--json'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
555
|
+
const outLines = (r.stdout || '').trimEnd().split('\n');
|
|
556
|
+
const jsonLine = [...outLines].reverse().find(l => l.startsWith('{'));
|
|
557
|
+
console.log(`── ${p.html} → ${p.out}`);
|
|
558
|
+
for (const l of outLines) if (l !== jsonLine) console.log(' ' + l);
|
|
559
|
+
if (r.stderr) for (const l of r.stderr.trimEnd().split('\n')) console.log(' ' + l);
|
|
560
|
+
if (!jsonLine || (r.status !== 0 && r.status !== 3)) { errored++; rows.push({ plate: p.html, verdict: `오류 exit ${r.status}`, provider: '-', bytes: null, total }); continue; }
|
|
561
|
+
const s = JSON.parse(jsonLine);
|
|
562
|
+
const asset = assets.find(a => typeof a?.file === 'string' && path.basename(a.file) === path.basename(out));
|
|
563
|
+
const provider = asset?.provider ?? (s.capture ? 'user-provided' : 'deterministic');
|
|
564
|
+
const enc = encodedSize(fs.readFileSync(s.written), provider);
|
|
565
|
+
if (enc.missing) cwebpMissing = true;
|
|
566
|
+
if (enc.bytes !== null) total += enc.bytes;
|
|
567
|
+
rows.push({ plate: p.html, verdict: s.fails.length ? `✗${s.fails.length}` : `통과${s.warns.length ? ` ⚠${s.warns.length}` : ''}`,
|
|
568
|
+
provider: `${provider}${asset ? '' : '(추정)'}`, bytes: enc.bytes, over: enc.over, total, fails: s.fails.length, written: s.written });
|
|
569
|
+
}
|
|
570
|
+
const fmt = (n) => n === null ? '?' : n.toLocaleString();
|
|
571
|
+
console.log(`\n일괄 판정 — ${path.basename(manifestPath)} ${list.length}장`);
|
|
572
|
+
console.log('| # | 도판 | 판정 | provider | 크기 B | 누계 B |');
|
|
573
|
+
console.log('|---|---|---|---|---|---|');
|
|
574
|
+
rows.forEach((x, i) => console.log(`| ${i + 1} | ${x.plate} | ${x.verdict}${x.over ? ' · 장당 상한 초과' : ''} | ${x.provider} | ${fmt(x.bytes)} | ${fmt(x.total)} |`));
|
|
575
|
+
const failedPlates = rows.filter(x => x.fails).length, overPlates = rows.filter(x => x.over).length;
|
|
576
|
+
const pct = ((total / BUDGET_ALL) * 100).toFixed(0);
|
|
577
|
+
console.log(`합계 ${total.toLocaleString()}B / 예산 ${BUDGET_ALL.toLocaleString()}B (${pct}%) · ✗ 도판 ${failedPlates}장${overPlates ? ` · 장당 상한 초과 ${overPlates}장` : ''}${errored ? ` · 오류 ${errored}장` : ''}`);
|
|
578
|
+
let budgetFail = false;
|
|
579
|
+
// cwebp 가 없으면 합계가 0B 로 나온다 — 그대로 exit 0 이면 「예산 안」이라는 **거짓 초록**이다(2026-09-14 검증). 도구 문제이므로 2 로 멈춘다.
|
|
580
|
+
if (cwebpMissing) console.log('✗ cwebp 가 없어 크기를 못 셌습니다 — 합계 0B 는 예산 통과가 아닙니다. brew install webp 로 설치한 뒤 다시');
|
|
581
|
+
if (total > BUDGET_ALL) { budgetFail = true; console.log(`✗ 예산 합계 ${total.toLocaleString()}B 가 ${BUDGET_ALL.toLocaleString()}B 를 넘습니다 — 중복 증거 도판을 빼고 부록 자료 목록에 사유를`); }
|
|
582
|
+
else if (total > BUDGET_ALL * BUDGET_WARN) console.log(`⚠ 예산 ${Math.round(BUDGET_WARN * 100)}% — 합계 ${total.toLocaleString()}B 입니다. 도판을 더하기 전에 뺄 것부터`);
|
|
583
|
+
if (overPlates) console.log(`✗ 장당 상한 ${BUDGET_ONE.toLocaleString()}B 를 넘는 도판 ${overPlates}장 — 발행 때 서버가 거절합니다. 담은 화면 수를 줄인다`);
|
|
584
|
+
if (json) console.log(JSON.stringify({ plates: rows, total, budget: BUDGET_ALL, failedPlates, overPlates, errored, cwebpMissing }));
|
|
585
|
+
if (errored || cwebpMissing) return 2;
|
|
586
|
+
return failedPlates || overPlates || budgetFail ? 3 : 0;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// 발행 도구와 같은 인코딩으로 센다 — deterministic 은 무손실, user-provided 는 q82→52 사다리(긴 변 1400 으로 줄이기만)
|
|
590
|
+
function encodedSize(png, provider) {
|
|
591
|
+
const cwebp = (args) => spawnSync('cwebp', [...args, '-o', '-', '--', '-'], { input: png, maxBuffer: 64 * 1024 * 1024 });
|
|
592
|
+
if (provider !== 'user-provided') {
|
|
593
|
+
const r = cwebp(['-quiet', '-lossless', '-exact']);
|
|
594
|
+
if (r.error) return { bytes: null, missing: true };
|
|
595
|
+
return { bytes: r.status === 0 ? r.stdout.length : null, over: r.status === 0 && r.stdout.length > BUDGET_ONE };
|
|
596
|
+
}
|
|
597
|
+
const w = png.readUInt32BE(16), h = png.readUInt32BE(20);
|
|
598
|
+
const resize = Math.max(w, h) > LONG_EDGE ? (h > w ? ['-resize', '0', String(LONG_EDGE)] : ['-resize', String(LONG_EDGE), '0']) : [];
|
|
599
|
+
let last = null;
|
|
600
|
+
for (const q of SHOT_QUALITY_LADDER) {
|
|
601
|
+
const r = cwebp(['-quiet', '-q', String(q), '-sharp_yuv', ...resize]);
|
|
602
|
+
if (r.error) return { bytes: null, missing: true };
|
|
603
|
+
if (r.status !== 0) return { bytes: null };
|
|
604
|
+
last = r.stdout.length;
|
|
605
|
+
if (last <= BUDGET_ONE) return { bytes: last, over: false };
|
|
606
|
+
}
|
|
607
|
+
return { bytes: last, over: true };
|
|
608
|
+
}
|
|
609
|
+
|
|
283
610
|
// ── 색 도우미 ──
|
|
284
611
|
function hueOf([r, g, b]) { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); const d = max - min; if (!d) return 0; let h; if (max === r) h = ((g - b) / d) % 6; else if (max === g) h = (b - r) / d + 2; else h = (r - g) / d + 4; return ((h * 60) + 360) % 360; }
|
|
285
612
|
function isSaturated([r, g, b]) { const R = r / 255, G = g / 255, B = b / 255; const max = Math.max(R, G, B), min = Math.min(R, G, B); const l = (max + min) / 2; const d = max - min; const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1)); return s >= 0.15 && l > 0.1 && l < 0.95; }
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
//
|
|
4
4
|
// node mockshot.mjs <in.html> <out.png> [--sel .phone] [--dpr 2] [--w 375] [--h 900]
|
|
5
5
|
//
|
|
6
|
-
// 찍은 PNG 는 그대로 싣지 않는다 — 도판 HTML 에 <img> 로 앉힌 뒤 figure.mjs 로 가독성 관문을
|
|
7
|
-
//
|
|
6
|
+
// 찍은 PNG 는 그대로 싣지 않는다 — 도판 HTML 에 <img> 로 앉힌 뒤 figure.mjs 로 가독성 관문을 지난다.
|
|
7
|
+
// 두 칸 비교면 pair.mjs 가 칸 폭(readability §1 R13a — 표시 폭은 §1 기준 창)·배율·data-dpr·data-min-text 를 쓰고 figure.mjs 를 부른다.
|
|
8
|
+
// 한 장이면 `<img data-dpr="2" data-min-text="<화면의 가장 작은 글자 CSS px>">` 로 적는다(--dpr 을 바꿨으면 그 값).
|
|
8
9
|
// playwright-core 와 크롬은 figure.mjs 와 같은 것을 찾아 쓴다 — 여기서 따로 설치하지 않는다.
|
|
9
10
|
import fs from 'node:fs';
|
|
10
11
|
import os from 'node:os';
|