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,142 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""sentbreak.py — 한글 문서의 「한 문장 한 줄」 조판을 마크업에 새긴다.
|
|
4
|
+
|
|
5
|
+
사용: python3 sentbreak.py <파일.html> (예행 — 세기만)
|
|
6
|
+
python3 sentbreak.py <파일.html> --apply (적용)
|
|
7
|
+
|
|
8
|
+
한글 보고서는 마침표에서 줄을 바꾼다. 문장을 이어 붙인 한글 문단은 기계가 쓴 글처럼 읽힌다.
|
|
9
|
+
그런데 **줄만 바꾸면 안 된다** — 실측(report-v2): 문장의 26%가 글줄 폭을 넘어 접히고,
|
|
10
|
+
좁은 여백 주석 단은 54%다. 접히면 둘째 줄과 다음 문장의 첫 줄이 같은 자리에서 시작해
|
|
11
|
+
구별할 수 없다. 그래서 문장마다 블록으로 세우고 이어지는 줄만 내어쓴다:
|
|
12
|
+
|
|
13
|
+
.sn { display: block; padding-left: 1em; text-indent: -1em; }
|
|
14
|
+
|
|
15
|
+
**낱말은 하나도 바뀌지 않는다.** contentdiff.sh 로 확인할 것.
|
|
16
|
+
멱등이다 — 이미 감싼 블록은 건너뛴다.
|
|
17
|
+
|
|
18
|
+
기각한 대안:
|
|
19
|
+
· <br /> 만 — 접힌 줄을 구별할 수 없다
|
|
20
|
+
· text-indent: -1em — 첫 줄이 아니라 **다음 문장**이 들여써진다 (방향이 거꾸로)
|
|
21
|
+
· text-indent: … each-line
|
|
22
|
+
Chrome 은 CSS.supports 에 true 를 돌려주고 계산값도 `-17px each-line` 으로
|
|
23
|
+
보여 주면서 **렌더에서는 무시한다**(실측). 명세만 있고 구현이 없다.
|
|
24
|
+
"""
|
|
25
|
+
import io, os, re, sys
|
|
26
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
27
|
+
from boundary import _mask, _unmask, SENT_END, INITIAL, TAG
|
|
28
|
+
|
|
29
|
+
# 안에 블록 요소가 또 있으면 그 요소를 따로 처리한다 — 겹쳐 감싸지 않는다
|
|
30
|
+
NESTED = re.compile(r'<(p|li|td|ul|ol|table|div|h[1-6])[\s>]', re.I)
|
|
31
|
+
VOID = {'br', 'img', 'hr', 'input', 'wbr', 'i'} # i 는 이 문서에서 빈 장식 아이콘
|
|
32
|
+
|
|
33
|
+
# ── 무엇을 감싸는가 ────────────────────────────────────────────────────────
|
|
34
|
+
# 「긍정 목록」으로 두면 검사기(tone.mjs)보다 좁아져 곧 어긋난다 — 실제로 뼈대의
|
|
35
|
+
# .cover__lead 가 검사에는 걸리고 적용에는 빠졌다. 그래서 **전부 대상, 예외만 명시**로 뒤집는다.
|
|
36
|
+
#
|
|
37
|
+
# 예외 = 라벨-값 양식 행. 실측으로 고른다 — display 가 flex/grid 인 <p> 는
|
|
38
|
+
# 산문이 아니라 「라벨 | 값」 배치다. report-v2 실측: 아래 넷.
|
|
39
|
+
DENY = {'cover__issuer', 'marg__k', 'apx__t', 'colophon__mark'}
|
|
40
|
+
|
|
41
|
+
# 경계가 있는 블록은 클래스와 무관하게 감싼다 — 그게 규칙 자체다.
|
|
42
|
+
# 아래는 **한 문장짜리도** 감싸는 자리 = 접히면 이어지는 줄을 내어써야 하는 산문.
|
|
43
|
+
# (안 그러면 「두 문장 블록은 들여쓰고 한 문장 블록은 flush」로 두 방식이 섞인다 —
|
|
44
|
+
# 실측: 여백 주석 단에서 Key Point 는 들여쓰고 Advice 는 flush 였다)
|
|
45
|
+
PROSE = {'', 'ask__body', 'part__lede', 'gist__body', 'pt__lede', 'note', 'note--in',
|
|
46
|
+
'notice__text', 'urgent__t', 'colophon__note', 'marg__t', 'cover__lead'}
|
|
47
|
+
PROSE_SPAN = {'hist__c'} # 표 한 칸을 통째로 감싼 인라인 산문
|
|
48
|
+
TAGS = ['p', 'td', 'dd', 'figcaption']
|
|
49
|
+
LIST_SCOPES = ['guide__a', 'limit__list', 'pt__askl'] # 산문 목록만. 차례·색인·표지 메타는 아니다
|
|
50
|
+
|
|
51
|
+
OPEN = lambda tag: re.compile(r'(<%s\b([^>]*)>)(.*?)(</%s>)' % (tag, tag), re.S)
|
|
52
|
+
SCOPE = lambda cls: re.compile(
|
|
53
|
+
r'(<(?:ul|ol|dl)\b[^>]*class="[^"]*\b%s\b[^"]*"[^>]*>)(.*?)(</(?:ul|ol|dl)>)' % re.escape(cls), re.S)
|
|
54
|
+
|
|
55
|
+
def classes(attrs):
|
|
56
|
+
m = re.search(r'class="([^"]*)"', attrs)
|
|
57
|
+
return set(m.group(1).split()) if m else {''}
|
|
58
|
+
|
|
59
|
+
def wrap(inner, single_ok=True):
|
|
60
|
+
"""문장마다 <span class="sn"> 로 감싼다. 낱말은 하나도 바뀌지 않는다."""
|
|
61
|
+
if 'class="sn"' in inner or NESTED.search(inner):
|
|
62
|
+
return inner, 0
|
|
63
|
+
|
|
64
|
+
# ① 글자 하나하나가 원문 어디에서 왔는지 대응표를 만든다.
|
|
65
|
+
# 태그를 먼저 지우고 문장을 찾으면 「… 본 것. <small>…」 처럼
|
|
66
|
+
# **마침표 뒤에 태그가 오는 경계**를 놓치거나, 자를 자리를 태그 안쪽으로 잡는다.
|
|
67
|
+
# 깊이 0(인라인 요소 바깥)의 글만 자를 수 있다 —
|
|
68
|
+
# <span class="x">첫 문장. 둘째 문장.</span> 을 그냥 자르면 span 짝이 깨진다.
|
|
69
|
+
plain, at, depth, top_len = [], [], 0, 0
|
|
70
|
+
pos = 0
|
|
71
|
+
for m in list(TAG.finditer(inner)) + [None]:
|
|
72
|
+
end = m.start() if m else len(inner)
|
|
73
|
+
for k in range(pos, end):
|
|
74
|
+
plain.append(inner[k]); at.append((k, depth))
|
|
75
|
+
if depth == 0: top_len += 1
|
|
76
|
+
if not m: break
|
|
77
|
+
tag = m.group(0)
|
|
78
|
+
t = re.match(r'</?\s*([A-Za-z0-9]+)', tag)
|
|
79
|
+
name = t.group(1).lower() if t else ''
|
|
80
|
+
if name and name not in VOID and not tag.endswith('/>'):
|
|
81
|
+
depth += -1 if tag.startswith('</') else 1
|
|
82
|
+
pos = m.end()
|
|
83
|
+
|
|
84
|
+
flat = ''.join(plain)
|
|
85
|
+
masked, _ = _mask(flat, keep_len=True) # 날짜를 가리되 길이는 그대로 (위치 대응 유지)
|
|
86
|
+
cuts = []
|
|
87
|
+
if True:
|
|
88
|
+
for m in SENT_END.finditer(masked):
|
|
89
|
+
if INITIAL.search(masked[:m.start(1)]): continue
|
|
90
|
+
# 부호와 그 앞 글자가 모두 깊이 0 이어야 자른다
|
|
91
|
+
if at[m.start(1)][1] != 0: continue
|
|
92
|
+
cuts.append((at[m.start(1)][0] + 1, m.end(2) - m.end(1))) # (원문 위치, 지울 공백 수)
|
|
93
|
+
|
|
94
|
+
n = len(cuts)
|
|
95
|
+
if n == 0 and (not single_ok or top_len < 20):
|
|
96
|
+
return inner, 0
|
|
97
|
+
|
|
98
|
+
# ② 뒤에서부터 잘라 넣는다 — 앞 위치가 밀리지 않게
|
|
99
|
+
out = inner
|
|
100
|
+
for cut, ws in reversed(cuts):
|
|
101
|
+
out = out[:cut] + '</span><span class="sn">' + out[cut + ws:]
|
|
102
|
+
|
|
103
|
+
lead = re.match(r'\s*', out).group(0)
|
|
104
|
+
tail = re.search(r'\s*\Z', out).group(0)
|
|
105
|
+
core = out[len(lead):len(out) - len(tail)] if tail else out[len(lead):]
|
|
106
|
+
return f'{lead}<span class="sn">{core}</span>{tail}', n
|
|
107
|
+
|
|
108
|
+
def run(path, write=False):
|
|
109
|
+
s = io.open(path, encoding='utf-8').read()
|
|
110
|
+
per = {}
|
|
111
|
+
|
|
112
|
+
def make_sub(label_of, allow):
|
|
113
|
+
def sub(m):
|
|
114
|
+
open_tag, attrs, inner, close = m.groups()
|
|
115
|
+
cls = classes(attrs)
|
|
116
|
+
if cls & DENY: return m.group(0)
|
|
117
|
+
new, n = wrap(inner, single_ok=bool(cls & allow))
|
|
118
|
+
if new is not inner:
|
|
119
|
+
k = label_of(cls); per[k] = per.get(k, 0) + max(n, 0)
|
|
120
|
+
if n == 0: per[k + ' (한 문장)'] = per.get(k + ' (한 문장)', 0) + 1; per[k] = per[k]
|
|
121
|
+
return open_tag + new + close
|
|
122
|
+
return sub
|
|
123
|
+
|
|
124
|
+
nm = lambda cls, pre: pre + (sorted(cls - {''})[0] if cls - {''} else '(무클래스)')
|
|
125
|
+
# 인라인 산문을 먼저 — 바깥 <td> 가 통째로 삼키기 전에
|
|
126
|
+
s = OPEN('span').sub(make_sub(lambda c: nm(c, 'span.'),
|
|
127
|
+
PROSE_SPAN if True else set()), s) if PROSE_SPAN else s
|
|
128
|
+
for tag in TAGS:
|
|
129
|
+
s = OPEN(tag).sub(make_sub(lambda c, t=tag: nm(c, t + '.'), PROSE), s)
|
|
130
|
+
for sc in LIST_SCOPES:
|
|
131
|
+
sub = make_sub(lambda c, sc=sc: f'{sc} > li', PROSE)
|
|
132
|
+
s = SCOPE(sc).sub(lambda mo: mo.group(1) + OPEN('li').sub(sub, mo.group(2)) + mo.group(3), s)
|
|
133
|
+
|
|
134
|
+
if write: io.open(path, 'w', encoding='utf-8').write(s)
|
|
135
|
+
return s, sum(v for k, v in per.items() if '한 문장' not in k), sorted(per.items(), key=lambda kv: -kv[1])
|
|
136
|
+
|
|
137
|
+
if __name__ == '__main__':
|
|
138
|
+
path = sys.argv[1]; write = '--apply' in sys.argv
|
|
139
|
+
s, total, per = run(path, write)
|
|
140
|
+
for k, v in per:
|
|
141
|
+
if v: print(f' {k:<26} {v:>3}')
|
|
142
|
+
print(f'{"적용" if write else "예행"} 경계 {total}곳 · <span class="sn"> {s.count(chr(60) + "span class=" + chr(34) + "sn" + chr(34))}개')
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/* tone.mjs — 보고서 문체 검사
|
|
2
|
+
사용: node tone.mjs <파일.html|.md> (모듈로는 `import { analyze }`)
|
|
3
|
+
① 경어체 잔존(0 이어야 함) ② 늘어짐 지표(밀도 기반) ②b 문체 유형 ③ 한 문장 한 줄
|
|
4
|
+
|
|
5
|
+
②의 분류·처방은 epoko77-ai/im-not-ai (MIT) 의 「AI 한글 티 분류 체계」에서
|
|
6
|
+
**관보 보고서에도 그대로 해당하는 항목만** 골라 왔다. 그쪽은 산문 윤문 도구이므로
|
|
7
|
+
번역투(A)·영어 인용(B)·리듬 균일성(E)은 가져오지 않는다 —
|
|
8
|
+
개조식은 리듬이 균일한 것이 정상이다.
|
|
9
|
+
|
|
10
|
+
②b(2026-08-27)는 사용자 반려 사유에서 회수한 넷이다 — 학생 보고서투 · 완충어 ·
|
|
11
|
+
근거 없는 총평 · 단독 부정 종결. 무엇을 재는가의 서술은 genetics-report 스킬
|
|
12
|
+
references/content-pipeline.md §6.4 에 있고, **집행은 여기뿐이다.**
|
|
13
|
+
⚠ 단독 부정 종결에서 「주어가 사람·조직·국가인가」는 기계가 못 본다 —
|
|
14
|
+
그 판정은 사람의 눈 검수 몫이다(genetics-report references/process.md 7단계).
|
|
15
|
+
|
|
16
|
+
심각도: S1 한 번만 나와도 고친다 · S2 밀도 초과 시 고친다 · S3 겹칠 때만 본다 */
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import { pathToFileURL } from 'url';
|
|
19
|
+
|
|
20
|
+
export function analyze(raw0) {
|
|
21
|
+
const raw = String(raw0)
|
|
22
|
+
.replace(/<script[\s\S]*?<\/script>/g, '')
|
|
23
|
+
.replace(/<style[\s\S]*?<\/style>/g, '')
|
|
24
|
+
.replace(/<!--[\s\S]*?-->/g, '');
|
|
25
|
+
// 「인라인 강조」는 **문장 안에 박힌** 굵게만 센다.
|
|
26
|
+
// <b>01-1</b> · <b>표 1-1</b> · <b>Why</b> 같은 구조 라벨은 강조가 아니다 —
|
|
27
|
+
// 그것까지 세면 라벨이 많은 문서가 전부 지적당한다(스캐폴드 실측: 14건 중 13건이 오탐).
|
|
28
|
+
// 판정: 여는 태그 앞과 닫는 태그 뒤에 같은 블록 안에서 8자 이상의 글이 있을 때만.
|
|
29
|
+
const inlineEmph = (() => {
|
|
30
|
+
const blocks = raw.split(/<\/?(?:p|li|td|th|dd|dt|h[1-6]|figcaption|caption)[ >]/);
|
|
31
|
+
let n = 0;
|
|
32
|
+
for (const blk of blocks) {
|
|
33
|
+
const re = /<(b|strong)[ >][^>]*>([\s\S]*?)<\/\1>/g;
|
|
34
|
+
let m;
|
|
35
|
+
while ((m = re.exec(blk))) {
|
|
36
|
+
const before = blk.slice(0, m.index).replace(/<[^>]+>/g, '').trim();
|
|
37
|
+
const after = blk.slice(m.index + m[0].length).replace(/<[^>]+>/g, '').trim();
|
|
38
|
+
if (before.length >= 8 && after.length >= 8) n++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return n;
|
|
42
|
+
})();
|
|
43
|
+
const txt = raw.replace(/<[^>]+>/g, ' ')
|
|
44
|
+
.replace(/'/g, "'").replace(/&/g, '&').replace(/ /g, ' ')
|
|
45
|
+
.replace(/\s+/g, ' ').trim();
|
|
46
|
+
const K = txt.length / 1000; // 천 자 단위
|
|
47
|
+
const lines = [];
|
|
48
|
+
|
|
49
|
+
// ── ① 경어체 (S1) ────────────────────────────────────────────
|
|
50
|
+
const POLITE = /[^<>]{4,}?(?:습니다|십시오|합니다|입니다|됩니다|드립니다|주세요|하세요|해요)[.。]?/g;
|
|
51
|
+
const polite = txt.match(POLITE) || [];
|
|
52
|
+
lines.push(`[S1] 경어체 잔존: ${polite.length}건 (0 이어야 함 — 개조식은 ~함 / ~했음 / ~필요)`);
|
|
53
|
+
polite.slice(0, 12).forEach(h => lines.push(' ✗ ' + h.trim().slice(0, 80)));
|
|
54
|
+
|
|
55
|
+
// ── ②b-1 문체 유형 (S1 — 한 번만 나와도 고친다) ─────────────
|
|
56
|
+
// 「조사」 단독은 제외한다 — 「조사 결과」는 정상 낱말이다. 잡는 것은 서술형이다.
|
|
57
|
+
const STUDENT = /(사료됨|사료된다|고찰하|살펴보(?:았|고자)|알아보(?:았|고자)|에 대해(?:서)? 조사하|조사하였)/g;
|
|
58
|
+
// 「양해각서(MOU)」는 완충이 아니라 용어다 — 부분일치 오탐(2026-08-27 리뷰).
|
|
59
|
+
const BUFFER = /(부득이|양해(?!각서)|유감스럽|안타깝게도|죄송|\bplease\b)/gi;
|
|
60
|
+
const student = txt.match(STUDENT) || [];
|
|
61
|
+
const buffer = txt.match(BUFFER) || [];
|
|
62
|
+
lines.push(`\n[S1] 학생 보고서투: ${student.length}건 (0 이어야 함 — 「무엇을 했다」가 아니라 「무엇을 알아냈다」로)`);
|
|
63
|
+
student.slice(0, 8).forEach(h => lines.push(' ✗ ' + h));
|
|
64
|
+
lines.push(`[S1] 완충어: ${buffer.length}건 (0 이어야 함 — 사과 대신 사실 → 이유 → 선택지)`);
|
|
65
|
+
buffer.slice(0, 8).forEach(h => lines.push(' ✗ ' + h));
|
|
66
|
+
|
|
67
|
+
// ── ② 늘어짐 지표 (S2, 밀도) ─────────────────────────────────
|
|
68
|
+
const CHECKS = [
|
|
69
|
+
['문두 접속사', /(?:^|[.。] )(또한|따라서|즉|나아가|아울러|게다가|더욱이|그리고)\s/g, 1.5,
|
|
70
|
+
'70% 이상 제거. 논리 흐름은 문장 내용으로 표현'],
|
|
71
|
+
['추측·관측 종결', /(보인다|보임|판단된다|판단됨|여겨진다|여겨짐|듯하다|듯함)/g, 1.0,
|
|
72
|
+
'근거가 있으면 단정. 없으면 「확인하지 못함」으로 범위를 명시'],
|
|
73
|
+
['이중 완곡', /(가능성[^.]{0,8}있을 수|것으로 보여질|수도 있을)/g, 0.2,
|
|
74
|
+
'완곡은 하나만'],
|
|
75
|
+
['「이는 ~」 지시', /(이는 |이 점에서|이 관점에서|이 말은)/g, 1.0,
|
|
76
|
+
'앞 문장과 통합. 메타 진입 삭제 후 본 서술 직진'],
|
|
77
|
+
['형식명사 결합', /(라는 점에 있|다는 것이|라는 것이|다는 뜻이|할 필요가 있)/g, 1.0,
|
|
78
|
+
'구체 명사·동사로 치환. 「X는 ~다」 직설로'],
|
|
79
|
+
['괄호 부연', /\([^)]{6,}\)/g, 2.5,
|
|
80
|
+
'근거·출처 표기는 남기고, 설명 부연은 본문화'],
|
|
81
|
+
['낫표 「」', /「/g, 5.0,
|
|
82
|
+
'용어 첫 등장 1회만. 이후는 평문'],
|
|
83
|
+
['대시 —', /—/g, 0.5,
|
|
84
|
+
'쉼표·괄호·별도 문장으로 분해'],
|
|
85
|
+
// ②b-2 근거 없는 총평 — 등급·수치·절 참조가 할 일을 부사가 대신하면 슬롭이다.
|
|
86
|
+
['근거 없는 총평', /(전반적으로|대체로|종합적으로 보면|매우 우수|크게 개선|성공적으로)/g, 1.0,
|
|
87
|
+
'총평은 등급·수치·절 참조로 대체'],
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
lines.push(`\n[S2] 늘어짐 지표 (본문 ${txt.length}자 · 천 자당 밀도 · 3회 이상일 때만 지적)`);
|
|
91
|
+
let over = 0;
|
|
92
|
+
for (const [name, re, limit, fix] of CHECKS) {
|
|
93
|
+
const n = (txt.match(re) || []).length;
|
|
94
|
+
const d = n / K;
|
|
95
|
+
// 1~2회는 자연스럽다. 3회 이상 반복될 때만 「패턴」이다 —
|
|
96
|
+
// 최소 횟수가 없으면 짧은 글에서 1건이 밀도를 넘어 오탐이 된다.
|
|
97
|
+
const bad = d > limit && n >= 3;
|
|
98
|
+
if (bad) over++;
|
|
99
|
+
lines.push(` ${bad ? '✗' : ' '} ${name.padEnd(14)} ${String(n).padStart(3)}건 ${d.toFixed(2)}/천자 (한도 ${limit})${bad ? ' → ' + fix : ''}`);
|
|
100
|
+
}
|
|
101
|
+
const ed = inlineEmph / K;
|
|
102
|
+
const edBad = ed > 5.0 && inlineEmph >= 3;
|
|
103
|
+
if (edBad) over++;
|
|
104
|
+
lines.push(` ${edBad ? '✗' : ' '} ${'인라인 강조'.padEnd(14)} ${String(inlineEmph).padStart(3)}건 ${ed.toFixed(2)}/천자 (한도 5.0)${edBad ? ' → 굵게는 요지에만' : ''}`);
|
|
105
|
+
|
|
106
|
+
// ── ③ 한 문장 한 줄 (S1) ─────────────────────────────────────
|
|
107
|
+
// 한글 보고서는 마침표에서 줄을 바꾼다. 문장을 이어 붙인 한글 문단은 기계가 쓴 글처럼 읽힌다.
|
|
108
|
+
// 「줄바꿈이 있는가」는 낱말이 아니라 마크업으로 판정한다 — <span class="sn"> 또는 <br>.
|
|
109
|
+
// 날짜(2026. 8. 21.)·버전(iOS 26.4)·경로(api.php)는 문장 끝이 아니므로 먼저 가린다.
|
|
110
|
+
const BLOCK = /<(p|li|td|dd|figcaption)\b[^>]*>([\s\S]*?)<\/\1>/g;
|
|
111
|
+
// 연도 없는 점 날짜(8. 21.)도 문장 끝이 아니다 — 안 가리면 부정 종결의 문맥 창이 밀린다.
|
|
112
|
+
const DATE = /\d{4}\.\s*\d{1,2}\.(?:\s*\d{1,2}\.)?|(?<!\d)\d{1,2}\.\s*\d{1,2}\.(?!\s*\d)/g;
|
|
113
|
+
const BOUND = /(?<=[가-힣0-9)\]」』”'"A-Za-z])[.!?]\s+(?=[가-힣0-9「『※(\[]|[A-Z])/g;
|
|
114
|
+
const runOn = [];
|
|
115
|
+
const blockTexts = []; // ②b-3 이 같은 블록 단위를 다시 쓴다
|
|
116
|
+
{
|
|
117
|
+
let m;
|
|
118
|
+
while ((m = BLOCK.exec(raw))) {
|
|
119
|
+
const inner = m[2];
|
|
120
|
+
if (/<(p|li|td|ul|ol|table|div|h[1-6])[\s>]/i.test(inner)) continue; // 겹친 블록은 안쪽에서 센다
|
|
121
|
+
const plain = inner.replace(/<[^>]+>/g, '').replace(/'/g, "'").replace(/\s+/g, ' ').trim();
|
|
122
|
+
blockTexts.push(plain);
|
|
123
|
+
const n = (plain.replace(DATE, '·').match(BOUND) || []).length;
|
|
124
|
+
if (n >= 1 && !/class="sn"|<br/i.test(inner)) runOn.push({ n, t: plain.slice(0, 60) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// HTML 블록이 하나도 없으면(.md·평문) 빈 줄로 가른 문단을 블록으로 삼는다.
|
|
128
|
+
if (!blockTexts.length) blockTexts.push(...raw.split(/\n{2,}/).map(s => s.replace(/\s+/g, ' ').trim()).filter(Boolean));
|
|
129
|
+
lines.push(`\n[S1] 한 문장 한 줄: 이어 붙인 블록 ${runOn.length}개 (0 이어야 함 — 마침표에서 줄을 바꾼다)`);
|
|
130
|
+
runOn.slice(0, 10).forEach(r => lines.push(` ✗ ${r.n + 1}문장 한 덩어리 ${r.t}`));
|
|
131
|
+
if (runOn.length) lines.push(' → scripts/sentbreak.py <파일> --apply · CSS 에 .sn { display:block; padding-left:1em; text-indent:-1em }');
|
|
132
|
+
|
|
133
|
+
// ── ②b-3 단독 부정 종결 (S1) ─────────────────────────────────
|
|
134
|
+
// 「할 수 없음 / 제외됨」으로 끝나는 문장은, 같은 블록 안(그 문장 자신 포함)에
|
|
135
|
+
// 이유·기준·재검토·선택지 표지가 없으면 배제 통보로 읽힌다 — 조건 주어 + 넷을 붙여
|
|
136
|
+
// 고쳐 쓴다(genetics-report content-pipeline §4.2 유형 K).
|
|
137
|
+
// ⚠ 주어가 사람·조직·국가인지는 여기서 못 본다 — 사람의 눈 검수 몫이다.
|
|
138
|
+
const NEG_END = /(할 수 없음|불가함|제외됨|제외함|지원하지 않음|미지원임|대상이 아님)\.?$/;
|
|
139
|
+
// 구제 표지(2026-08-27 리뷰로 조임):
|
|
140
|
+
// · 「까지」는 날짜·기간에 붙었을 때만 — 「현재까지/지금까지」가 구제로 오인되던 미탐.
|
|
141
|
+
// · 앞 문장은 **인과 표지**(때문·이유·기준·조건)일 때만 문맥이다 — 무관한 「예정」이 구제하던 미탐.
|
|
142
|
+
// · 자기·다음 문장은 전 목록을 본다(유형 K 정형: 부정 뒤에 이유·재검토·선택지가 온다).
|
|
143
|
+
const RESCUE = /(때문|이유|기준|재검토|대신|대안|선택|가능한 것|조건|예정|(?:[0-9일월년주]|분기)\s*까지)/;
|
|
144
|
+
const CAUSAL = /(때문|이유|기준|조건)/;
|
|
145
|
+
const negation = [];
|
|
146
|
+
for (const blk of blockTexts) {
|
|
147
|
+
// 목록 번호(「1. 」)는 문장이 아니다 — 안 가리면 조각이 문맥 창을 밀어낸다.
|
|
148
|
+
const masked = blk.replace(DATE, '·').replace(/(^|\s)\d{1,2}\.(?=\s)/g, '$1·');
|
|
149
|
+
const sents = masked.split(/(?<=[.。!?])\s+/).map(s => s.trim()).filter(Boolean);
|
|
150
|
+
sents.forEach((s, i) => {
|
|
151
|
+
if (!NEG_END.test(s)) return;
|
|
152
|
+
if (RESCUE.test([s, sents[i + 1]].filter(Boolean).join(' '))) return;
|
|
153
|
+
if (CAUSAL.test(sents[i - 1] || '')) return;
|
|
154
|
+
negation.push(s.slice(0, 70));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
lines.push(`\n[S1] 이유 없는 부정 종결: ${negation.length}건 (0 이어야 함 — 조건 주어 + 원인·기준·재검토·선택지)`);
|
|
158
|
+
negation.slice(0, 8).forEach(h => lines.push(' ✗ ' + h));
|
|
159
|
+
|
|
160
|
+
const s1extra = student.length + buffer.length + negation.length;
|
|
161
|
+
lines.push(`\n경어체 ${polite.length}건 · 이어 붙인 블록 ${runOn.length}개 · 밀도 초과 ${over}항 · 학생투 ${student.length} · 완충어 ${buffer.length} · 이유 없는 부정 종결 ${negation.length}`);
|
|
162
|
+
const fail = Boolean(polite.length || over || runOn.length || s1extra);
|
|
163
|
+
return { polite, runOn, over, student, buffer, negation, inlineEmph, textLen: txt.length, lines, fail };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
167
|
+
const P = process.argv[2];
|
|
168
|
+
if (!P) { console.error('usage: node tone.mjs <file.html|.md>'); process.exit(1); }
|
|
169
|
+
const r = analyze(fs.readFileSync(P, 'utf8'));
|
|
170
|
+
console.log(r.lines.join('\n'));
|
|
171
|
+
process.exit(r.fail ? 1 : 0);
|
|
172
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/* tone.test.mjs — 문체 검사기의 계약 (node --test)
|
|
2
|
+
deploy.sh 관문이 태운다. 양성 표본 수를 출력한다 — 0표본 통과는 통과가 아니다. */
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { analyze } from './tone.mjs';
|
|
6
|
+
|
|
7
|
+
const wrap = (...ps) => ps.map(t => `<p>${t}</p>`).join('');
|
|
8
|
+
let positives = 0;
|
|
9
|
+
const pos = (n) => { positives += n; return n; };
|
|
10
|
+
|
|
11
|
+
test('대조군 — 개조식·이유 있는 부정만 있는 문서는 전부 0', () => {
|
|
12
|
+
// 두 문장 블록은 조판 규칙대로 .sn 으로 세운다 — 안 그러면 「한 문장 한 줄」에 옳게 걸린다.
|
|
13
|
+
const r = analyze(wrap(
|
|
14
|
+
'저장이 일어나는 모든 경로를 수정함.',
|
|
15
|
+
'<span class="sn">라이선스 조항 때문에 그 경로는 쓸 수 없음.</span> <span class="sn">조항이 바뀌면 재검토 예정.</span>',
|
|
16
|
+
'확인 범위는 주문 목록 화면까지임.'));
|
|
17
|
+
assert.equal(r.polite.length, 0);
|
|
18
|
+
assert.equal(r.student.length, 0);
|
|
19
|
+
assert.equal(r.buffer.length, 0);
|
|
20
|
+
assert.equal(r.negation.length, 0);
|
|
21
|
+
assert.equal(r.runOn.length, 0);
|
|
22
|
+
assert.equal(r.fail, false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('경어체 잔존 — 회귀(기존 검사 유지)', () => {
|
|
26
|
+
const r = analyze(wrap('이번 회차 결과를 정리해 보고드립니다.'));
|
|
27
|
+
assert.equal(pos(r.polite.length) >= 1, true);
|
|
28
|
+
assert.equal(r.fail, true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('한 문장 한 줄 — 회귀(기존 검사 유지)', () => {
|
|
32
|
+
const bad = analyze('<p>첫 문장임. 둘째 문장임.</p>');
|
|
33
|
+
assert.equal(pos(bad.runOn.length), 1);
|
|
34
|
+
const good = analyze('<p><span class="sn">첫 문장임.</span> <span class="sn">둘째 문장임.</span></p>');
|
|
35
|
+
assert.equal(good.runOn.length, 0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('학생 보고서투 — 교대항 전수(하나를 빼는 뮤테이션은 여기서 죽는다), 「조사 결과」는 안 잡는다', () => {
|
|
39
|
+
for (const t of ['원인으로 사료됨.', '그 원인으로 사료된다.', '구조를 고찰하였음.', '동작을 살펴보았음.',
|
|
40
|
+
'흐름을 살펴보고자 함.', '차이를 알아보았음.', '차이를 알아보고자 함.',
|
|
41
|
+
'시장에 대해 조사하기로 함.', '동향을 조사하였음.']) {
|
|
42
|
+
const r = analyze(wrap(t));
|
|
43
|
+
assert.equal(pos(r.student.length) >= 1, true, t);
|
|
44
|
+
assert.equal(r.fail, true, t);
|
|
45
|
+
}
|
|
46
|
+
assert.equal(analyze(wrap('조사 결과는 부록 1에 있음.')).student.length, 0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('완충어 — 사과·양해 조각은 한 번만 나와도 잡는다', () => {
|
|
50
|
+
for (const t of ['부득이 일정을 미룸.', '양해 바람.', '유감스럽게도 재현하지 못함.',
|
|
51
|
+
'안타깝게도 자료가 없음.', '죄송하지만 확인 필요.', 'please 확인 바람.']) {
|
|
52
|
+
assert.equal(pos(analyze(wrap(t)).buffer.length) >= 1, true, t);
|
|
53
|
+
}
|
|
54
|
+
assert.equal(analyze(wrap('사죄나 양보 없이 사실만 적음.')).buffer.length, 0);
|
|
55
|
+
// 「양해각서」는 용어다 — 완충 오탐이던 자리(2026-08-27 리뷰).
|
|
56
|
+
assert.equal(analyze(wrap('양해각서(MOU)를 체결함.')).buffer.length, 0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('근거 없는 총평 — 1회는 자연, 3회 반복이면 밀도 초과(S2)', () => {
|
|
60
|
+
const one = analyze(wrap('전반적으로 안정적임.', '세부 수치는 03절에 있음.'));
|
|
61
|
+
// 1건은 S2 최소 횟수(3) 미만 — 지적하지 않는다.
|
|
62
|
+
assert.equal(one.over, 0);
|
|
63
|
+
const three = analyze(wrap('전반적으로 안정적임.', '성공적으로 마무리함.', '크게 개선됨.'));
|
|
64
|
+
assert.equal(pos(three.over) >= 1, true);
|
|
65
|
+
assert.equal(three.fail, true);
|
|
66
|
+
// 교대항 전수 — 낱말 하나를 목록에서 빼는 뮤테이션은 그 낱말의 3연타에서 죽는다.
|
|
67
|
+
for (const w of ['전반적으로', '대체로', '종합적으로 보면', '매우 우수', '크게 개선', '성공적으로']) {
|
|
68
|
+
const r = analyze(wrap(`${w}라 판단함.`, `${w}라 적음.`, `${w}로 마무리함.`));
|
|
69
|
+
assert.equal(pos(r.over) >= 1, true, w);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('단독 부정 종결 — 이유 표지가 붙으면 통과, 없으면 잡는다', () => {
|
|
74
|
+
// 일곱 어미 전부 — 목록에서 하나를 빼는 뮤테이션은 여기서 죽는다.
|
|
75
|
+
for (const end of ['할 수 없음', '불가함', '제외됨', '제외함', '지원하지 않음', '미지원임', '대상이 아님']) {
|
|
76
|
+
const r = analyze(wrap(`그 경로는 ${end}.`));
|
|
77
|
+
assert.equal(pos(r.negation.length), 1, end);
|
|
78
|
+
assert.equal(r.fail, true, end);
|
|
79
|
+
}
|
|
80
|
+
// 같은 문장 안의 이유(유형 K 정형)는 통과.
|
|
81
|
+
assert.equal(analyze(wrap('약관 조항 때문에 그 경로는 할 수 없음.')).negation.length, 0);
|
|
82
|
+
// 다음 문장의 이유·재검토도 통과 — 「앞뒤 문장」이 문맥이다.
|
|
83
|
+
assert.equal(analyze(wrap('그 경로는 제외됨. 약관 기준에 안 맞기 때문임.')).negation.length, 0);
|
|
84
|
+
// 다른 블록의 이유는 문맥이 아니다 — 여전히 잡는다.
|
|
85
|
+
assert.equal(pos(analyze(wrap('그 경로는 제외됨.', '기준은 부록 1에 있음.')).negation.length), 1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('부정 종결이 아닌 부정 — 목록 어구가 문장 **중간**이면 안 잡는다($ 앵커의 계약)', () => {
|
|
89
|
+
// 「지원하지 않음」이 그대로 들어 있되 문장 끝이 아니다 — $ 앵커를 빼는 뮤테이션은 여기서 죽는다.
|
|
90
|
+
assert.equal(analyze(wrap('지원하지 않음을 확인한 상태로 진행함.')).negation.length, 0);
|
|
91
|
+
assert.equal(analyze(wrap('지원하지 않는 형식이면 안내문이 뜸.')).negation.length, 0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('부정 종결 — 구제 표지의 조임(2026-08-27 리뷰 회귀)', () => {
|
|
95
|
+
// 「현재까지」의 「까지」는 기간 표지가 아니다 — 미탐이던 자리.
|
|
96
|
+
assert.equal(pos(analyze(wrap('현재까지 원인을 확인할 수 없음.')).negation.length), 1);
|
|
97
|
+
// 앞 문장의 무관한 「예정」은 문맥이 아니다 — 미탐이던 자리.
|
|
98
|
+
assert.equal(pos(analyze(wrap('예정된 일정대로 점검을 마침. 그 경로는 제외됨.')).negation.length), 1);
|
|
99
|
+
// 앞 문장이 **인과**면 문맥이다.
|
|
100
|
+
assert.equal(analyze(wrap('약관 기준이 바뀌었음. 그 경로는 제외됨.')).negation.length, 0);
|
|
101
|
+
// 날짜에 붙은 「까지」는 구제다.
|
|
102
|
+
assert.equal(analyze(wrap('그 형식은 미지원임. 12월 1일까지 대체 경로를 씀.')).negation.length, 0);
|
|
103
|
+
// 목록 번호 조각이 문맥 창을 밀지 않는다(.md 경로).
|
|
104
|
+
assert.equal(analyze('1. 약관 기준이 바뀜.\n2. 그 경로는 제외됨.').negation.length, 0);
|
|
105
|
+
// 연도 없는 점 날짜(8. 21.)가 문장을 가르지 않는다.
|
|
106
|
+
assert.equal(analyze(wrap('<span class="sn">그 경로는 제외됨.</span> <span class="sn">8. 21. 이후 재검토 예정임.</span>')).negation.length, 0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('CLI 계약 — lines 에 요약줄이 서고 fail 이 종료코드의 근거다', () => {
|
|
110
|
+
const r = analyze(wrap('그 경로는 제외됨.'));
|
|
111
|
+
assert.match(r.lines.at(-1), /이유 없는 부정 종결 1/);
|
|
112
|
+
assert.equal(r.fail, true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('양성 표본 수 — 0표본 통과 방지', () => {
|
|
116
|
+
console.log(` 양성 표본 ${positives}건 (0 이면 이 시험은 아무것도 안 잰 것)`);
|
|
117
|
+
assert.equal(positives >= 25, true, String(positives));
|
|
118
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* typesurvey.mjs — 「어느 것이 어느 크기인가」 전수표
|
|
2
|
+
사용: node typesurvey.mjs <URL> [폭]
|
|
3
|
+
|
|
4
|
+
fontchk.mjs 는 크기가 몇 종인지를 본다. 이건 **어느 것이** 그 크기인지를 본다.
|
|
5
|
+
기계는 역할을 판정할 수 없으므로 판정은 사람이 한다 — 이 표가 그 재료다.
|
|
6
|
+
|
|
7
|
+
두 가지를 반드시 지킨다:
|
|
8
|
+
① 크기·굵기를 **분포**로 모은다. 서명당 첫 표본을 대표로 찍으면 변형(--why)이나
|
|
9
|
+
문맥 상속(th 안)으로 갈리는 경우를 통째로 놓친다.
|
|
10
|
+
② **평균 자수**를 같이 낸다. 자수가 역할을 가른다 —
|
|
11
|
+
한두 낱말이면 라벨(14px 가능), 문장이면 표제(본문보다 작으면 안 됨). */
|
|
12
|
+
import { chromium } from 'playwright-core'; import os from 'os';
|
|
13
|
+
const EXE = process.env.CHROME || `${os.homedir()}/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing`;
|
|
14
|
+
if (!process.argv[2]) { console.error('usage: node typesurvey.mjs <URL> [폭]'); process.exit(1); }
|
|
15
|
+
const b = await chromium.launch({ executablePath: EXE });
|
|
16
|
+
const p = await b.newPage({ viewport: { width: +(process.argv[3] || 1440), height: 900 } });
|
|
17
|
+
await p.goto(process.argv[2], { waitUntil: 'networkidle' }); await p.waitForTimeout(800);
|
|
18
|
+
const rows = await p.evaluate(() => {
|
|
19
|
+
const m = new Map();
|
|
20
|
+
document.querySelectorAll('body *').forEach(el => {
|
|
21
|
+
if (el.closest('script,style')) return;
|
|
22
|
+
const own = [...el.childNodes].filter(n=>n.nodeType===3).map(n=>n.textContent.trim()).join('');
|
|
23
|
+
if (!own) return;
|
|
24
|
+
const cs = getComputedStyle(el);
|
|
25
|
+
if (cs.display==='none' || cs.visibility==='hidden') return;
|
|
26
|
+
const cls = (typeof el.className==='string' && el.className.trim())
|
|
27
|
+
? '.'+el.className.trim().split(/\s+/).join('.') : el.tagName.toLowerCase();
|
|
28
|
+
const par = (el.parentElement && typeof el.parentElement.className==='string' && el.parentElement.className.trim())
|
|
29
|
+
? '.'+el.parentElement.className.trim().split(/\s+/)[0]+' > ' : '';
|
|
30
|
+
const sig = par + cls;
|
|
31
|
+
const rec = m.get(sig) || { sig, sizes:{}, ws:{}, n:0, ex:'', len:0 };
|
|
32
|
+
const sz = Math.round(parseFloat(cs.fontSize));
|
|
33
|
+
rec.sizes[sz]=(rec.sizes[sz]||0)+1; rec.ws[cs.fontWeight]=(rec.ws[cs.fontWeight]||0)+1;
|
|
34
|
+
rec.n++; rec.len += own.replace(/\s+/g,'').length;
|
|
35
|
+
if (!rec.ex) rec.ex = own.replace(/\s+/g,' ').slice(0,40);
|
|
36
|
+
m.set(sig, rec);
|
|
37
|
+
});
|
|
38
|
+
return [...m.values()];
|
|
39
|
+
});
|
|
40
|
+
await b.close();
|
|
41
|
+
const key = o => Object.entries(o).sort((a,b)=>b[1]-a[1]).map(([k,v])=>`${k}×${v}`).join(' ');
|
|
42
|
+
const maxSize = o => Math.max(...Object.keys(o).map(Number));
|
|
43
|
+
rows.sort((a,b)=> maxSize(b.sizes)-maxSize(a.sizes) || a.sig.localeCompare(b.sig));
|
|
44
|
+
let cur=null;
|
|
45
|
+
for (const r of rows) { const s=maxSize(r.sizes); if (s!==cur){ cur=s; console.log(`\n━━━━ ${cur}px ━━━━`); }
|
|
46
|
+
const avg = Math.round(r.len/r.n);
|
|
47
|
+
console.log(`${r.sig.padEnd(44)} ${key(r.sizes).padEnd(14)} ${key(r.ws).padEnd(14)} 평균${String(avg).padStart(3)}자 ${r.ex}`); }
|
|
48
|
+
console.log(`\n서명 ${rows.length}종`);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# template — 관보 문서 뼈대
|
|
2
|
+
|
|
3
|
+
`gazette-report` 스킬로 새 문서를 시작할 때 여기서 출발한다.
|
|
4
|
+
**추출 원본은 `FI-soft/report-v2`** — 8종 검사를 통과하고 A4 21쪽으로 인쇄되는 구현이다.
|
|
5
|
+
문서 고유 요소(숫자 롤 · 경과일 배지 · 진입 모션)만 빼고 골격은 그대로다.
|
|
6
|
+
|
|
7
|
+
**`gazette.css` 를 손으로 고치지 마라.** 이건 추출물이다. 조판 규칙이 바뀌면 레퍼런스를 고치고
|
|
8
|
+
`../scripts/extract.sh <레퍼런스.css> gazette.css` 를 다시 돌린다. 손으로 따라 고쳤다가
|
|
9
|
+
레퍼런스에서 폐기한 12px 등급이 여기 47곳 남아 있던 적이 있다.
|
|
10
|
+
|
|
11
|
+
## 파일
|
|
12
|
+
|
|
13
|
+
| 파일 | 무엇 |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `gazette.css` | 조판 3체계(활자·간격·괘선) · 가구 · 두 단 본문 · 표 4괘선 · 확인 강도 표식 · 인쇄 페이지 문법. **문서 무관** — 그대로 링크해 쓴다 |
|
|
16
|
+
| `skeleton.html` | 가구 9요소의 최소 골격. 절 2개(PART 2개) · 표 2종 · 흐름도 1개를 예시로 담았다 |
|
|
17
|
+
|
|
18
|
+
## 시작하기
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
cp -r template 새문서 && cd 새문서
|
|
22
|
+
python3 -m http.server 8781 # http://localhost:8781/skeleton.html
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 글을 채울 때 — 한 문장 한 줄
|
|
26
|
+
|
|
27
|
+
한글은 마침표에서 줄을 바꾼다. 산문을 새로 써 넣었으면 마지막에 한 번 돌린다:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python3 ../scripts/sentbreak.py skeleton.html --apply # 문장마다 <span class="sn">
|
|
31
|
+
node ../scripts/tone.mjs skeleton.html # 「이어 붙인 블록 0개」 확인
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
낱말은 바뀌지 않는다(`contentdiff.sh` 로 확인). 멱등이라 여러 번 돌려도 안전하다.
|
|
35
|
+
|
|
36
|
+
## 반드시 고쳐야 하는 것 — 셋
|
|
37
|
+
|
|
38
|
+
| # | 어디 | 무엇 |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| 1 | `gazette.css` 의 `[data-chapter]` · `[data-part-no]` | **장 색 · PART 색.** 팔레트를 베끼지 말고 **그 문서가 이미 쓰는 상태색**에서 뽑아라 — 「완료」 절이 초록인 이유는 완료 태그가 이미 초록이라서다 |
|
|
41
|
+
| 2 | `gazette.css` 의 `--wide` · `--marg` | 문서 폭 · 여백 주석 단 폭 |
|
|
42
|
+
| 3 | `gazette.css` 의 `@page` 마진 박스 `content` | 러닝 푸터 문구(`문서 제목 · 발행처`) |
|
|
43
|
+
|
|
44
|
+
## 절을 늘리려면
|
|
45
|
+
|
|
46
|
+
`skeleton.html` 의 `<!-- ══ 절 — 여기서부터 복제한다 ══ -->` 블록을 복제하고 넷을 함께 고친다:
|
|
47
|
+
|
|
48
|
+
1. `<section class="part" id="sec-N" data-chapter="0N" data-part-no="M">`
|
|
49
|
+
2. 차례의 `.toc__row[data-chapter]` 행
|
|
50
|
+
3. 측면 색인 `.rail__tab[data-chapter]`
|
|
51
|
+
4. 머리띠 `.crumb__row--ch` 의 칸
|
|
52
|
+
|
|
53
|
+
**PART 를 늘릴 때**는 `.pt[data-part-no]` 간지 · 차례의 `.toc__part` 밴드 ·
|
|
54
|
+
머리띠 `.crumb__row--part` 의 칸을 함께 넣는다.
|
|
55
|
+
|
|
56
|
+
## 채울 때 지켜야 하는 것
|
|
57
|
+
|
|
58
|
+
- **여백 주석 단(`? Why` / `! Key Point` / `i Advice`)** 은 **독자가 실제로 던질 질문**이어야 한다.
|
|
59
|
+
채우려고 넣으면 안 넣느니만 못하다.
|
|
60
|
+
- **톤은 개조식** — `~함 / ~했음 / ~필요 / ~한 상황`. `~합니다 / ~주세요` 아님.
|
|
61
|
+
- **표는 맨 아래 굵은 줄로 닫는다** (`--rule-edge`). 없으면 표가 목록으로 읽힌다.
|
|
62
|
+
- **상자를 두르지 마라.** 구조는 괘선·여백·활자로 만든다.
|
|
63
|
+
- **본문 상호참조 링크를 굵게 하지 마라.** 밑줄이 이미 신호다.
|
|
64
|
+
- **시각 장치는 내용에서 도출된 것만.** 장식은 금지.
|
|
65
|
+
|
|
66
|
+
## 스크립트
|
|
67
|
+
|
|
68
|
+
상호작용(처리 표시 · 머리띠 위치 추적 · 진입 모션)은 문서마다 다르므로 뼈대에 없다.
|
|
69
|
+
붙일 때 지켜야 할 계약은 `skeleton.html` 머리의 주석에 있다.
|
|
70
|
+
|
|
71
|
+
## 검증
|
|
72
|
+
|
|
73
|
+
뼈대 자체가 8종 검사를 통과한 상태로 배포된다. 채운 뒤에도 그대로 통과해야 한다.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
cd ../scripts
|
|
77
|
+
node kcheck.mjs http://localhost:8781/skeleton.html
|
|
78
|
+
node read.mjs http://localhost:8781/skeleton.html 1440
|
|
79
|
+
node read.mjs http://localhost:8781/skeleton.html 390
|
|
80
|
+
node fontchk.mjs http://localhost:8781/skeleton.html 12,14,17,20,24,29,35,42,51,61
|
|
81
|
+
node flexcheck.mjs http://localhost:8781/skeleton.html
|
|
82
|
+
node tone.mjs ../template/skeleton.html
|
|
83
|
+
./cssaudit.sh ../template/gazette.css
|
|
84
|
+
./pdfcheck.sh http://localhost:8781/skeleton.html
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
기준선(뼈대 상태, 2026-08-24 실측):
|
|
88
|
+
대비 표본 116 · 미달 0 / 가독성 지적 0 / 활자 스케일 밖 0 · 굵은 글자 29% /
|
|
89
|
+
경어체 0 · 밀도 초과 0 / route light / 인쇄 A4 12쪽.
|