genetics-mcp 2.22.0 → 2.24.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.
Files changed (31) hide show
  1. package/bin/aarch64-apple-darwin/gcell +0 -0
  2. package/bin/aarch64-unknown-linux-musl/gcell +0 -0
  3. package/bin/manifest.json +5 -5
  4. package/bin/x86_64-apple-darwin/gcell +0 -0
  5. package/bin/x86_64-pc-windows-gnu/gcell.exe +0 -0
  6. package/bin/x86_64-unknown-linux-musl/gcell +0 -0
  7. package/genetics.mjs +2224 -238
  8. package/package.json +3 -2
  9. package/skills/gazette-report/SKILL.md +11 -10
  10. package/skills/gazette-report/scripts/tone.mjs +17 -5
  11. package/skills/gazette-report/scripts/tone.test.mjs +15 -2
  12. package/skills/gazette-report/template/skeleton.html +10 -10
  13. package/skills/genetics-define/SKILL.md +2 -0
  14. package/skills/genetics-report/SKILL.md +93 -87
  15. package/skills/genetics-report/assets/example/cases-briefing.json +1 -1
  16. package/skills/genetics-report/assets/example/cases.json +3 -3
  17. package/skills/genetics-report/assets/example/report-briefing-designs.json +26 -26
  18. package/skills/genetics-report/assets/example/report-briefing.json +3 -3
  19. package/skills/genetics-report/assets/example/report.json +11 -11
  20. package/skills/genetics-report/assets/mcp-call.mjs +109 -0
  21. package/skills/genetics-report/assets/patch.py +196 -0
  22. package/skills/genetics-report/assets/render.py +172 -0
  23. package/skills/genetics-report/assets/xlsx-plate.py +203 -0
  24. package/skills/genetics-report/references/content-pipeline.md +26 -26
  25. package/skills/genetics-report/references/edition-review.workflow.js +70 -15
  26. package/skills/genetics-report/references/history.md +76 -2
  27. package/skills/genetics-report/references/lim-portal-format.md +101 -45
  28. package/skills/genetics-report/references/linear-read-test.workflow.js +50 -14
  29. package/skills/genetics-report/references/process.md +47 -43
  30. package/skills/genetics-report/references/reader-review.workflow.js +105 -28
  31. package/skills/genetics-report/references/visual-first.md +29 -12
@@ -27,8 +27,18 @@ 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)
30
32
 
31
33
  레포 위치는 `GENETICS_REPO` 로 덮어쓸 수 있다.
34
+
35
+ ## 소속처가 반려한 이름 (2026-09-26)
36
+
37
+ 목록은 그 소속처의 반려 결정 장(`remember_decision` 의 `rejected_names` 칸)에 산다 — 제품 저장소에 없다.
38
+ 포털 발행·모의 발행은 서버가 그 목록을 렌더러에 넘긴다. 로컬 렌더는 서버를 안 거치므로 여기서
39
+ 같은 옆 파일 `mcp-call.mjs` 로 `list_rejected_names` 를 불러 받아 얹는다(발행과 같은 목록 · 같은 대조 규칙).
40
+ 못 받으면 렌더는 그대로 하고 「세지 않았다」고 한 줄 말한다 — 조용히 넘기면 「반려 이름 0」으로 읽힌다.
41
+ 원고에 `rejected_names` 가 적혀 있어도 쓰지 않는다(포털 입구도 버린다 — 두 경로가 같아야 한다).
32
42
  """
33
43
  import argparse
34
44
  import json
@@ -37,6 +47,7 @@ import pathlib
37
47
  import re
38
48
  import subprocess
39
49
  import sys
50
+ from html.parser import HTMLParser
40
51
 
41
52
  DEFAULT_REPO = pathlib.Path.home() / 'project/FCG/report-portal'
42
53
 
@@ -132,6 +143,139 @@ def templates_from_server(repo: pathlib.Path) -> dict:
132
143
  }
133
144
 
134
145
 
146
+ # ── 독자용 본문 텍스트(--text) ──
147
+ # 독자 검토의 「처음 읽는 사람」·「선형 읽기」 관점은 원고 JSON 이 아니라 **렌더본에 보이는 글**만 읽는다.
148
+ # JSON 으로 읽히면 독자에게 안 보이는 제작 기록(assets[].origin)이 검토자에게는 보이고, 그것을 근거로 판정한
149
+ # 발견이 나왔다(2026-09-26 스킬 검토: wf_7bfad8fa-89f 발견 4건). origin 은 렌더러가 그리지 않는 칸이라 HTML 에 없다.
150
+ # 빼는 것: head·script·style·template·noscript, svg 의 title·desc(말풍선이라 화면 글이 아니다), hidden 속성이
151
+ # 붙은 요소, HTML 주석, 화면 전용 크롬(아래), 그림의 alt(화면에 안 보이는 글이다).
152
+ _TEXT_SKIP = {'head', 'script', 'style', 'template', 'noscript', 'title', 'desc'}
153
+ # 머리띠 이동줄·진행 막대·측면 색인·「본문 바로가기」는 차례와 절 이름을 한 번 더 늘어놓는 화면 장치라 인쇄본도
154
+ # 지운다(gazette.css @media print 「화면 전용 크롬」). 그 목록의 현황 줄(notice)은 요약면 본문이라 남긴다.
155
+ _TEXT_CHROME = {'topbar', 'progress', 'rail', 'skip'}
156
+ _TEXT_BLOCK = {'address', 'article', 'aside', 'blockquote', 'caption', 'dd', 'div', 'dl', 'dt',
157
+ 'figcaption', 'figure', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
158
+ 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'tbody', 'tfoot',
159
+ 'thead', 'tr', 'ul'}
160
+ _TEXT_VOID = {'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta',
161
+ 'source', 'track', 'wbr'}
162
+
163
+
164
+ class _VisibleText(HTMLParser):
165
+ """렌더된 HTML 에서 화면에 보이는 글만 문서 순서대로 모은다.
166
+
167
+ 그림·영상은 자리 표시(「[그림]」「[영상]」) 한 줄만 남긴다 — 그림 안의 글자는 텍스트로 옮기지 못한다.
168
+ 표 칸은 「 | 」로 잇고 행마다 줄을 바꾼다.
169
+ 클래스가 붙은 인라인 요소와 span·label·i 는 화면에서 따로 서는 조각(문장 조각 `sn`, 차례의 절 번호·제목·건수)이라
170
+ 경계에서 한 칸 띄운다. 클래스 없는 b·strong·em·a 는 낱말에 붙는 강조·링크라 띄우지 않는다(「**1**건」은 「1건」).
171
+ """
172
+
173
+ def __init__(self):
174
+ super().__init__(convert_charrefs=True)
175
+ self.parts = []
176
+ self.open = [] # (태그, 숨김, 경계 띄움) — 닫는 태그를 짝지어 숨김 깊이를 되돌린다
177
+ self.hidden = 0
178
+ self.gap = False
179
+
180
+ def handle_starttag(self, tag, attrs):
181
+ a = dict(attrs)
182
+ if tag in _TEXT_VOID:
183
+ if not self.hidden and tag in ('br', 'hr'):
184
+ self.parts.append('\n')
185
+ elif not self.hidden and tag == 'img':
186
+ self.parts.append('\n[그림]\n')
187
+ return
188
+ hide = (tag in _TEXT_SKIP or tag == 'video' or 'hidden' in a
189
+ or bool(_TEXT_CHROME & set((a.get('class') or '').split())))
190
+ if tag == 'video' and not self.hidden:
191
+ self.parts.append('\n[영상]\n') # 안의 대체 글은 재생기가 있는 화면에서 안 보인다
192
+ sep = tag not in _TEXT_BLOCK and (tag in ('span', 'label', 'i') or 'class' in a)
193
+ self.open.append((tag, hide, sep))
194
+ if hide:
195
+ self.hidden += 1
196
+ elif not self.hidden and tag in _TEXT_BLOCK:
197
+ self.parts.append('\n')
198
+ elif not self.hidden and sep:
199
+ self.gap = True
200
+
201
+ def handle_endtag(self, tag):
202
+ for i in range(len(self.open) - 1, -1, -1):
203
+ if self.open[i][0] == tag:
204
+ sep = self.open[i][2]
205
+ self.hidden -= sum(1 for _, h, _s in self.open[i:] if h)
206
+ del self.open[i:]
207
+ break
208
+ else:
209
+ return # 여는 태그가 없는 닫는 태그(빈 요소의 짝 등)는 건너뛴다
210
+ if self.hidden:
211
+ return
212
+ if tag in ('td', 'th'):
213
+ self.parts.append(' | ')
214
+ elif tag in _TEXT_BLOCK:
215
+ self.parts.append('\n')
216
+ elif sep:
217
+ self.gap = True
218
+
219
+ def handle_data(self, data):
220
+ if self.hidden:
221
+ return
222
+ data = re.sub(r'\s+', ' ', data)
223
+ if not data:
224
+ return
225
+ if self.gap and not data[0].isspace() and self.parts and not self.parts[-1][-1:].isspace():
226
+ data = ' ' + data
227
+ self.gap = False
228
+ self.parts.append(data)
229
+
230
+
231
+ def visible_text(html: str) -> str:
232
+ """렌더본 HTML → 독자용 본문 텍스트. 빈 줄은 하나로 접고 줄 끝 칸 구분자는 걷는다."""
233
+ p = _VisibleText()
234
+ p.feed(html)
235
+ p.close()
236
+ lines = [re.sub(r'(?:\s*\|)+$', '', ln.strip()).strip() for ln in ''.join(p.parts).split('\n')]
237
+ return re.sub(r'\n{3,}', '\n\n', '\n'.join(lines)).strip() + '\n'
238
+
239
+
240
+ # ── 소속처가 반려한 이름(--tenant) ──
241
+ REJECTED_KEY = 'rejected_names' # 렌더러 cfg 의 칸(gazette.py Doc.REJECTED · report_publish.php REPORT_REJECTED_KEY 와 같은 낱말)
242
+ REJECTED_TIMEOUT = 20 # 초 — MCP 서버를 띄워 창구 한 번을 부르는 값. 넘으면 못 받은 것으로 친다
243
+
244
+
245
+ def rejected_names_from_server(tenant: str):
246
+ """(목록, 못 받은 까닭) — 목록은 [{name, use, near}]. 서버가 발행 때 쓰는 그 목록이다(GET /api/wiki/rejected_names).
247
+
248
+ 옆 파일 `mcp-call.mjs` 로 저장소의 genetics.mjs 를 띄운다(npm 판과 무관하게 이 저장소의 도구). 세포 자동 설치·스킬 배치·
249
+ 심박은 이 한 번의 읽기에 필요 없어 끈다. 도구 응답 뒤에 메일·세계 소식이 덧붙을 수 있어 앞의 JSON 한 덩어리만 읽는다.
250
+ `--no-mail` — 이 세션의 신원을 자식에게 안 물려준다. 물려주면 자식이 이 세션 앞 메일을 긷고 「받음」 도장을 찍은 뒤
251
+ 위의 앞 JSON 만 읽기로 버린다(mcp-call.mjs 머리 주석).
252
+ """
253
+ call = pathlib.Path(__file__).parent / 'mcp-call.mjs'
254
+ if not call.exists():
255
+ return None, f'{call.name} 가 없음'
256
+ env = {**os.environ, 'GENETICS_NO_CELL': '1', 'GENETICS_NO_SKILL': '1', 'GENETICS_NO_BEAT': '1'}
257
+ try:
258
+ r = subprocess.run(['node', str(call), '--no-mail', 'list_rejected_names', json.dumps({'tenant': tenant, 'format': 'json'})],
259
+ capture_output=True, text=True, timeout=REJECTED_TIMEOUT, env=env)
260
+ except (OSError, subprocess.TimeoutExpired) as e:
261
+ return None, type(e).__name__
262
+ out = (r.stdout or '').strip()
263
+ if r.returncode != 0 or not out.startswith('{'):
264
+ # 까닭은 도구가 낸 첫 줄(「실패: …」) — 뒤에 덧붙는 세계 소식 줄이 아니다. 출력이 없으면 서버 표준 오류의 끝 줄.
265
+ why = [ln for ln in out.splitlines() if ln.strip() and ln.strip() != 'TOOL_ERROR'][:1] \
266
+ or (r.stderr or '').strip().splitlines()[-1:]
267
+ return None, (why[0] if why else f'종료 코드 {r.returncode}')[:160]
268
+ try:
269
+ got, _end = json.JSONDecoder().raw_decode(out)
270
+ except ValueError as e:
271
+ return None, f'JSON 을 못 읽음 — {e}'
272
+ names = got.get('names') if isinstance(got, dict) else None
273
+ if not isinstance(names, list):
274
+ return None, 'names 가 목록이 아님'
275
+ return [{'name': n.get('name', ''), 'use': n.get('use', ''), 'near': n.get('near') or []}
276
+ for n in names if isinstance(n, dict)], ''
277
+
278
+
135
279
  def main() -> None:
136
280
  ap = argparse.ArgumentParser(
137
281
  description='단일 HTML 렌더 — 서버 서식을 읽어 관보로 굽는다 (레포 렌더러 호출)')
@@ -140,6 +284,11 @@ def main() -> None:
140
284
  # 어긋나면 「스킬로 만든 문서」와 「MCP 로 올린 문서」의 서식이 갈린다.
141
285
  ap.add_argument('--template', default='lim-report',
142
286
  help='lim-report(기본·관보 서식). 서버 목록에 있는 이름만 받는다')
287
+ # 독자 검토의 「처음 읽는 사람」·「선형 읽기」 관점은 이 파일 하나만 받는다(visible_text()).
288
+ ap.add_argument('--text', metavar='출력.txt',
289
+ help='렌더본에서 화면에 보이는 글만 뽑아 이 파일에 쓴다. 원고 폴더 밖 빈 폴더에 둔다')
290
+ ap.add_argument('--tenant', metavar='slug',
291
+ help='이 소속처가 반려한 이름으로도 센다(MCP list_rejected_names). 없으면 원고의 tenant 를 쓴다')
143
292
  args = ap.parse_args()
144
293
 
145
294
  refuse_fork()
@@ -157,6 +306,20 @@ def main() -> None:
157
306
  if args.template == 'lim-report':
158
307
  cfg.update(t['lim-report'])
159
308
 
309
+ # 소속처가 반려한 이름 — 원고에 적힌 목록은 버리고(포털 입구와 같다) 서버의 목록만 얹는다.
310
+ cfg.pop(REJECTED_KEY, None)
311
+ tenant = (args.tenant or str(cfg.get('tenant') or '')).strip()
312
+ # flush — 뒤에 도는 build.py 출력보다 먼저 보이게(파일로 돌려 받을 때 순서가 뒤집힌다).
313
+ if not tenant:
314
+ print('⚠ 소속처(--tenant 또는 원고의 tenant)가 없어 반려 이름을 세지 않았습니다', flush=True)
315
+ else:
316
+ names, why = rejected_names_from_server(tenant)
317
+ if why:
318
+ print(f'⚠ 반려 이름을 못 받았습니다({why}) — 이 렌더는 소속처 「{tenant}」의 반려 이름을 세지 않았습니다', flush=True)
319
+ else:
320
+ cfg[REJECTED_KEY] = names
321
+ print(f'소속처 「{tenant}」 반려 이름 {len(names)}개로 셈', flush=True)
322
+
160
323
  # 서식을 얹은 설정으로 새로 쓴다 — 원본 report.json 은 안 건드린다.
161
324
  merged = cfg_path.parent / f'.render-{args.template}.json'
162
325
  merged.write_text(json.dumps(cfg, ensure_ascii=False), encoding='utf-8')
@@ -165,6 +328,15 @@ def main() -> None:
165
328
  cwd=str(cfg_path.parent))
166
329
  finally:
167
330
  merged.unlink(missing_ok=True)
331
+ if r.returncode == 0 and args.text:
332
+ # 렌더러가 쓴 자리 그대로(build.py: 설정 파일 폴더 / cfg['out']) 읽는다.
333
+ text = visible_text((cfg_path.parent / cfg['out']).read_text(encoding='utf-8'))
334
+ out = pathlib.Path(args.text).expanduser().resolve()
335
+ out.parent.mkdir(parents=True, exist_ok=True)
336
+ out.write_text(text, encoding='utf-8')
337
+ print(f'독자용 본문 · {out} {text.count(chr(10)):,}줄')
338
+ if out.parent == cfg_path.parent:
339
+ print(' ⚠ 원고 폴더 안에 썼습니다 — 검토자가 옆의 결정 기록·핸드오프를 열 수 있으니 빈 폴더로 옮기세요')
168
340
  sys.exit(r.returncode)
169
341
 
170
342
 
@@ -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` 은 **아직 닫히지 않은 것만** — ① 우리가 끝낼 수 없어 상대의 판단·확인이 필요한 것 ② 우리 작업이지만 **운영에 아직 안 나간 것**(출시 전 수정). 없으면 비움. 이 칸의 뜻은 여기가 정본임 | 대조표의 「미결/완료」가 독자의 할 일 목록임(`gazette.py Doc.board_block()`). ② 를 비우면 대조표·일감이 **완료로 셈** | 전건에 뭔가 적으면 그 열이 무의미해지고, 안 나간 건을 비우면 문서가 끝났다고 말함. 집행자는 **사람 눈** |
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 간지·판권지 회신 안내)는 이 표가 아니라 SKILL 5a 를 따름 —
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 | 배포 이벤트 → **반영 단계** 서술 | 「배포했다」·「커밋했다」 → 반영 단계 넷 가운데 하나: 「앱에 반영함 — 시험판 전달 전」 · 「시험판에서 확인함」 · 「운영에 반영됨」 · 「반영 전」. 실기기 확인이 없으면 같은 문장에 「실기기 미확인」을 붙임. ISO 날짜 → 한국어 날짜 | 고객은 릴리스 절차를 모르고 알 필요도 없음. 알아야 할 것은 「지금 내 쪽에 적용돼 있나」뿐. 동사는 원고 밖 상태 표(무엇 · 코드 반영 · 시험판 전달 · 실기기 · 운영, 칸마다 근거)의 확인 수준을 넘지 않게 고름. 판정 규칙은 이용자가 확인할 수 있는 동작으로만 적고 구현 규칙(「기록에 키가 없으면 프로필 키로 판정」)은 싣지 않음. 커밋·브랜치·파일·시험 도구·시험 건수는 상태 표에만 — 원고에 새면 렌더러 ⚠(lim-portal-format §10-1 「개발 식별자」). 확인 수준 동사는 사람 눈 | `2026-08-09 운영 배포를 마쳤고` → `2026년 8월 9일 운영 환경에 반영함` · `브랜치에 커밋, 시험 7/7 통과` → `앱에 반영함 — 시험판 전달 전, 실기기 미확인` |
297
- | C | 티켓 분리 서술 → **문서 내 절 참조** | 「별도 티켓으로 뺐다」 → 「§5.1 에 정리함」 | 티켓은 고객이 볼 수 없는 곳임. 같은 문서 안의 좌표로 바꾸면 **「빠진 것」이 「다른 데 적힌 것」이 됨** | `별도 티켓으로 분리해 처리함` → `§5.1 에 따로 정리함` |
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`) | **안 돈다.** `REPORT_CLIENT_KEYS` 에 없어 서버가 입구에서 깎아 냄(`report_publish.php report_client_fields()`) |
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
- > 그 자리를 메우는 것은 `dry_run` 의 `warnings` 와 사람의 눈임.
334
- > 로컬 렌더를 한 번 돌려 `forbid` 를 통과시키는 것이 지금으로서는 유일한 자동 방어임.
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` 와 확인 요청의 `title` 은 완전문** | 여기가 판단이 일어나는 자리임 | `표본 부족` 같은 명사구는 심각도를 감춤 |
405
+ | **요약의 `lead` 는 완전문, 확인 요청의 `title` 은 「~ 요청」+기한 괄호(결정 문서는 물음)** | lead 는 판단이 일어나는 자리임. 확인 요청 제목은 명사형 「~ 요청」이 공문 표준이고, 심각도는 본문 첫 문장이 나름 | lead 가 `표본 부족` 같은 명사구면 심각도를 감춤. 제목에 기한 괄호가 없으면 렌더러 ⚠ |
406
406
  | 같은 원인에서 갈라진 건은 **증상 실체로 갈라 씀** | 티켓 제목이 똑같아지기 쉬움. 제목만 보고 두 건이 다르다는 걸 알 수 있어야 함 | `다른 고객의 정보가 열리던` / `사전상담이 열리지 않던` 처럼 안 가르면 중복으로 읽힘 |
407
407
 
408
408
  ---
@@ -417,19 +417,19 @@ AssertionError: 금지 표현 유출: 조회 색인 → ['조회 색인']
417
417
 
418
418
  | 검사 | 기준 |
419
419
  |---|---|
420
- | **경어체 잔존** | `습니다 · 십시오 · 합니다 · 입니다 · 됩니다 · 드립니다 · 주세요 · 하세요 · 해요` → **기록 자리에서 0건** |
420
+ | **경어체 잔존** | `습니다 · 십시오 · 합니다 · 입니다 · 됩니다 · 드립니다 · 주세요 · 하세요 · 해요` → **요청 자리 밖에서 0건** |
421
421
  | **한 문장 한 줄** | 문장 경계가 있는데 `<span class="sn">` 도 `<br>` 도 없는 블록 → **0개** |
422
422
 
423
- ⚠ **경어체 0건은 기록 자리에만 거는 기준임.**
424
- 요청 절(`cta`·`open_items`)·PART I 간지·판권지 회신 안내는 **하십시오체로 맺어야 함**(SKILL 5a).
425
- 두 문체 관문이 자리를 가림 — 서버 `report_tone.php report_tone_warnings()`(dry_run)는 `open_items[].body`·`cta`·`colophon.note`·`asks` 절 `marg` 와 「」 인용을 빼고 셈(운영 반영 확인 전 — lim-portal-format §10).
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` 는 판단 항목 본문(`p.ask__body`)과 「」 인용을 빼고 셈(2026-09-12) — `cta`·판권지 회신 안내에서 나는 경고는 로컬에서 사람이 거름(의도).
428
- 두 관문 모두 PART I 간지의 요청 문장은 셈 — 그 한 종류는 의도이고 고치지 않음(통과 기준은 lim-portal-format §10).
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
- 「부탁드림」도 존대가 아님. 자리를 안 가리고 개조식을 걸어 **네 번 반려됨**(`references/history.md` §5 표).
432
+ 「부탁드림」도 존대가 아님. 주어·자리를 안 가리고 개조식을 걸어 **여섯 번 반려됨**(`references/history.md` §5 표).
433
433
 
434
434
  ### 6.2 S2 — 늘어짐 지표 (천 자당 밀도)
435
435