genetics-mcp 2.23.0 → 2.25.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 +5 -5
- package/bin/x86_64-apple-darwin/gcell +0 -0
- package/bin/x86_64-pc-windows-gnu/gcell.exe +0 -0
- package/bin/x86_64-unknown-linux-musl/gcell +0 -0
- package/genetics.mjs +2068 -239
- package/package.json +2 -2
- package/skills/gazette-report/SKILL.md +11 -10
- package/skills/gazette-report/scripts/tone.mjs +17 -5
- package/skills/gazette-report/scripts/tone.test.mjs +15 -2
- package/skills/gazette-report/template/skeleton.html +10 -10
- package/skills/genetics-define/SKILL.md +2 -0
- package/skills/genetics-report/SKILL.md +123 -102
- package/skills/genetics-report/assets/example/cases-briefing.json +1 -1
- package/skills/genetics-report/assets/example/cases.json +3 -3
- package/skills/genetics-report/assets/example/report-briefing-designs.json +26 -26
- package/skills/genetics-report/assets/example/report-briefing.json +3 -3
- package/skills/genetics-report/assets/example/report.json +11 -11
- package/skills/genetics-report/assets/mcp-call.mjs +160 -0
- package/skills/genetics-report/assets/patch.py +411 -0
- package/skills/genetics-report/assets/render.py +223 -0
- package/skills/genetics-report/assets/xlsx-plate.py +203 -0
- package/skills/genetics-report/references/content-pipeline.md +26 -26
- package/skills/genetics-report/references/edition-review.workflow.js +120 -23
- package/skills/genetics-report/references/font-embedding.md +1 -1
- package/skills/genetics-report/references/history.md +76 -2
- package/skills/genetics-report/references/lim-portal-format.md +109 -48
- package/skills/genetics-report/references/linear-read-test.workflow.js +50 -14
- package/skills/genetics-report/references/process.md +54 -48
- package/skills/genetics-report/references/reader-review.workflow.js +118 -33
- package/skills/genetics-report/references/visual-first.md +34 -17
- package/skills/gazette-report/scripts/package-lock.json +0 -24
|
@@ -27,16 +27,39 @@ cover-frame·ch-head·cindex·eof-endcard·Spoqa 가 전부 0회로 나갔다.
|
|
|
27
27
|
|
|
28
28
|
python3 render.py report.json # 기본 서식
|
|
29
29
|
python3 render.py report.json --template lim-report # 브랜드 서식
|
|
30
|
+
python3 render.py report.json --text /빈/폴더/본문.txt # + 화면에 보이는 글만 텍스트로(독자 검토용)
|
|
31
|
+
python3 render.py report.json --tenant <slug> # 그 소속처가 반려한 이름으로도 센다(없으면 원고의 tenant)
|
|
32
|
+
python3 render.py report.rev9.json # get_report 로 받은 원고 그대로 — report.rev9.html, 보관 그림은 자리 상자
|
|
30
33
|
|
|
31
34
|
레포 위치는 `GENETICS_REPO` 로 덮어쓸 수 있다.
|
|
35
|
+
|
|
36
|
+
## get_report 로 받은 원고 (2026-09-29)
|
|
37
|
+
|
|
38
|
+
되읽은 원고에는 `out` 이 없고, 그림 바이트 대신 `(생략 — NB)` 표식이 온다(되읽기는 바이트를 안 준다).
|
|
39
|
+
전에는 `out` 이 없어 렌더가 죽었고, 표식 도판은 ⚠ 로 빠져 독자 본문에서 그림 자리·캡션·출처 줄이 사라졌다 —
|
|
40
|
+
그래서 수정 판마다 렌더 사본을 따로 지어 `out` 과 로컬 PNG 를 끼웠다. 이제 `out` 은 원고 이름 옆 `.html` 이 기본이고,
|
|
41
|
+
표식 도판은 서버 기록 치수의 자리 상자로 선다(`kept_stand_ins()`). 그 HTML 은 검토본이다 — 전달본은 그림 바이트가 실린 원고로 굽는다.
|
|
42
|
+
받을 때는 `mcp-call.mjs --json` 으로 JSON 만 받는다(머리줄이 붙은 파일은 못 읽는다). 원고에 처음부터 `out` 이 있으면 작성 원고(단일 HTML 전달 경로)라,
|
|
43
|
+
자리 상자가 생기면 그 한 줄을 ⚠ 로 낸다 — 전달 관문 「⚠0」이 검토본을 통과시키지 않게(2026-09-30 적대 검토).
|
|
44
|
+
|
|
45
|
+
## 소속처가 반려한 이름 (2026-09-26)
|
|
46
|
+
|
|
47
|
+
목록은 그 소속처의 반려 결정 장(`remember_decision` 의 `rejected_names` 칸)에 산다 — 제품 저장소에 없다.
|
|
48
|
+
포털 발행·모의 발행은 서버가 그 목록을 렌더러에 넘긴다. 로컬 렌더는 서버를 안 거치므로 여기서
|
|
49
|
+
같은 옆 파일 `mcp-call.mjs` 로 `list_rejected_names` 를 불러 받아 얹는다(발행과 같은 목록 · 같은 대조 규칙).
|
|
50
|
+
못 받으면 렌더는 그대로 하고 「세지 않았다」고 한 줄 말한다 — 조용히 넘기면 「반려 이름 0」으로 읽힌다.
|
|
51
|
+
원고에 `rejected_names` 가 적혀 있어도 쓰지 않는다(포털 입구도 버린다 — 두 경로가 같아야 한다).
|
|
32
52
|
"""
|
|
33
53
|
import argparse
|
|
54
|
+
import base64
|
|
55
|
+
import html
|
|
34
56
|
import json
|
|
35
57
|
import os
|
|
36
58
|
import pathlib
|
|
37
59
|
import re
|
|
38
60
|
import subprocess
|
|
39
61
|
import sys
|
|
62
|
+
from html.parser import HTMLParser
|
|
40
63
|
|
|
41
64
|
DEFAULT_REPO = pathlib.Path.home() / 'project/FCG/report-portal'
|
|
42
65
|
|
|
@@ -132,6 +155,169 @@ def templates_from_server(repo: pathlib.Path) -> dict:
|
|
|
132
155
|
}
|
|
133
156
|
|
|
134
157
|
|
|
158
|
+
# ── 독자용 본문 텍스트(--text) ──
|
|
159
|
+
# 독자 검토의 「처음 읽는 사람」·「선형 읽기」 관점은 원고 JSON 이 아니라 **렌더본에 보이는 글**만 읽는다.
|
|
160
|
+
# JSON 으로 읽히면 독자에게 안 보이는 제작 기록(assets[].origin)이 검토자에게는 보이고, 그것을 근거로 판정한
|
|
161
|
+
# 발견이 나왔다(2026-09-26 스킬 검토: wf_7bfad8fa-89f 발견 4건). origin 은 렌더러가 그리지 않는 칸이라 HTML 에 없다.
|
|
162
|
+
# 빼는 것: head·script·style·template·noscript, svg 의 title·desc(말풍선이라 화면 글이 아니다), hidden 속성이
|
|
163
|
+
# 붙은 요소, HTML 주석, 화면 전용 크롬(아래), 그림의 alt(화면에 안 보이는 글이다).
|
|
164
|
+
_TEXT_SKIP = {'head', 'script', 'style', 'template', 'noscript', 'title', 'desc'}
|
|
165
|
+
# 머리띠 이동줄·진행 막대·측면 색인·「본문 바로가기」는 차례와 절 이름을 한 번 더 늘어놓는 화면 장치라 인쇄본도
|
|
166
|
+
# 지운다(gazette.css @media print 「화면 전용 크롬」). 그 목록의 현황 줄(notice)은 요약면 본문이라 남긴다.
|
|
167
|
+
_TEXT_CHROME = {'topbar', 'progress', 'rail', 'skip',
|
|
168
|
+
# 접기(<details>) 요약 줄 — 도판 번호·캡션과 「원인 · 조치 · 확인 방법」을 한 번 더 늘어놓는 화면 장치다.
|
|
169
|
+
# 인쇄본도 지운다(gazette.css 13-1b). 글은 접힌 칸 안에 그대로 있다(2026-09-30).
|
|
170
|
+
'fold__sum'}
|
|
171
|
+
_TEXT_BLOCK = {'address', 'article', 'aside', 'blockquote', 'caption', 'dd', 'div', 'dl', 'dt',
|
|
172
|
+
'figcaption', 'figure', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
|
|
173
|
+
'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'tbody', 'tfoot',
|
|
174
|
+
'thead', 'tr', 'ul'}
|
|
175
|
+
_TEXT_VOID = {'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta',
|
|
176
|
+
'source', 'track', 'wbr'}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class _VisibleText(HTMLParser):
|
|
180
|
+
"""렌더된 HTML 에서 화면에 보이는 글만 문서 순서대로 모은다.
|
|
181
|
+
|
|
182
|
+
그림·영상은 자리 표시(「[그림]」「[영상]」) 한 줄만 남긴다 — 그림 안의 글자는 텍스트로 옮기지 못한다.
|
|
183
|
+
표 칸은 「 | 」로 잇고 행마다 줄을 바꾼다.
|
|
184
|
+
클래스가 붙은 인라인 요소와 span·label·i 는 화면에서 따로 서는 조각(문장 조각 `sn`, 차례의 절 번호·제목·건수)이라
|
|
185
|
+
경계에서 한 칸 띄운다. 클래스 없는 b·strong·em·a 는 낱말에 붙는 강조·링크라 띄우지 않는다(「**1**건」은 「1건」).
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def __init__(self):
|
|
189
|
+
super().__init__(convert_charrefs=True)
|
|
190
|
+
self.parts = []
|
|
191
|
+
self.open = [] # (태그, 숨김, 경계 띄움) — 닫는 태그를 짝지어 숨김 깊이를 되돌린다
|
|
192
|
+
self.hidden = 0
|
|
193
|
+
self.gap = False
|
|
194
|
+
|
|
195
|
+
def handle_starttag(self, tag, attrs):
|
|
196
|
+
a = dict(attrs)
|
|
197
|
+
if tag in _TEXT_VOID:
|
|
198
|
+
if not self.hidden and tag in ('br', 'hr'):
|
|
199
|
+
self.parts.append('\n')
|
|
200
|
+
elif not self.hidden and tag == 'img':
|
|
201
|
+
self.parts.append('\n[그림]\n')
|
|
202
|
+
return
|
|
203
|
+
hide = (tag in _TEXT_SKIP or tag == 'video' or 'hidden' in a
|
|
204
|
+
or bool(_TEXT_CHROME & set((a.get('class') or '').split())))
|
|
205
|
+
if tag == 'video' and not self.hidden:
|
|
206
|
+
self.parts.append('\n[영상]\n') # 안의 대체 글은 재생기가 있는 화면에서 안 보인다
|
|
207
|
+
sep = tag not in _TEXT_BLOCK and (tag in ('span', 'label', 'i') or 'class' in a)
|
|
208
|
+
self.open.append((tag, hide, sep))
|
|
209
|
+
if hide:
|
|
210
|
+
self.hidden += 1
|
|
211
|
+
elif not self.hidden and tag in _TEXT_BLOCK:
|
|
212
|
+
self.parts.append('\n')
|
|
213
|
+
elif not self.hidden and sep:
|
|
214
|
+
self.gap = True
|
|
215
|
+
|
|
216
|
+
def handle_endtag(self, tag):
|
|
217
|
+
for i in range(len(self.open) - 1, -1, -1):
|
|
218
|
+
if self.open[i][0] == tag:
|
|
219
|
+
sep = self.open[i][2]
|
|
220
|
+
self.hidden -= sum(1 for _, h, _s in self.open[i:] if h)
|
|
221
|
+
del self.open[i:]
|
|
222
|
+
break
|
|
223
|
+
else:
|
|
224
|
+
return # 여는 태그가 없는 닫는 태그(빈 요소의 짝 등)는 건너뛴다
|
|
225
|
+
if self.hidden:
|
|
226
|
+
return
|
|
227
|
+
if tag in ('td', 'th'):
|
|
228
|
+
self.parts.append(' | ')
|
|
229
|
+
elif tag in _TEXT_BLOCK:
|
|
230
|
+
self.parts.append('\n')
|
|
231
|
+
elif sep:
|
|
232
|
+
self.gap = True
|
|
233
|
+
|
|
234
|
+
def handle_data(self, data):
|
|
235
|
+
if self.hidden:
|
|
236
|
+
return
|
|
237
|
+
data = re.sub(r'\s+', ' ', data)
|
|
238
|
+
if not data:
|
|
239
|
+
return
|
|
240
|
+
if self.gap and not data[0].isspace() and self.parts and not self.parts[-1][-1:].isspace():
|
|
241
|
+
data = ' ' + data
|
|
242
|
+
self.gap = False
|
|
243
|
+
self.parts.append(data)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def visible_text(html: str) -> str:
|
|
247
|
+
"""렌더본 HTML → 독자용 본문 텍스트. 빈 줄은 하나로 접고 줄 끝 칸 구분자는 걷는다."""
|
|
248
|
+
p = _VisibleText()
|
|
249
|
+
p.feed(html)
|
|
250
|
+
p.close()
|
|
251
|
+
lines = [re.sub(r'(?:\s*\|)+$', '', ln.strip()).strip() for ln in ''.join(p.parts).split('\n')]
|
|
252
|
+
return re.sub(r'\n{3,}', '\n\n', '\n'.join(lines)).strip() + '\n'
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ── 소속처가 반려한 이름(--tenant) ──
|
|
256
|
+
REJECTED_KEY = 'rejected_names' # 렌더러 cfg 의 칸(gazette.py Doc.REJECTED · report_publish.php REPORT_REJECTED_KEY 와 같은 낱말)
|
|
257
|
+
REJECTED_TIMEOUT = 20 # 초 — MCP 서버를 띄워 창구 한 번을 부르는 값. 넘으면 못 받은 것으로 친다
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def rejected_names_from_server(tenant: str):
|
|
261
|
+
"""(목록, 못 받은 까닭) — 목록은 [{name, use, near}]. 서버가 발행 때 쓰는 그 목록이다(GET /api/wiki/rejected_names).
|
|
262
|
+
|
|
263
|
+
옆 파일 `mcp-call.mjs` 로 MCP 서버를 띄운다 — 저장소 안 스킬이면 저장소의 genetics.mjs, 사본이면 게시본(찾는 순서는 그 파일 머리 주석). 세포 자동 설치·스킬 배치·
|
|
264
|
+
심박은 이 한 번의 읽기에 필요 없어 끈다. 도구 응답 뒤에 메일·세계 소식이 덧붙을 수 있어 앞의 JSON 한 덩어리만 읽는다.
|
|
265
|
+
`--no-mail` — 이 세션의 신원을 자식에게 안 물려준다. 물려주면 자식이 이 세션 앞 메일을 긷고 「받음」 도장을 찍은 뒤
|
|
266
|
+
위의 앞 JSON 만 읽기로 버린다(mcp-call.mjs 머리 주석).
|
|
267
|
+
"""
|
|
268
|
+
call = pathlib.Path(__file__).parent / 'mcp-call.mjs'
|
|
269
|
+
if not call.exists():
|
|
270
|
+
return None, f'{call.name} 가 없음'
|
|
271
|
+
env = {**os.environ, 'GENETICS_NO_CELL': '1', 'GENETICS_NO_SKILL': '1', 'GENETICS_NO_BEAT': '1'}
|
|
272
|
+
try:
|
|
273
|
+
r = subprocess.run(['node', str(call), '--no-mail', 'list_rejected_names', json.dumps({'tenant': tenant, 'format': 'json'})],
|
|
274
|
+
capture_output=True, text=True, timeout=REJECTED_TIMEOUT, env=env)
|
|
275
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
276
|
+
return None, type(e).__name__
|
|
277
|
+
out = (r.stdout or '').strip()
|
|
278
|
+
if r.returncode != 0 or not out.startswith('{'):
|
|
279
|
+
# 까닭은 도구가 낸 첫 줄(「실패: …」) — 뒤에 덧붙는 세계 소식 줄이 아니다. 출력이 없으면 서버 표준 오류의 끝 줄.
|
|
280
|
+
why = [ln for ln in out.splitlines() if ln.strip() and ln.strip() != 'TOOL_ERROR'][:1] \
|
|
281
|
+
or (r.stderr or '').strip().splitlines()[-1:]
|
|
282
|
+
return None, (why[0] if why else f'종료 코드 {r.returncode}')[:160]
|
|
283
|
+
try:
|
|
284
|
+
got, _end = json.JSONDecoder().raw_decode(out)
|
|
285
|
+
except ValueError as e:
|
|
286
|
+
return None, f'JSON 을 못 읽음 — {e}'
|
|
287
|
+
names = got.get('names') if isinstance(got, dict) else None
|
|
288
|
+
if not isinstance(names, list):
|
|
289
|
+
return None, 'names 가 목록이 아님'
|
|
290
|
+
return [{'name': n.get('name', ''), 'use': n.get('use', ''), 'near': n.get('near') or []}
|
|
291
|
+
for n in names if isinstance(n, dict)], ''
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ── 보관 그림(get_report 표식) ──
|
|
295
|
+
# get_report 는 그림 바이트 대신 `(생략 — NB)` 표식을 준다(report_publish.php report_assets_elide — 되읽기는 바이트를 안 준다).
|
|
296
|
+
# 그 원고를 발행하면 서버가 같은 id 의 저장본을 잇는다(report_carry_assets — 저장본이 없는 id 는 입구 검증이 거절하니 dry_run 이 말한다).
|
|
297
|
+
# 로컬은 바이트가 없으니 도판 **자리**만 서버 기록 치수로 세운다 — 그림 번호·캡션·출처 줄·「[그림]」이 발행본과 같은 자리에 서야
|
|
298
|
+
# --text 독자 검토가 캡션을 읽는다(2026-09-29 실측: 자리를 빼면 독자 본문에서 도판 셋의 16줄 — 「[그림]」·캡션·출처 — 이 빠졌다).
|
|
299
|
+
# file·source 가 있으면 발행이 그것으로 새로 굳힌다(genetics.mjs materializeAssets) — 보관 그림이 아니다.
|
|
300
|
+
# 치수가 없는 표식도 건드리지 않는다 — 전과 같이 ⚠ 로 센다(2026-09-29 받은 판 표본의 figure 표식은 모두 치수가 있었다).
|
|
301
|
+
KEPT_MARK = '(생략'
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def kept_stand_ins(cfg: dict) -> list:
|
|
305
|
+
"""보관 그림의 data 를 서버 기록 치수의 SVG 자리 상자로 바꾸고 그 id 목록을 돌려준다(원고 파일은 안 건드린다 — 합친 설정만)."""
|
|
306
|
+
ids = []
|
|
307
|
+
for a in cfg.get('assets') or []:
|
|
308
|
+
if not (isinstance(a, dict) and a.get('role') == 'figure' and str(a.get('data') or '').startswith(KEPT_MARK)
|
|
309
|
+
and not a.get('file') and not a.get('source') and a.get('width') and a.get('height')):
|
|
310
|
+
continue
|
|
311
|
+
w, h = int(a['width']), int(a['height'])
|
|
312
|
+
# 상자 글은 id 까지만 — 좁은 도판(폭 296·372)에서 긴 풀이 문장이 잘렸다. 풀이는 안내 줄이 한다.
|
|
313
|
+
svg = ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d"><rect width="100%%" height="100%%" fill="#f5f6f7"/>'
|
|
314
|
+
'<text x="50%%" y="50%%" text-anchor="middle" dominant-baseline="middle" font-size="%d" fill="#6b7280">%s</text></svg>'
|
|
315
|
+
% (w, h, max(14, w // 45), html.escape('보관 그림 %s' % a.get('id', ''))))
|
|
316
|
+
a['data'] = 'data:image/svg+xml;base64,' + base64.b64encode(svg.encode('utf-8')).decode('ascii')
|
|
317
|
+
ids.append(str(a.get('id', '')))
|
|
318
|
+
return ids
|
|
319
|
+
|
|
320
|
+
|
|
135
321
|
def main() -> None:
|
|
136
322
|
ap = argparse.ArgumentParser(
|
|
137
323
|
description='단일 HTML 렌더 — 서버 서식을 읽어 관보로 굽는다 (레포 렌더러 호출)')
|
|
@@ -140,6 +326,11 @@ def main() -> None:
|
|
|
140
326
|
# 어긋나면 「스킬로 만든 문서」와 「MCP 로 올린 문서」의 서식이 갈린다.
|
|
141
327
|
ap.add_argument('--template', default='lim-report',
|
|
142
328
|
help='lim-report(기본·관보 서식). 서버 목록에 있는 이름만 받는다')
|
|
329
|
+
# 독자 검토의 「처음 읽는 사람」·「선형 읽기」 관점은 이 파일 하나만 받는다(visible_text()).
|
|
330
|
+
ap.add_argument('--text', metavar='출력.txt',
|
|
331
|
+
help='렌더본에서 화면에 보이는 글만 뽑아 이 파일에 쓴다. 원고 폴더 밖 빈 폴더에 둔다')
|
|
332
|
+
ap.add_argument('--tenant', metavar='slug',
|
|
333
|
+
help='이 소속처가 반려한 이름으로도 센다(MCP list_rejected_names). 없으면 원고의 tenant 를 쓴다')
|
|
143
334
|
args = ap.parse_args()
|
|
144
335
|
|
|
145
336
|
refuse_fork()
|
|
@@ -157,6 +348,29 @@ def main() -> None:
|
|
|
157
348
|
if args.template == 'lim-report':
|
|
158
349
|
cfg.update(t['lim-report'])
|
|
159
350
|
|
|
351
|
+
# 소속처가 반려한 이름 — 원고에 적힌 목록은 버리고(포털 입구와 같다) 서버의 목록만 얹는다.
|
|
352
|
+
cfg.pop(REJECTED_KEY, None)
|
|
353
|
+
tenant = (args.tenant or str(cfg.get('tenant') or '')).strip()
|
|
354
|
+
# flush — 뒤에 도는 build.py 출력보다 먼저 보이게(파일로 돌려 받을 때 순서가 뒤집힌다).
|
|
355
|
+
if not tenant:
|
|
356
|
+
print('⚠ 소속처(--tenant 또는 원고의 tenant)가 없어 반려 이름을 세지 않았습니다', flush=True)
|
|
357
|
+
else:
|
|
358
|
+
names, why = rejected_names_from_server(tenant)
|
|
359
|
+
if why:
|
|
360
|
+
print(f'⚠ 반려 이름을 못 받았습니다({why}) — 이 렌더는 소속처 「{tenant}」의 반려 이름을 세지 않았습니다', flush=True)
|
|
361
|
+
else:
|
|
362
|
+
cfg[REJECTED_KEY] = names
|
|
363
|
+
print(f'소속처 「{tenant}」 반려 이름 {len(names)}개로 셈', flush=True)
|
|
364
|
+
|
|
365
|
+
# 출력 파일 — 원고에 out 이 없으면(get_report 원고) 원고 이름 옆 .html. 서버는 늘 report.html 을 박는다(report_publish.php report_render).
|
|
366
|
+
authored = bool(cfg.get('out')) # out 이 있으면 작성 원고(전달 경로) — get_report 로 받은 판에는 out 이 없다
|
|
367
|
+
if not cfg.get('out'):
|
|
368
|
+
cfg['out'] = cfg_path.with_suffix('.html').name
|
|
369
|
+
kept = kept_stand_ins(cfg)
|
|
370
|
+
if kept:
|
|
371
|
+
print(f'{"⚠ " if authored else ""}보관 그림 {len(kept)}장({", ".join(kept)}) — get_report 표식이라 서버 기록 치수의 자리 상자로 그렸습니다. '
|
|
372
|
+
'발행 때 서버가 같은 id 의 저장본을 잇습니다(저장본이 없는 id 는 dry_run 이 거절합니다) — '
|
|
373
|
+
'이 HTML 은 그림 자리가 상자인 검토본이라 단일 HTML 로 보내지 않습니다', flush=True)
|
|
160
374
|
# 서식을 얹은 설정으로 새로 쓴다 — 원본 report.json 은 안 건드린다.
|
|
161
375
|
merged = cfg_path.parent / f'.render-{args.template}.json'
|
|
162
376
|
merged.write_text(json.dumps(cfg, ensure_ascii=False), encoding='utf-8')
|
|
@@ -165,6 +379,15 @@ def main() -> None:
|
|
|
165
379
|
cwd=str(cfg_path.parent))
|
|
166
380
|
finally:
|
|
167
381
|
merged.unlink(missing_ok=True)
|
|
382
|
+
if r.returncode == 0 and args.text:
|
|
383
|
+
# 렌더러가 쓴 자리 그대로(build.py: 설정 파일 폴더 / cfg['out']) 읽는다.
|
|
384
|
+
text = visible_text((cfg_path.parent / cfg['out']).read_text(encoding='utf-8'))
|
|
385
|
+
out = pathlib.Path(args.text).expanduser().resolve()
|
|
386
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
387
|
+
out.write_text(text, encoding='utf-8')
|
|
388
|
+
print(f'독자용 본문 · {out} {text.count(chr(10)):,}줄')
|
|
389
|
+
if out.parent == cfg_path.parent:
|
|
390
|
+
print(' ⚠ 원고 폴더 안에 썼습니다 — 검토자가 옆의 결정 기록·핸드오프를 열 수 있으니 빈 폴더로 옮기세요')
|
|
168
391
|
sys.exit(r.returncode)
|
|
169
392
|
|
|
170
393
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""스프레드시트의 칸을 **근거 도판**으로 — 원본 파일은 건드리지 않는다.
|
|
3
|
+
|
|
4
|
+
## 왜 이 파일이 생겼나 (2026-09-22)
|
|
5
|
+
|
|
6
|
+
「그 엑셀에서 근거도 캡처해서 관보에 올려놔야 돼」(앳플리 #31 첨삭 13). 고객사가 준 기능정의서·
|
|
7
|
+
정책표의 **그 칸**을 보여 줘야 할 때가 있는데, 방법이 없어 회차마다 문서 폴더에 렌더 스크립트를
|
|
8
|
+
새로 지었다. 옮기는 것은 방법이고, 무엇을 자를지는 그 회차가 정한다.
|
|
9
|
+
|
|
10
|
+
## 하는 일
|
|
11
|
+
|
|
12
|
+
원본을 스크래치에 복사해 ① 대상 시트만 남기고 인쇄 범위를 그 칸으로 지정 → ② LibreOffice 로
|
|
13
|
+
PDF → ③ `pdftoppm` 으로 PNG → ④ 흰 여백을 잘라낸다. **원본은 읽기만 한다.**
|
|
14
|
+
|
|
15
|
+
## 전제 (없으면 이름을 대고 멈춘다)
|
|
16
|
+
|
|
17
|
+
| 무엇 | 왜 |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `openpyxl` | 시트를 고른 사본을 만든다 |
|
|
20
|
+
| `Pillow` | 흰 여백 잘라내기 |
|
|
21
|
+
| LibreOffice (`soffice`) | xlsx → PDF |
|
|
22
|
+
| `pdftoppm` (poppler) | PDF → PNG |
|
|
23
|
+
| **한글 폰트가 LibreOffice 에 보일 것** | ⚠ 이것이 조용한 함정이다 — 아래 |
|
|
24
|
+
|
|
25
|
+
⚠ **LibreOffice 가 시스템 폰트를 못 보는 기계가 있다.** 2026-09-22 실측(macOS, Homebrew cask
|
|
26
|
+
26.8.0): 문서가 지정한 `Noto Sans KR`·`맑은 고딕` 이 없자 번들 `Linux Libertine` 으로 대체했고,
|
|
27
|
+
그 폰트에 한글 글리프가 없어 **한글이 통째로 빠진 PNG** 가 나왔다. 숫자와 라틴만 남아서
|
|
28
|
+
언뜻 정상으로 보인다. 그래서 이 도구는 **렌더 뒤 PDF 에 한글이 실렸는지 스스로 확인하고**,
|
|
29
|
+
안 실렸으면 멈춘다(`--font` 로 대체 폰트를 주거나, LibreOffice 번들 폰트 폴더에 한글 폰트를 넣는다).
|
|
30
|
+
|
|
31
|
+
## 쓰는 법
|
|
32
|
+
|
|
33
|
+
python3 xlsx-plate.py <파일.xlsx> <시트이름> <범위> <나온그림.png> [--font '이름'] [--landscape]
|
|
34
|
+
|
|
35
|
+
# 예 — 기능정의서의 성별 상수표
|
|
36
|
+
python3 xlsx-plate.py 정의서.xlsx '계산식' B40:E48 out/spec-const.png --landscape
|
|
37
|
+
|
|
38
|
+
나온 PNG 는 그대로 도판이 아니다 — 관보 도판은 **가독성 관문**을 지나야 한다.
|
|
39
|
+
`figure.mjs` 가 읽을 HTML 로 감싸 굽는다(`--html` 을 주면 감싸개 HTML 도 함께 쓴다).
|
|
40
|
+
"""
|
|
41
|
+
import argparse
|
|
42
|
+
import os
|
|
43
|
+
import pathlib
|
|
44
|
+
import shutil
|
|
45
|
+
import subprocess
|
|
46
|
+
import sys
|
|
47
|
+
import tempfile
|
|
48
|
+
|
|
49
|
+
CSS = ('body{margin:0;background:#fff}'
|
|
50
|
+
'.wrap{width:1030px;padding:8px 10px 10px;box-sizing:border-box;background:#fff;'
|
|
51
|
+
'font-family:"Pretendard","Apple SD Gothic Neo",sans-serif;color:#1E2124}'
|
|
52
|
+
'.chipline{height:28px;display:flex;align-items:center;gap:10px;font-size:17px;white-space:nowrap}'
|
|
53
|
+
'.chip{display:inline-block;font-size:15px;font-weight:700;padding:2px 10px;border-radius:2px;'
|
|
54
|
+
'background:#E5E7EB;color:#1E2124}'
|
|
55
|
+
'.shot{border:1px solid #D1D5DB;background:#fff;overflow:hidden}.shot img{display:block;width:1008px}')
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def need(what, how):
|
|
59
|
+
sys.exit('✗ %s 가 없습니다 — %s' % (what, how))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def which(*names):
|
|
63
|
+
for n in names:
|
|
64
|
+
p = shutil.which(n)
|
|
65
|
+
if p:
|
|
66
|
+
return p
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def trim(path, pad=6):
|
|
71
|
+
from PIL import Image, ImageChops
|
|
72
|
+
im = Image.open(path).convert('RGB')
|
|
73
|
+
box = ImageChops.difference(im, Image.new('RGB', im.size, (255, 255, 255))).getbbox()
|
|
74
|
+
if box:
|
|
75
|
+
l, t, r, b = box
|
|
76
|
+
im = im.crop((max(0, l - pad), max(0, t - pad),
|
|
77
|
+
min(im.width, r + pad), min(im.height, b + pad)))
|
|
78
|
+
im.save(path)
|
|
79
|
+
return im.size
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main():
|
|
83
|
+
ap = argparse.ArgumentParser(description='스프레드시트 칸을 근거 도판으로(원본 무접촉)')
|
|
84
|
+
ap.add_argument('xlsx')
|
|
85
|
+
ap.add_argument('sheet', help='시트 이름 — 일부만 적어도 하나로 좁혀지면 받는다')
|
|
86
|
+
ap.add_argument('area', help='인쇄 범위 (예: B40:E48)')
|
|
87
|
+
ap.add_argument('out', help='나올 PNG 경로')
|
|
88
|
+
ap.add_argument('--font', default='Apple SD Gothic Neo',
|
|
89
|
+
help='문서 폰트를 이것으로 바꿔 렌더한다(원본은 안 바뀐다). 기본: Apple SD Gothic Neo')
|
|
90
|
+
ap.add_argument('--landscape', action='store_true', help='가로쓰기')
|
|
91
|
+
ap.add_argument('--width', type=float, default=0, help='그 열의 폭을 정한다(긴 글이 잘릴 때)')
|
|
92
|
+
ap.add_argument('--dpi', type=int, default=200, help='PNG 해상도(기본 200 — 축소 배율이 1 이하가 되게)')
|
|
93
|
+
ap.add_argument('--html', help='감싸개 HTML 도 여기 쓴다(figure.mjs 로 굽는 용)')
|
|
94
|
+
ap.add_argument('--label', default='', help='감싸개 칩 글자 (예: 「계산식 시트 B40:E48」)')
|
|
95
|
+
ap.add_argument('--caption', default='', help='감싸개 한 줄 설명')
|
|
96
|
+
a = ap.parse_args()
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
import copy
|
|
100
|
+
import openpyxl
|
|
101
|
+
except ImportError:
|
|
102
|
+
need('openpyxl', 'pip install openpyxl')
|
|
103
|
+
try:
|
|
104
|
+
import PIL # noqa: F401
|
|
105
|
+
except ImportError:
|
|
106
|
+
need('Pillow', 'pip install Pillow')
|
|
107
|
+
soffice = which('soffice', 'libreoffice') or need('LibreOffice', 'brew install --cask libreoffice')
|
|
108
|
+
topng = which('pdftoppm') or need('pdftoppm', 'brew install poppler')
|
|
109
|
+
|
|
110
|
+
src = pathlib.Path(a.xlsx).expanduser().resolve()
|
|
111
|
+
if not src.exists():
|
|
112
|
+
sys.exit('✗ 원본이 없습니다: %s' % src)
|
|
113
|
+
out = pathlib.Path(a.out).expanduser().resolve()
|
|
114
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
|
|
116
|
+
wb = openpyxl.load_workbook(src, data_only=True) # 화면에 보이는 값으로(수식 아님)
|
|
117
|
+
hit = [s for s in wb.sheetnames if s == a.sheet] or [s for s in wb.sheetnames if a.sheet in s]
|
|
118
|
+
if len(hit) != 1:
|
|
119
|
+
sys.exit('✗ 시트를 하나로 못 좁혔습니다(%r) — 있는 것: %s'
|
|
120
|
+
% (a.sheet, ' · '.join(repr(s) for s in wb.sheetnames)))
|
|
121
|
+
sheet = hit[0]
|
|
122
|
+
for s in list(wb.sheetnames):
|
|
123
|
+
if s != sheet:
|
|
124
|
+
del wb[s]
|
|
125
|
+
ws = wb[sheet]
|
|
126
|
+
|
|
127
|
+
# 폰트 바꾸기 — 크기·굵기는 그대로 두고 이름만. 위 ⚠ 참조.
|
|
128
|
+
swapped = 0
|
|
129
|
+
for row in ws.iter_rows():
|
|
130
|
+
for c in row:
|
|
131
|
+
if c.font is not None and c.font.name != a.font:
|
|
132
|
+
g = copy.copy(c.font)
|
|
133
|
+
g.name = a.font
|
|
134
|
+
c.font = g
|
|
135
|
+
swapped += 1
|
|
136
|
+
# 대체 폰트가 원본보다 높아 고정 행 높이에서 글이 잘린다 — 자동 높이로 푼다.
|
|
137
|
+
for r in range(1, ws.max_row + 1):
|
|
138
|
+
ws.row_dimensions[r].height = None
|
|
139
|
+
if a.width:
|
|
140
|
+
col = ''.join(ch for ch in a.area.split(':')[0] if ch.isalpha())
|
|
141
|
+
ws.column_dimensions[col].width = a.width
|
|
142
|
+
n = openpyxl.utils.column_index_from_string(col)
|
|
143
|
+
for row in ws.iter_rows():
|
|
144
|
+
for c in row:
|
|
145
|
+
if c.column == n and c.value is not None:
|
|
146
|
+
al = copy.copy(c.alignment)
|
|
147
|
+
al.wrapText = True
|
|
148
|
+
c.alignment = al
|
|
149
|
+
|
|
150
|
+
ws.print_area = a.area
|
|
151
|
+
ws.page_setup.orientation = 'landscape' if a.landscape else 'portrait'
|
|
152
|
+
ws.page_setup.fitToWidth, ws.page_setup.fitToHeight = 1, 0
|
|
153
|
+
ws.sheet_properties.pageSetUpPr.fitToPage = True
|
|
154
|
+
ws.page_margins.left = ws.page_margins.right = 0.2
|
|
155
|
+
ws.page_margins.top = ws.page_margins.bottom = 0.2
|
|
156
|
+
ws.page_margins.header = ws.page_margins.footer = 0
|
|
157
|
+
for part in (ws.oddHeader, ws.oddFooter):
|
|
158
|
+
part.left.text = part.center.text = part.right.text = ''
|
|
159
|
+
|
|
160
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
161
|
+
work = pathlib.Path(tmp) / 'cut.xlsx'
|
|
162
|
+
wb.save(work)
|
|
163
|
+
subprocess.run([soffice, '--headless', '--convert-to', 'pdf', '--outdir', tmp, str(work)],
|
|
164
|
+
check=True, capture_output=True)
|
|
165
|
+
pdf = pathlib.Path(tmp) / 'cut.pdf'
|
|
166
|
+
if not pdf.exists():
|
|
167
|
+
sys.exit('✗ PDF 가 안 나왔습니다 — LibreOffice 가 이 파일을 못 읽었습니다')
|
|
168
|
+
# ⚠ 한글이 실렸는지 스스로 확인한다 — 안 하면 「한글만 빠진 그림」이 조용히 나간다.
|
|
169
|
+
totext = which('pdftotext')
|
|
170
|
+
if totext:
|
|
171
|
+
txt = subprocess.run([totext, str(pdf), '-'], capture_output=True, text=True).stdout
|
|
172
|
+
src_ko = any('가' <= ch <= '힣' for row in ws.iter_rows()
|
|
173
|
+
for ch in str(row[0].value or '') + ''.join(str(c.value or '') for c in row))
|
|
174
|
+
if src_ko and not any('가' <= ch <= '힣' for ch in txt):
|
|
175
|
+
sys.exit('✗ 원본에는 한글이 있는데 PDF 에는 없습니다 — LibreOffice 가 한글 폰트를 못 봅니다.\n'
|
|
176
|
+
' --font 로 그 기계에 **설치된** 한글 폰트를 주거나, LibreOffice 번들 폰트\n'
|
|
177
|
+
' 폴더(macOS: /Applications/LibreOffice.app/Contents/Resources/fonts/truetype/)에\n'
|
|
178
|
+
' 한글 폰트를 한 벌 넣으세요. 그대로 두면 한글만 빠진 그림이 나갑니다.')
|
|
179
|
+
subprocess.run([topng, '-r', str(a.dpi), '-png', '-f', '1', '-l', '1', str(pdf),
|
|
180
|
+
str(pathlib.Path(tmp) / 'p')], check=True, capture_output=True)
|
|
181
|
+
raw = pathlib.Path(tmp) / 'p-1.png'
|
|
182
|
+
if not raw.exists():
|
|
183
|
+
sys.exit('✗ PNG 가 안 나왔습니다')
|
|
184
|
+
shutil.copyfile(raw, out)
|
|
185
|
+
w, h = trim(str(out))
|
|
186
|
+
print('%s · %s · 폰트교체 %d → %s (%d×%dpx)' % (src.name, a.area, swapped, out, w, h))
|
|
187
|
+
|
|
188
|
+
if a.html:
|
|
189
|
+
hp = pathlib.Path(a.html).expanduser().resolve()
|
|
190
|
+
hp.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
hp.write_text(
|
|
192
|
+
'<!doctype html><meta charset="utf-8"><style>%s</style>'
|
|
193
|
+
'<div class="wrap"><div class="chipline"><span class="chip">%s</span><span>%s</span></div>'
|
|
194
|
+
'<div class="shot"><img src="file://%s" data-dpr="2" data-min-text="12"></div></div>'
|
|
195
|
+
% (CSS, a.label or a.area, a.caption, out), encoding='utf-8')
|
|
196
|
+
print(' 감싸개: %s — figure.mjs 로 구워 관문을 지나세요' % hp)
|
|
197
|
+
# 세로가 길면 R9(표시 높이)에 걸린다. 그때는 범위를 나누거나 --dpi 를 낮춘다.
|
|
198
|
+
if h / 1008 * (w and 1008 / w or 1) > 0:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == '__main__':
|
|
203
|
+
main()
|
|
@@ -55,13 +55,13 @@
|
|
|
55
55
|
| 증상 | `symptom` | 「화면에서 무엇이 잘못 보였나」 — 제보자가 자기 경험과 대조할 유일한 앵커 | 그 행이 통째로 빠짐 |
|
|
56
56
|
| 원인 | `cause` | 「왜 그랬나」 — 조치를 신뢰할지 판단할 근거 | 같음 |
|
|
57
57
|
| 조치 | `fix` | 「무엇을 바꿨고 어디까지 확인했나」 — 본문 최장 | 같음 |
|
|
58
|
-
| 확인 방법 | `how_to_check` | **독자가
|
|
59
|
-
| 확인 요청 | `remaining` | **아직 닫히지 않은 것** —
|
|
58
|
+
| 확인 방법 | `how_to_check` | **독자가 자기 화면에서 볼 수 있는 결과.** 「주문서를 저장하면 목록에 새로고침 없이 바로 보임」 — 주어는 화면·값(SKILL 반드시 지키는 것 5) | 같음 |
|
|
59
|
+
| 확인 요청 | `remaining` | **아직 닫히지 않은 것** — 미확인·미결인 사실(「기준 수치는 미정」 — 부탁은 요청 절 항으로), 그리고 우리 작업이지만 운영에 아직 안 나간 것(출시 전 수정). 대조표의 「미결」 열이 이것으로 갈림 | 그 행이 빠지고 대조표는 「완료」로 표시 |
|
|
60
60
|
|
|
61
61
|
| 규칙 | 근거 | 어기면 |
|
|
62
62
|
|---|---|---|
|
|
63
|
-
| 모든 건에 `how_to_check` 를 채움 | 고객이 자기 화면에서
|
|
64
|
-
| `remaining` 은 **아직 닫히지 않은 것만** — ① 우리가 끝낼 수 없어 상대의 판단·확인이 필요한 것 ② 우리 작업이지만 **운영에 아직 안 나간 것**(출시 전 수정). 없으면 비움. 이 칸의 뜻은 여기가 정본임 | 대조표의 「미결/완료」가
|
|
63
|
+
| 모든 건에 `how_to_check` 를 채움 | 고객이 자기 화면에서 볼 수 있는 결과가 없으면 검수 요청이 아니라 통보임 | **렌더러가 조용히 그 행만 빼고 표를 세움.** 전 건 중 하나만 반쪽인 것을 눈으로는 못 찾음 |
|
|
64
|
+
| `remaining` 은 **아직 닫히지 않은 것만** — ① 우리가 끝낼 수 없어 상대의 판단·확인이 필요한 것(그 사실만 — 부탁 문장은 요청 절) ② 우리 작업이지만 **운영에 아직 안 나간 것**(출시 전 수정). 없으면 비움. 이 칸의 뜻은 여기가 정본임 | 대조표의 「미결/완료」가 아직 안 닫힌 것의 목록임(`gazette.py Doc.board_block()`). ② 를 비우면 대조표·일감이 **완료로 셈** | 전건에 뭔가 적으면 그 열이 무의미해지고, 안 나간 건을 비우면 문서가 끝났다고 말함. 집행자는 **사람 눈** |
|
|
65
65
|
| 다섯 칸이 다 비면 그 건은 **아예 안 실림** | `if not rows: continue`(`gazette.py Doc.sheets_block()`) | 건수는 세는데 문서에는 없음 |
|
|
66
66
|
|
|
67
67
|
### 1.2 나머지 칸
|
|
@@ -286,15 +286,15 @@ MCP 발행 스키마에도 `text_fix` 칸이 없음(`mcp/genetics.mjs`).
|
|
|
286
286
|
### 4.2 치환 유형 — 원고를 쓰면서 적용한다
|
|
287
287
|
|
|
288
288
|
내부 기록을 그대로 옮기면 걸리는 자리 열두 가지임.
|
|
289
|
-
**여기는
|
|
290
|
-
요청 자리(`cta`·`open_items`·PART I
|
|
289
|
+
**여기는 요청 자리 밖이므로 우리·사물이 주어인 개조식으로 적음.**
|
|
290
|
+
요청 자리(`cta`·`open_items`·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)는 이 표가 아니라 SKILL 5·5a 를 따름 —
|
|
291
291
|
거기서는 하십시오체로 맺음. 문체 관문은 요청 자리를 빼고 셈(6.1).
|
|
292
292
|
|
|
293
293
|
| # | 유형 | 방향 | 왜 | 예 (내부 → 고객) |
|
|
294
294
|
|---|---|---|---|---|
|
|
295
|
-
| A | 검수 환경·계정 지칭어 | 조직 내부 명칭 → **용도**로 지칭 | 고객에게 「테스트 매장」은 **자기 매장 중 하나**로
|
|
296
|
-
| B | 배포 이벤트 → **반영 단계** 서술 | 「배포했다」·「커밋했다」 → 반영 단계 넷 가운데 하나: 「앱에 반영함 —
|
|
297
|
-
| C | 티켓 분리 서술 →
|
|
295
|
+
| A | 검수 환경·계정 지칭어 | 조직 내부 명칭 → 독자 화면에 그 매장·계정이 보이면 **그 이름 그대로**(첫 등장에 괄호로 용도 한 번), 독자가 볼 일이 없을 때만 **용도**로 지칭 | 고객에게 「테스트 매장」은 **자기 매장 중 하나**로 읽힘. 용도로 새 이름을 지으면 독자 화면 어디에도 없는 이름이 됨(SKILL 반드시 지키는 것 12 · 0단계 물음 5) | `테스트 매장의 테스트 고객에 올려` → `<독자 화면의 매장 이름> 매장(검수용)의 고객으로 올려` · 화면에 없는 것이면 `검수용 매장·고객으로 올려` |
|
|
296
|
+
| B | 배포 이벤트 → **반영 단계** 서술 | 「배포했다」·「커밋했다」 → 반영 단계 넷 가운데 하나: 「앱에 반영함 — <독자 화면의 채널 이름> 앱 전달 전」 · 「<독자 화면의 채널 이름> 앱에서 확인함」 · 「운영에 반영됨」 · 「반영 전」(채널 이름은 0단계 이름 대응표에서). 실기기 확인이 없으면 같은 문장에 「실기기 미확인」을 붙임. ISO 날짜 → 한국어 날짜 | 고객은 릴리스 절차를 모르고 알 필요도 없음. 알아야 할 것은 「지금 내 쪽에 적용돼 있나」뿐. 동사는 원고 밖 상태 표(무엇 · 코드 반영 · 시험판 전달 · 실기기 · 운영, 칸마다 근거)의 확인 수준을 넘지 않게 고름. 판정 규칙은 이용자가 확인할 수 있는 동작으로만 적고 구현 규칙(「기록에 키가 없으면 프로필 키로 판정」)은 싣지 않음. 커밋·브랜치·파일·시험 도구·시험 건수는 상태 표에만 — 원고에 새면 렌더러 ⚠(lim-portal-format §10-1 「개발 식별자」). 확인 수준 동사는 사람 눈 | `2026-08-09 운영 배포를 마쳤고` → `2026년 8월 9일 운영 환경에 반영함` · `브랜치에 커밋, 시험 7/7 통과` → `앱에 반영함 — <독자 화면의 채널 이름> 앱 전달 전, 실기기 미확인` |
|
|
297
|
+
| C | 티켓 분리 서술 → **제 자리의 건** | 「별도 티켓으로 뺐다」 → 그 일을 제 절의 건으로 세우고 분리 서술은 지움. 가리켜야 하면 뒤 절이 앞 절의 건을 되짚는 방향만(「03-2 건과 같은 원인임」) | 티켓은 고객이 볼 수 없는 곳임. 그 일이 제 자리의 건으로 서면 **「빠진 것」이 「다른 데 적힌 것」이 됨**. 앞 절에서 뒤 절 번호를 가리키면 독자가 장을 오감(SKILL 0.5단계 7행 · readability R12h) | `별도 티켓으로 분리해 처리함` → (삭제 — 그 일은 제 절의 건으로) |
|
|
298
298
|
| D | 내부 작업 규모 수치 제거 | 코드·테스트 카운트 → **보장 범위** 서술 | 「6곳」은 고객이 검증할 수 없는 수치라 신뢰를 못 만들고 **「왜 6곳뿐?」이라는 불안만** 만듦 | `저장 경로 6곳을 수정함` → `저장이 일어나는 모든 경로를 수정함` |
|
|
299
299
|
| E | 배포 채널 용어 → 사용자 체감 사건 | 스토어 빌드·버전 → 「앱 업데이트」 | 현장 인원의 세계에서 일어나는 사건은 「앱이 업데이트된다」뿐임 | `다음 스토어 빌드부터 적용됨` → `다음 앱 업데이트부터 적용됨` |
|
|
300
300
|
| F | 기술 명사 → 관찰 가능한 대상 | 쿠키·세션·업체 → 화면에서 보이는 것 | 독자가 그 명사를 **눈으로 확인할 수 없으면** 문장이 검증 불가능해짐 | `세션이 잠깐 되살아나던` → `로그인 상태가 잠깐 되살아나던` |
|
|
@@ -313,10 +313,10 @@ K 는 **우리 근거의 약함을 지우라는 뜻이 아님** — 약한 근
|
|
|
313
313
|
`별도 티켓` 은 금지, `별건`·`별도 건` 은 허용.
|
|
314
314
|
「건」은 고객도 세는 단위이고 「티켓」은 우리 도구의 이름임.
|
|
315
315
|
|
|
316
|
-
### 4.3 `forbid` — 로컬
|
|
316
|
+
### 4.3 `forbid` — 로컬 렌더는 멈추고, 포털 발행은 경고로 센다
|
|
317
317
|
|
|
318
|
-
`forbid` 는
|
|
319
|
-
`guard()` 가 **파일을 쓰기 전에** 검사하고, 걸리면 빌드가 죽음(`build.py guard()`).
|
|
318
|
+
`forbid` 는 문서마다 거는 금칙어 정규식 목록임.
|
|
319
|
+
로컬 렌더는 `guard()` 가 **파일을 쓰기 전에** 렌더된 산출물 전체 문자열을 검사하고, 걸리면 빌드가 죽음(`build.py guard()`).
|
|
320
320
|
|
|
321
321
|
실측(예시에 `"forbid": ["조회 색인"]` 을 넣고 렌더).
|
|
322
322
|
|
|
@@ -326,12 +326,12 @@ AssertionError: 금지 표현 유출: 조회 색인 → ['조회 색인']
|
|
|
326
326
|
|
|
327
327
|
| 경로 | `forbid` 가 도는가 |
|
|
328
328
|
|---|---|
|
|
329
|
-
| `python3 assets/render.py report.json` |
|
|
330
|
-
| MCP 발행(`publish_report`·`update_report`) |
|
|
329
|
+
| `python3 assets/render.py report.json` | **돈다 — 멈춤.** 위 실측. 렌더된 HTML 을 봄 |
|
|
330
|
+
| MCP 발행(`publish_report`·`update_report`) | **돈다 — 경고.** 렌더러에는 안 넘기고(`REPORT_CLIENT_KEYS` 밖) 입구의 `report_publish.php report_forbid_warnings()` 가 발행 데이터의 글자 칸마다 맞춰 dry_run·실발행 경고로 실음(자리 5곳까지). 발행은 막지 않음. |
|
|
331
331
|
|
|
332
|
-
> **귀결:
|
|
333
|
-
>
|
|
334
|
-
>
|
|
332
|
+
> **귀결: 두 경로의 태도와 훑는 자리가 다름.**
|
|
333
|
+
> 로컬은 렌더된 HTML 에 표현이 남으면 멈추고, 포털은 원고 글자 칸(`data` 한 칸만 뺌 — 렌더러가 안 그리는 `generated_by`·`work` 포함, 렌더러가 스스로 그리는 머리글·라벨은 안 봄)을 세어 경고만 냄 — 두 판정은 갈릴 수 있음.
|
|
334
|
+
> 포털은 패턴을 PCRE(`u`)로 맞춤. 정규식으로 안 읽히거나 빈 문자열에 맞거나 실행 상한에 걸린 패턴은 「세지 못한 패턴이 N개」로 알림 — 그 패턴의 적중은 0 이 아니라 미확인임.
|
|
335
335
|
|
|
336
336
|
한국어 금칙어의 함정 하나.
|
|
337
337
|
**한국어에는 낱말 경계가 없음** — `머지` 가 「**나**머지 절은」을 잡음.
|
|
@@ -402,7 +402,7 @@ AssertionError: 금지 표현 유출: 조회 색인 → ['조회 색인']
|
|
|
402
402
|
| 어미는 **`…던 상황`** 을 기본으로 씀 | 개조식 전환 뒤의 표기임. 예시 두 건 모두 `…던 상황` 임 | `…던 문제` 도 통하지만 문서 안에서 섞어 쓰면 두 종류가 있는 것처럼 읽힘 |
|
|
403
403
|
| **결함이 아닌 항목은 다른 어미로 끝냄** | 방침 반영(`… 동작 제거 (방침 확정)`) · 예방적 정리(`… 연결 기록 정리`) | 전부 「문제/상황」으로 통일하면 **없던 결함을 만들어 냄.** 어미가 항목의 성격 분류자 역할을 함 |
|
|
404
404
|
| 명사구 종결은 **허용** | 목차로 훑는 항목이라 완전문으로 바꾸면 오히려 읽기 어려움 | 제목이 문장이 되어 목차가 장황해짐 |
|
|
405
|
-
| **요약의 `lead`
|
|
405
|
+
| **요약의 `lead` 는 완전문, 확인 요청의 `title` 은 「~ 요청」+기한 괄호(결정 문서는 물음)** | lead 는 판단이 일어나는 자리임. 확인 요청 제목은 명사형 「~ 요청」이 공문 표준이고, 심각도는 본문 첫 문장이 나름 | lead 가 `표본 부족` 같은 명사구면 심각도를 감춤. 제목에 기한 괄호가 없으면 렌더러 ⚠ |
|
|
406
406
|
| 같은 원인에서 갈라진 건은 **증상 실체로 갈라 씀** | 티켓 제목이 똑같아지기 쉬움. 제목만 보고 두 건이 다르다는 걸 알 수 있어야 함 | `다른 고객의 정보가 열리던` / `사전상담이 열리지 않던` 처럼 안 가르면 중복으로 읽힘 |
|
|
407
407
|
|
|
408
408
|
---
|
|
@@ -417,19 +417,19 @@ AssertionError: 금지 표현 유출: 조회 색인 → ['조회 색인']
|
|
|
417
417
|
|
|
418
418
|
| 검사 | 기준 |
|
|
419
419
|
|---|---|
|
|
420
|
-
| **경어체 잔존** | `습니다 · 십시오 · 합니다 · 입니다 · 됩니다 · 드립니다 · 주세요 · 하세요 · 해요` →
|
|
420
|
+
| **경어체 잔존** | `습니다 · 십시오 · 합니다 · 입니다 · 됩니다 · 드립니다 · 주세요 · 하세요 · 해요` → **요청 자리 밖에서 0건** |
|
|
421
421
|
| **한 문장 한 줄** | 문장 경계가 있는데 `<span class="sn">` 도 `<br>` 도 없는 블록 → **0개** |
|
|
422
422
|
|
|
423
|
-
⚠ **경어체 0건은
|
|
424
|
-
요청
|
|
425
|
-
두 문체 관문이 자리를 가림 — 서버 `report_tone.php report_tone_warnings()`(dry_run)는 `open_items[].body`·`cta`·`colophon.note`·`asks` 절 `marg` 와 「」 인용을 빼고 셈(
|
|
423
|
+
⚠ **경어체 0건은 요청 자리 밖에만 거는 기준임.**
|
|
424
|
+
요청 자리(`cta`·`open_items`·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)는 **사실 문장까지 하십시오체로 맺음**(SKILL 5·5a).
|
|
425
|
+
두 문체 관문이 자리를 가림 — 서버 `report_tone.php report_tone_warnings()`(dry_run)는 `open_items[].body`·`cta`·`colophon.note`·`asks` 절 `marg` 와 「」 인용을 빼고 셈(lim-portal-format §10).
|
|
426
426
|
`open_items[].title` 은 셈(제목은 「~ 요청」·물음이 규칙). 건의 `how_to_check` 에서 걸린 요청문은 「요청 절로 옮김」 처방이 따로 섬.
|
|
427
|
-
로컬 `tone.mjs` 는 판단 항목
|
|
428
|
-
|
|
427
|
+
로컬 `tone.mjs` 는 요청 자리 가운데 넷(판단 항목 본문·`cta`·요청 PART 의 PART I 간지·판권지 회신 안내)과 「」 인용을 빼고 셈(2026-09-26) — 읽는 법 요청 한 줄은 셈.
|
|
428
|
+
서버 관문은 PART I 간지의 문장을, 두 관문은 읽는 법 요청 한 줄을 셈 — PART I 이 요청 PART 일 때 그 둘은 의도이고 고치지 않음(통과 기준은 lim-portal-format §10).
|
|
429
429
|
|
|
430
|
-
개조식 `~함 / ~했음 / ~임` 은
|
|
430
|
+
개조식 `~함 / ~했음 / ~임` 은 **요청 자리 밖에서 우리·사물이 주어인 문장**의 문체임.
|
|
431
431
|
명사형 종결(`-ㅁ/-음`)은 청자 높임 등급이 없어, 상대에게 말 거는 자리에 쓰면 하대로 읽힘 —
|
|
432
|
-
「부탁드림」도 존대가 아님.
|
|
432
|
+
「부탁드림」도 존대가 아님. 주어·자리를 안 가리고 개조식을 걸어 **여섯 번 반려됨**(`references/history.md` §5 표).
|
|
433
433
|
|
|
434
434
|
### 6.2 S2 — 늘어짐 지표 (천 자당 밀도)
|
|
435
435
|
|