@chrono-meta/fh-gate 3.4.0 → 3.5.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/.claude-plugin/marketplace.json +9 -3
- package/README.md +2 -2
- package/docs/codex-compat.md +1 -1
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +89 -4
- package/package.json +8 -1
- package/plugins/fh-commons/.claude-plugin/plugin.json +2 -2
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/CHANGELOG.md +22 -0
- package/plugins/fh-preprep/.claude-plugin/plugin.json +22 -0
- package/plugins/fh-preprep/README.md +24 -0
- package/plugins/{fh-commons → fh-preprep}/skills/preprep/SKILL.md +75 -3
- package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/mk_slide_fixtures.py +152 -1
- package/plugins/fh-preprep/skills/preprep/lane_attr_consistency.py +370 -0
- package/plugins/fh-preprep/skills/preprep/lane_geometry.py +339 -0
- package/plugins/fh-preprep/skills/preprep/lane_screen_parity.py +232 -0
- package/plugins/fh-preprep/skills/preprep/oox.py +305 -0
- package/plugins/{fh-commons → fh-preprep}/skills/preprep/preprep.py +52 -4
- package/plugins/fh-preprep/skills/preprep/safe_install.py +369 -0
- package/plugins/{fh-commons → fh-preprep}/skills/preprep/surfaces.example.yaml +29 -0
- package/plugins/fh-preprep/skills/preprep/test_preprep_lanes_rp.py +1417 -0
- package/plugins/fh-preprep/skills/preprep/usage_ledger.py +191 -0
- package/plugins/fh-qp/.claude-plugin/plugin.json +1 -1
- package/scripts/fh-run.sh +13 -0
- package/scripts/field_canon_preload.sh +1 -1
- package/scripts/selfcheck.sh +12 -8
- package/scripts/test_fh_run_resolver_lanes.sh +116 -0
- package/scripts/test_files_manifest_shipping_lanes.sh +31 -0
- package/scripts/test_preprep_adjacent_dup_lanes.sh +1 -1
- package/scripts/test_preprep_diagram_lanes.sh +1 -1
- package/scripts/test_preprep_drift_anchor.sh +27 -12
- package/scripts/test_preprep_drift_anchor_lanes.sh +4 -2
- package/scripts/test_preprep_font_lanes.sh +7 -7
- package/scripts/test_preprep_progression_lanes.sh +1 -1
- package/scripts/test_preprep_promise_lanes.sh +1 -1
- package/scripts/test_preprep_retired_lanes.sh +45 -2
- package/scripts/test_preprep_slide_refs_lanes.sh +5 -5
- package/scripts/test_usage_ledger_lanes.sh +243 -0
- package/scripts/usage_report.py +211 -0
- package/plugins/fh-commons/skills/preprep/lane_geometry.py +0 -181
- package/plugins/fh-commons/skills/preprep/test_preprep_lanes_rp.py +0 -184
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/README.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/canon_terms.example.yaml +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/diagram_from_json.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/adjdup_known_negative.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/adjdup_known_positive.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/fixture_R3_negative.pptx +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/fixture_R3_positive.pptx +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/font_revert_probe.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/progression_known_negative.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/progression_known_positive.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/promise_known_negative.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/promise_known_positive.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/interslide_deps.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/jargon_terms.example.yaml +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_adjacent_dup.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_diagram.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_font.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_progression.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_promise.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_slide_refs.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_slide_relations.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/ooxml/c1_baseline.txt +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/ooxml/gate.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/preprep_wire.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/presentation_checklist.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/test_lane_font.py +0 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""oox — 세 레인(geometry · attr_consistency · screen_parity)이 공유하는 OOXML 독자.
|
|
3
|
+
|
|
4
|
+
🟥 왜 있나 (2026-09-11, codex cross-family R1~R6 — 여섯 라운드 연속): 각 레인이 정규식으로 XML 을 읽었고
|
|
5
|
+
매 라운드 같은 부류가 S/A 를 냈다 — 속성 순서(`<a:off y= x=>`·`<p:sldId r:id= id=>`·`<p:sldSz cy= cx=>`),
|
|
6
|
+
접두 변이(`<q:sldId xmlns:q=…>`), 여는 태그의 속성(`<p:sp useBgFill>`), 엔티티(`&` vs `&`),
|
|
7
|
+
중첩 그룹의 변환(flip·이동)이 자식에 안 붙음. 「조이지 말고 줄여라」— 정규식을 지우고 **트리로 읽는다**.
|
|
8
|
+
태그는 지역명(local-name)으로 맞춘다: 실물의 `p:`/`a:` 도, 레인 픽스처의 가짜 접두(`xmlns:p="p"`)도 같이 읽힌다.
|
|
9
|
+
그룹은 grpSpPr/xfrm(off·ext·chOff·chExt·flipH/V) 을 **재귀로 합성**해 자식 좌표를 절대 EMU 로 낸다.
|
|
10
|
+
|
|
11
|
+
이 파일은 정규식을 «안» 쓴다(접두 결박 한 줄 제외). 도형의 «무엇»(이름·좌표·flip·문단·런·선)만 낸다 —
|
|
12
|
+
판정은 각 레인의 몫이다.
|
|
13
|
+
"""
|
|
14
|
+
import re, zipfile, xml.etree.ElementTree as ET
|
|
15
|
+
|
|
16
|
+
_PREFIX_RE = re.compile(r'\b(p|a|r):')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_xml(s):
|
|
20
|
+
"""XML 문자열 → 루트. 레인 픽스처처럼 접두가 «선언 없이» 쓰인 문서도 읽는다(루트에 임시 결박)."""
|
|
21
|
+
if isinstance(s, bytes):
|
|
22
|
+
s = s.decode('utf-8', 'replace')
|
|
23
|
+
_NSMAP.clear(); _NSMAP.update({p_: u for p_, u in re.findall(r'xmlns:([\w.-]+)\s*=\s*["\']([^"\']*)["\']', s)})
|
|
24
|
+
m = None
|
|
25
|
+
for m_ in re.finditer(r'<!--.*?-->|<\?.*?\?>|<!\[CDATA\[.*?\]\]>|<([A-Za-z_][\w.-]*:)?[A-Za-z_][\w.-]*\b[^>]*>', s, re.S): # R8 B10: 주석·PI 안의 태그는 루트가 아니다
|
|
26
|
+
if not m_.group(0).startswith(('<!--', '<?', '<![CDATA[')):
|
|
27
|
+
m = m_
|
|
28
|
+
break
|
|
29
|
+
if m:
|
|
30
|
+
head = m.group(0)
|
|
31
|
+
need = [p for p in ('p', 'a', 'r') if not re.search(r'xmlns:%s\s*=' % p, head) and re.search(r'\b%s:' % p, s)] # R7 B8: `xmlns:p = "…"` 도 선언이다
|
|
32
|
+
if need:
|
|
33
|
+
inj = ''.join(' xmlns:%s="urn:fh-unbound-%s"' % (p, p) for p in need)
|
|
34
|
+
s = s[:m.start()] + head[:-1].rstrip('/') + inj + ('/>' if head.endswith('/>') else '>') + s[m.end():]
|
|
35
|
+
return ET.fromstring(s)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def local(tag):
|
|
39
|
+
return tag.rsplit('}', 1)[-1] if '}' in tag else tag.split(':', 1)[-1]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def attr(el, name):
|
|
43
|
+
"""지역명으로 속성 찾기 — `r:id` 든 `{ns}id` 든."""
|
|
44
|
+
for k, v in el.attrib.items():
|
|
45
|
+
if local(k) == name:
|
|
46
|
+
return v
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def children(el, name):
|
|
51
|
+
return [c for c in el if local(c.tag) == name]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def child(el, name):
|
|
55
|
+
cs = children(el, name)
|
|
56
|
+
return cs[0] if cs else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def find_desc(el, name):
|
|
60
|
+
for d in el.iter():
|
|
61
|
+
if d is not el and local(d.tag) == name:
|
|
62
|
+
return d
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── 발표 순서 ─────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def slide_order(z):
|
|
69
|
+
"""[슬라이드 파일 번호 …] 발표 순서. 못 푸는 r:id 는 KeyError 로 올라간다(조용히 안 빠진다)."""
|
|
70
|
+
rels = {}
|
|
71
|
+
for rel in parse_xml(z.read('ppt/_rels/presentation.xml.rels')).iter():
|
|
72
|
+
if local(rel.tag) != 'Relationship':
|
|
73
|
+
continue
|
|
74
|
+
tgt = attr(rel, 'Target') or ''
|
|
75
|
+
if tgt.startswith('/'): # R8 B7: OPC 절대 Target
|
|
76
|
+
tgt = tgt[len('/ppt/'):] if tgt.startswith('/ppt/') else tgt.lstrip('/')
|
|
77
|
+
m = re.fullmatch(r'slides/slide(\d+)\.xml', tgt)
|
|
78
|
+
if m and attr(rel, 'Id'):
|
|
79
|
+
rels[attr(rel, 'Id')] = int(m.group(1))
|
|
80
|
+
pres = parse_xml(z.read('ppt/presentation.xml'))
|
|
81
|
+
lst = find_desc(pres, 'sldIdLst')
|
|
82
|
+
out = []
|
|
83
|
+
for sid in (list(lst) if lst is not None else []):
|
|
84
|
+
if local(sid.tag) != 'sldId':
|
|
85
|
+
continue
|
|
86
|
+
rid = attr(sid, 'id') if attr(sid, 'id') and not str(attr(sid, 'id')).isdigit() else None
|
|
87
|
+
# r:id 와 id 가 둘 다 «id» 지역명이다 — 숫자가 아닌 쪽이 관계 id
|
|
88
|
+
for k, v in sid.attrib.items():
|
|
89
|
+
if local(k) == 'id' and not str(v).isdigit():
|
|
90
|
+
rid = v
|
|
91
|
+
out.append(rels[rid])
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def slide_size(z):
|
|
96
|
+
"""(cx, cy) EMU. 없으면 None."""
|
|
97
|
+
pres = parse_xml(z.read('ppt/presentation.xml'))
|
|
98
|
+
sz = find_desc(pres, 'sldSz')
|
|
99
|
+
if sz is None or attr(sz, 'cx') is None or attr(sz, 'cy') is None:
|
|
100
|
+
return None
|
|
101
|
+
return int(attr(sz, 'cx')), int(attr(sz, 'cy'))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── 변환 합성 ─────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
class _T:
|
|
107
|
+
"""그룹 변환의 «사슬». 자식 좌표 → (chOff 빼고 × scale) → 이 그룹 자신의 flip 으로 상자 안에서 반사 → off 더해
|
|
108
|
+
부모 좌표 → 부모에게 같은 절차. 반사를 **각 단계에서** 하므로 바깥 그룹의 flip 이 안쪽 그룹의 내부 배치까지 거울로
|
|
109
|
+
옮긴다(R7 A4 — 초판은 표식만 XOR 하고 자리를 안 옮겨 «이동» 이 사라졌다).
|
|
110
|
+
회전(rot)은 합성하지 않는다 — 사슬 어딘가에 rot 가 있으면 `rot_unmeasured` 로 이름을 남긴다(잔여)."""
|
|
111
|
+
__slots__ = ('parent', 'ox', 'oy', 'sx', 'sy', 'chx', 'chy', 'gcx', 'gcy', 'own', 'flips', 'rot_unmeasured', 'geo_unmeasured')
|
|
112
|
+
|
|
113
|
+
def __init__(self):
|
|
114
|
+
self.parent = None
|
|
115
|
+
self.ox = self.oy = 0; self.sx = self.sy = 1.0; self.chx = self.chy = 0; self.gcx = self.gcy = 0
|
|
116
|
+
self.own = set(); self.flips = set(); self.rot_unmeasured = False; self.geo_unmeasured = False
|
|
117
|
+
|
|
118
|
+
def _apply_f(self, x, y, cx, cy):
|
|
119
|
+
if self.parent is None:
|
|
120
|
+
return (x, y, cx, cy)
|
|
121
|
+
lx, ly = (x - self.chx) * self.sx, (y - self.chy) * self.sy
|
|
122
|
+
w, h = cx * self.sx, cy * self.sy
|
|
123
|
+
if 'H' in self.own:
|
|
124
|
+
lx = self.gcx - lx - w
|
|
125
|
+
if 'V' in self.own:
|
|
126
|
+
ly = self.gcy - ly - h
|
|
127
|
+
return self.parent._apply_f(self.ox + lx, self.oy + ly, w, h)
|
|
128
|
+
|
|
129
|
+
def apply(self, x, y, cx, cy):
|
|
130
|
+
return tuple(int(round(v)) for v in self._apply_f(x, y, cx, cy))
|
|
131
|
+
|
|
132
|
+
def then(self, xfrm):
|
|
133
|
+
"""이 변환 «안» 의 그룹 xfrm 을 합성한 새 변환."""
|
|
134
|
+
t = _T()
|
|
135
|
+
t.parent = self
|
|
136
|
+
off, ext, choff, chext = (child(xfrm, n) for n in ('off', 'ext', 'chOff', 'chExt'))
|
|
137
|
+
gx, gy = (int(attr(off, 'x') or 0), int(attr(off, 'y') or 0)) if off is not None else (0, 0)
|
|
138
|
+
gcx, gcy = (int(attr(ext, 'cx') or 0), int(attr(ext, 'cy') or 0)) if ext is not None else (0, 0)
|
|
139
|
+
cx0, cy0 = (int(attr(choff, 'x') or 0), int(attr(choff, 'y') or 0)) if choff is not None else (gx, gy)
|
|
140
|
+
ccx, ccy = (int(attr(chext, 'cx') or 0), int(attr(chext, 'cy') or 0)) if chext is not None else (gcx, gcy)
|
|
141
|
+
t.ox, t.oy = gx, gy # 부모 좌표계 값 그대로 — 부모 변환은 사슬이 통과시킨다
|
|
142
|
+
t.sx = (gcx / ccx) if ccx else 1.0
|
|
143
|
+
t.sy = (gcy / ccy) if ccy else 1.0
|
|
144
|
+
t.chx, t.chy = cx0, cy0
|
|
145
|
+
t.gcx, t.gcy = gcx, gcy # 이 그룹 상자(부모 좌표계 크기) — 자식 반사의 기준
|
|
146
|
+
for k in ('H', 'V'):
|
|
147
|
+
if attr(xfrm, 'flip' + k) in ('1', 'true'):
|
|
148
|
+
t.own.add(k)
|
|
149
|
+
t.flips = set(self.flips) ^ t.own
|
|
150
|
+
t.rot_unmeasured = self.rot_unmeasured or attr(xfrm, 'rot') not in (None, '0')
|
|
151
|
+
# R8 B11: ext 또는 chExt 가 0/부재인 그룹은 배율이 정의되지 않는다 — «안 움직였다» 로 접지 말고 이름으로 남긴다
|
|
152
|
+
t.geo_unmeasured = self.geo_unmeasured or not (gcx and gcy and ccx and ccy)
|
|
153
|
+
return t
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _xfrm_of(el):
|
|
157
|
+
"""도형 요소의 xfrm — spPr/grpSpPr/graphicFrame 의 p:xfrm 중 첫 것."""
|
|
158
|
+
for d in el.iter():
|
|
159
|
+
if d is not el and local(d.tag) == 'xfrm':
|
|
160
|
+
return d
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _text_of(el):
|
|
165
|
+
return ''.join(el.itertext())
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# mc:AlternateContent — «우리가 읽는» 네임스페이스. Choice 의 Requires 접두가 전부 여기 결박돼 있으면 그 Choice 를 고르고,
|
|
169
|
+
# 하나라도 모르는 네임스페이스면 Fallback 을 고른다(OOXML Part 3 §8.3.4.2.1 — 이해 못 하는 Choice 는 건너뛴다). R9 A7a.
|
|
170
|
+
KNOWN_NS = {
|
|
171
|
+
'http://schemas.openxmlformats.org/presentationml/2006/main',
|
|
172
|
+
'http://schemas.openxmlformats.org/drawingml/2006/main',
|
|
173
|
+
'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
|
174
|
+
'http://schemas.openxmlformats.org/markup-compatibility/2006',
|
|
175
|
+
'http://schemas.microsoft.com/office/powerpoint/2010/main',
|
|
176
|
+
'http://schemas.microsoft.com/office/powerpoint/2012/main',
|
|
177
|
+
'http://schemas.microsoft.com/office/drawing/2010/main',
|
|
178
|
+
'http://schemas.microsoft.com/office/drawing/2014/main',
|
|
179
|
+
}
|
|
180
|
+
_NSMAP = {} # 마지막으로 parse_xml 한 문서의 접두 → URI (Requires 판정용)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def select_alternate(el):
|
|
184
|
+
"""mc:AlternateContent 원소 → 걸을 자식(Choice 또는 Fallback) 또는 None.
|
|
185
|
+
빈 Choice 라도 «이해하는» Requires 면 그 Choice(=아무것도 안 그림)다 — Element 진리값(길이)으로 고르지 않는다(R8 B6)."""
|
|
186
|
+
for c in el:
|
|
187
|
+
if local(c.tag) != 'Choice':
|
|
188
|
+
continue
|
|
189
|
+
req = (attr(c, 'Requires') or '').split()
|
|
190
|
+
# 접두 p·a·r·mc 는 이 독자가 «이름으로» 읽는 것이라 URI 가 무엇이든 이해한다(레인 픽스처는 가짜 URI 를 쓴다)
|
|
191
|
+
if all(p_ in ('p', 'a', 'r', 'mc') or _NSMAP.get(p_) in KNOWN_NS for p_ in req):
|
|
192
|
+
return c
|
|
193
|
+
# 모르는 네임스페이스를 요구하는 Choice 는 건너뛴다 → 다음 Choice / Fallback
|
|
194
|
+
return child(el, 'Fallback')
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _iter_selected(el):
|
|
198
|
+
"""el 의 자손을 문서 순서로 — 단 AlternateContent 는 «선택된 분기» 만 들어간다(R9 A8: 양쪽 문단을 다 세던 구멍)."""
|
|
199
|
+
for c in el:
|
|
200
|
+
if local(c.tag) == 'AlternateContent':
|
|
201
|
+
alt = select_alternate(c)
|
|
202
|
+
if alt is not None:
|
|
203
|
+
yield from _iter_selected(alt)
|
|
204
|
+
continue
|
|
205
|
+
yield c
|
|
206
|
+
yield from _iter_selected(c)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _paras(el):
|
|
210
|
+
"""[{'algn': str|None, 'runs': [(text, sz_pt|None)]}] — 문단 단위, endParaRPr 제외, fld 포함."""
|
|
211
|
+
out = []
|
|
212
|
+
for p in _iter_selected(el):
|
|
213
|
+
if local(p.tag) != 'p':
|
|
214
|
+
continue
|
|
215
|
+
ppr = child(p, 'pPr')
|
|
216
|
+
algn = attr(ppr, 'algn') if ppr is not None else None
|
|
217
|
+
runs = []
|
|
218
|
+
for r in p:
|
|
219
|
+
if local(r.tag) in ('r', 'fld'):
|
|
220
|
+
rpr = child(r, 'rPr')
|
|
221
|
+
sz = int(attr(rpr, 'sz')) / 100 if (rpr is not None and attr(rpr, 'sz')) else None
|
|
222
|
+
t = ''.join(x.text or '' for x in r.iter() if local(x.tag) == 't')
|
|
223
|
+
runs.append((t, sz))
|
|
224
|
+
elif local(r.tag) == 'br':
|
|
225
|
+
runs.append(('\n', None))
|
|
226
|
+
out.append({'algn': algn, 'runs': runs})
|
|
227
|
+
return out
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def walk_slide(z, sn):
|
|
231
|
+
"""한 슬라이드의 «화면에 놓이는» 도형 전부(그룹은 풀어서, 좌표는 절대 EMU 로 합성).
|
|
232
|
+
반환: [{'name','kind','x','y','cx','cy','flip','paras','ln_w','dash','in_group'} …] 문서 순서."""
|
|
233
|
+
root = parse_xml(z.read('ppt/slides/slide%d.xml' % sn))
|
|
234
|
+
tree = find_desc(root, 'spTree')
|
|
235
|
+
if tree is None:
|
|
236
|
+
return []
|
|
237
|
+
out = []
|
|
238
|
+
_walk(tree, _T(), out, False)
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _walk(container, T, out, in_group):
|
|
243
|
+
for el in container:
|
|
244
|
+
kind = local(el.tag)
|
|
245
|
+
if kind == 'AlternateContent': # R7 A5 · R8 B6 · R9 A7a: 선택 규칙은 select_alternate 한 곳
|
|
246
|
+
alt = select_alternate(el)
|
|
247
|
+
if alt is not None:
|
|
248
|
+
_walk(alt, T, out, in_group)
|
|
249
|
+
continue
|
|
250
|
+
if kind == 'grpSp':
|
|
251
|
+
gpr = child(el, 'grpSpPr')
|
|
252
|
+
xf = child(gpr, 'xfrm') if gpr is not None else None
|
|
253
|
+
_walk(el, T.then(xf) if xf is not None else T, out, True)
|
|
254
|
+
continue
|
|
255
|
+
if kind not in ('sp', 'cxnSp', 'pic', 'graphicFrame'):
|
|
256
|
+
continue
|
|
257
|
+
nv = None
|
|
258
|
+
for d in el:
|
|
259
|
+
if local(d.tag).startswith('nv') and local(d.tag).endswith('Pr'):
|
|
260
|
+
nv = d
|
|
261
|
+
cnv = child(nv, 'cNvPr') if nv is not None else None
|
|
262
|
+
name = attr(cnv, 'name') if cnv is not None else None
|
|
263
|
+
xf = _xfrm_of(el)
|
|
264
|
+
geo = None
|
|
265
|
+
if xf is not None:
|
|
266
|
+
off, ext = child(xf, 'off'), child(xf, 'ext')
|
|
267
|
+
if off is not None and ext is not None and all(attr(off, k) is not None for k in ('x', 'y')) and all(attr(ext, k) is not None for k in ('cx', 'cy')):
|
|
268
|
+
geo = T.apply(int(attr(off, 'x')), int(attr(off, 'y')), int(attr(ext, 'cx')), int(attr(ext, 'cy')))
|
|
269
|
+
flips = set(T.flips)
|
|
270
|
+
if xf is not None:
|
|
271
|
+
for k in ('H', 'V'):
|
|
272
|
+
if attr(xf, 'flip' + k) in ('1', 'true'):
|
|
273
|
+
flips ^= {k}
|
|
274
|
+
ln = None
|
|
275
|
+
spr = child(el, 'spPr')
|
|
276
|
+
if spr is not None:
|
|
277
|
+
ln = child(spr, 'ln')
|
|
278
|
+
ln_w = int(attr(ln, 'w')) if (ln is not None and attr(ln, 'w')) else None
|
|
279
|
+
dash_el = child(ln, 'prstDash') if ln is not None else None
|
|
280
|
+
dash = attr(dash_el, 'val') if dash_el is not None else None
|
|
281
|
+
# 문단: txBody 의 것 + 표(graphicFrame) 셀의 것
|
|
282
|
+
paras = []
|
|
283
|
+
tx = child(el, 'txBody')
|
|
284
|
+
if tx is not None:
|
|
285
|
+
paras = _paras(tx)
|
|
286
|
+
elif kind == 'graphicFrame':
|
|
287
|
+
paras = _paras(el)
|
|
288
|
+
out.append(dict(name=name, kind=kind, x=geo[0] if geo else None, y=geo[1] if geo else None,
|
|
289
|
+
cx=geo[2] if geo else None, cy=geo[3] if geo else None,
|
|
290
|
+
flip=''.join(k for k in ('H', 'V') if k in flips), paras=paras,
|
|
291
|
+
ln_w=ln_w, dash=dash, in_group=in_group, rot_unmeasured=T.rot_unmeasured, geo_unmeasured=T.geo_unmeasured))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def para_texts(paras):
|
|
295
|
+
"""문단 텍스트 목록(런 '' 로 이어붙임, 공백 정규화, 빈 문단 제외)."""
|
|
296
|
+
res = []
|
|
297
|
+
for p in paras:
|
|
298
|
+
t = re.sub(r'\s+', ' ', ''.join(t for t, _ in p['runs'])).strip()
|
|
299
|
+
if t:
|
|
300
|
+
res.append(t)
|
|
301
|
+
return res
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def shape_text(paras):
|
|
305
|
+
return ' '.join(para_texts(paras))
|
|
@@ -776,8 +776,9 @@ def main():
|
|
|
776
776
|
# [[feedback_rule_misdescribes_its_own_machine]] · [[feedback_instrument_vs_target_and_budget]]
|
|
777
777
|
_lanes = sorted(n[5:] for n in globals() if n.startswith('lane_') and callable(globals()[n]))
|
|
778
778
|
_mods = sorted(m for m in ('lane_promise', 'lane_adjacent_dup', 'lane_progression',
|
|
779
|
-
'lane_slide_relations', 'lane_geometry', '
|
|
780
|
-
'
|
|
779
|
+
'lane_slide_relations', 'lane_geometry', 'lane_attr_consistency',
|
|
780
|
+
'lane_screen_parity',
|
|
781
|
+
'lane_diagram', 'lane_slide_refs', 'lane_font')
|
|
781
782
|
if os.path.exists(os.path.join(HERE, m + '.py')))
|
|
782
783
|
# 🟥 2026-09-04 신설 — `--lane R1,R2,...` 로 **새 모듈 레인(R1-R5·P1/P3)만** 골라 끈다/켠다.
|
|
783
784
|
# L1~L11 은 아직 이 필터를 안 탄다(usage 줄의 --lane 은 그쪽엔 미배선인 채 남아 있다 —
|
|
@@ -793,7 +794,30 @@ def main():
|
|
|
793
794
|
print(f" 레인 {len(_mods)}(모듈): {' · '.join(m[5:] for m in _mods) if _mods else '🟥 없음 — 갈라진 사본일 수 있다'}")
|
|
794
795
|
cfg_path = sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith('--') \
|
|
795
796
|
else os.path.join(HERE, 'surfaces.yaml')
|
|
796
|
-
|
|
797
|
+
# 🟥 2026-09-13 — 설정을 못 읽는 것은 «발견» 이 아니라 «판정불가» 다.
|
|
798
|
+
# 종전엔 `load()` 가 그대로 터져 트레이스백 + rc=1 이었고, 이 파일의 선언(SKILL.md:164
|
|
799
|
+
# 「0 통과 · 1 발견 · 2 판정불가(계기오류/미측정)」)에서 **1 은 「발견 있음」**이다.
|
|
800
|
+
# 즉 「아무것도 못 쟀다」가 「뭔가 찾았다」와 **같은 종료코드**로 나갔다 — 부르는 쪽이
|
|
801
|
+
# 둘을 못 가른다. 이 저장소가 이름으로 관리하는 「미측정을 0(또는 남의 값)으로 렌더」의
|
|
802
|
+
# 한 얼굴이다. 그래서 여기서 잡아 **2** 로 확정한다(설정이 없으면 레인은 한 줄도 안 돌았다).
|
|
803
|
+
try:
|
|
804
|
+
cfg = load(cfg_path)
|
|
805
|
+
except FileNotFoundError:
|
|
806
|
+
print(f"\n{ANSI_BAD} 설정을 못 찾았다: {cfg_path}")
|
|
807
|
+
print(f" 이 디렉터리에 surfaces.example.yaml 이 있다 — 복사해서 surfaces.yaml 로 쓰거나,")
|
|
808
|
+
print(f" 설정 경로를 첫 인자로 줘라: python3 preprep.py <설정.yaml>")
|
|
809
|
+
print(f" ⚠️ 이것은 «발견 0» 이 아니라 «한 줄도 안 쟀다» 다 (exit 2 = 판정불가).")
|
|
810
|
+
return 2
|
|
811
|
+
except Exception as e:
|
|
812
|
+
print(f"\n{ANSI_BAD} 설정을 읽을 수 없다: {cfg_path}\n {type(e).__name__}: {e}")
|
|
813
|
+
print(f" ⚠️ «발견 0» 이 아니라 «한 줄도 안 쟀다» 다 (exit 2 = 판정불가).")
|
|
814
|
+
return 2
|
|
815
|
+
if not isinstance(cfg, dict) or 'root' not in cfg or 'surfaces' not in cfg:
|
|
816
|
+
# 파싱은 됐는데 계약이 아니다 — 빈 파일(`yaml.safe_load` → None)이 여기로 온다.
|
|
817
|
+
# 이것도 「돌았는데 발견 0」이 아니라 「돌 수 없었다」다.
|
|
818
|
+
print(f"\n{ANSI_BAD} 설정에 필수 키가 없다: {cfg_path} (root · surfaces)")
|
|
819
|
+
print(f" ⚠️ «발견 0» 이 아니라 «한 줄도 안 쟀다» 다 (exit 2 = 판정불가).")
|
|
820
|
+
return 2
|
|
797
821
|
root = os.path.expanduser(cfg['root'])
|
|
798
822
|
surf = {s['id']: s for s in cfg['surfaces']}
|
|
799
823
|
|
|
@@ -899,6 +923,21 @@ def main():
|
|
|
899
923
|
except Exception as _e:
|
|
900
924
|
notes.append('P1/P3 geometry : 계기 미실행 — NOT_WIRED (%s: %s) (0 아님)'
|
|
901
925
|
% (type(_e).__name__, _e))
|
|
926
|
+
try:
|
|
927
|
+
import lane_screen_parity
|
|
928
|
+
_f14, _n14 = lane_screen_parity.scan(cfg, root)
|
|
929
|
+
notes += _n14 # advisory 고정 — L8·L11 관례
|
|
930
|
+
except Exception as _e:
|
|
931
|
+
notes.append('L14 screen-parity : 계기 미실행 — NOT_WIRED (%s: %s) (0 아님)'
|
|
932
|
+
% (type(_e).__name__, _e))
|
|
933
|
+
if _lane_on('P4', 'P'):
|
|
934
|
+
try:
|
|
935
|
+
import lane_attr_consistency
|
|
936
|
+
_f4, _n4 = lane_attr_consistency.scan(cfg, root)
|
|
937
|
+
notes += _n4 # advisory 고정 — 같은 관례
|
|
938
|
+
except Exception as _e:
|
|
939
|
+
notes.append('P4 attr-consistency : 계기 미실행 — NOT_WIRED (%s: %s) (0 아님)'
|
|
940
|
+
% (type(_e).__name__, _e))
|
|
902
941
|
|
|
903
942
|
for n in notes: print(f" · {n}")
|
|
904
943
|
use = [f for f in findings if f[1] != 'mention']
|
|
@@ -922,4 +961,13 @@ def main():
|
|
|
922
961
|
# 못 쓴다(레인 하나만 부르려 해도 전체가 돌고 종료한다). 배포본이 된 뒤에 드러난 결함이다:
|
|
923
962
|
# 스킬 문서가 «레인을 골라 부를 수 있다»는 인상을 주는데 실제로는 CLI 한 형태뿐이었다.
|
|
924
963
|
if __name__ == '__main__':
|
|
925
|
-
|
|
964
|
+
# ── 사용 원장 (2026-09-13) — 🟥 **옵트인**이다. `FH_USAGE_LEDGER` 가 없으면 완전 무동작이고
|
|
965
|
+
# 배포본에서 아무것도 안 남긴다(동의 없는 계측 금지 · 부재 ≠ 승인).
|
|
966
|
+
# 🟥 **판정을 안 바꾼다**: `observe` 는 `main()` 의 반환값을 그대로 통과시키고, 원장 쓰기가
|
|
967
|
+
# 실패해도 예외를 여기로 올리지 않는다. 관측이 대상을 바꾸면 그 숫자는 의미가 없다.
|
|
968
|
+
# 모듈이 없어도(부분 설치) 종전과 같이 돈다 — 그게 이 try 의 유일한 목적이다.
|
|
969
|
+
try:
|
|
970
|
+
from usage_ledger import observe as _observe
|
|
971
|
+
except Exception:
|
|
972
|
+
_observe = None
|
|
973
|
+
sys.exit(_observe('preprep', 'cli', main) if _observe else main())
|