@chrono-meta/fh-gate 3.2.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/registry/agent_cards.json +1 -1
- package/.claude/rules/fh_4axis_gate.md +25 -0
- package/.claude-plugin/marketplace.json +9 -3
- package/AGENTS.md +2 -2
- package/CATALOG.md +4 -4
- package/CHEATSHEET.md +1 -1
- package/CLAUDE.md +4 -4
- package/README.md +2 -2
- package/docs/OUTPUT_EVIDENCE.md +1 -1
- package/docs/STANDARDS_ALIGNMENT.md +1 -1
- package/docs/codex-compat.md +2 -2
- package/knowledge/shared/harness-core/agents_md_runtime_details.md +2 -2
- package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +30 -3
- package/knowledge/shared/harness-core/iso_ai_standards_crosswalk.md +1 -1
- package/knowledge/shared/harness-core/skill_quality_rubric.md +1 -1
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +120 -4
- package/knowledge/shared/rules/modes_and_value.md +2 -2
- package/package.json +9 -1
- package/plugins/fh-commons/.claude-plugin/plugin.json +2 -2
- package/plugins/fh-commons/README.md +38 -0
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/CHANGELOG.md +117 -0
- package/plugins/fh-meta/skills/agent-composer/SKILL.md +2 -2
- package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +40 -0
- package/plugins/fh-meta/skills/frontier-digest/SKILL.md +1 -1
- package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +43 -5
- package/plugins/fh-meta/skills/{hub-cc-pr-reviewer → harness-pr-reviewer}/SKILL.md +75 -3
- package/plugins/fh-meta/skills/{hub-cc-pr-reviewer → harness-pr-reviewer}/SKILL_detail.md +2 -2
- package/plugins/fh-meta/skills/harvest-loop/SKILL_detail.md +2 -2
- package/plugins/fh-meta/skills/install-doctor/SKILL.md +1 -1
- package/plugins/fh-meta/skills/install-wizard/SKILL.md +1 -1
- package/plugins/fh-meta/skills/meta-prompt-builder/SKILL.md +1 -1
- package/plugins/fh-meta/skills/pipeline-conductor/SKILL.md +1 -1
- package/plugins/fh-meta/skills/plugin-recommender/SKILL.md +1 -1
- package/plugins/fh-meta/skills/sim-conductor/SKILL.md +1 -1
- 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/finding_fleet.sh +391 -13
- package/scripts/finding_pipeline.sh +370 -11
- package/scripts/finding_verifier.sh +31 -2
- package/scripts/finding_verify.py +198 -13
- package/scripts/frontier_digest_autopilot.sh +3 -3
- package/scripts/gate_shape_scan.sh +16 -2
- 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_finding_pipeline_lanes.sh +1556 -4
- package/scripts/test_gate_shape_scan_lanes.sh +11 -0
- package/scripts/test_marker_crossfamily_lanes.sh +75 -3
- package/scripts/test_marker_standpoint_lanes.sh +31 -6
- 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/templates/.git-hooks/pre-commit +152 -6
- package/templates/local_fh_context.md +1 -1
- package/templates/regression_guard.sh +1 -1
- 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,1417 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""R1~R5 · P1/P3 self-test — known-pair 계량 + 실물 재현.
|
|
3
|
+
|
|
4
|
+
exit 0 = 전부 통과 · 1 = 실패 있음 · 2 = 픽스처 생성 불가(python-pptx 부재 등, UNMEASURED)
|
|
5
|
+
|
|
6
|
+
구성:
|
|
7
|
+
① known-pair — `fixtures/mk_slide_fixtures.py` 로 R1·R2·R4·R5·P1·P3 를 테스트 시점에 만든다
|
|
8
|
+
(R3 는 예외 — 실물 구조 2장을 줄인 `fixtures/fixture_R3_{positive,negative}.pptx` 를 쓴다.
|
|
9
|
+
선두 줄 판별이 절대좌표·srgbClr 런 구조에 기대므로, 검출기 가정대로 «생성»한 픽스처는
|
|
10
|
+
검출기 자신을 검증 못 한다 — 자기참조. `tracks-meta` 원장이 지목한 방침)
|
|
11
|
+
② --baseline 델타 모드 — 편집 전/후 한 쌍에서 새 어긋남 1건만 잡히는지
|
|
12
|
+
③ 실물 재현 — 실제 백업 pptx(환경변수 PREPREP_REAL_CORPUS 가 가리키는 디렉터리, 있을 때만)에 돌려 원장이 인용한 실사고 4+1건이
|
|
13
|
+
그대로 뜨는지 확인한다. 그 코퍼스가 없는 머신에서는 SKIP(«통과»로 세지 않는다).
|
|
14
|
+
"""
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
19
|
+
sys.path.insert(0, HERE)
|
|
20
|
+
sys.path.insert(0, os.path.join(HERE, 'fixtures'))
|
|
21
|
+
|
|
22
|
+
PASS, FAIL, SKIP = 0, 0, 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def ok(msg):
|
|
26
|
+
global PASS
|
|
27
|
+
PASS += 1
|
|
28
|
+
print(' ✅', msg)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ng(msg):
|
|
32
|
+
global FAIL
|
|
33
|
+
FAIL += 1
|
|
34
|
+
print(' ❌', msg)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def sk(msg):
|
|
38
|
+
global SKIP
|
|
39
|
+
SKIP += 1
|
|
40
|
+
print(' ⏭ ', msg, '— SKIPPED (PASS 아님)')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def codes_of(deck_path, LSR):
|
|
44
|
+
deck = LSR.Deck(deck_path)
|
|
45
|
+
F = LSR.analyze(deck)
|
|
46
|
+
return F, {k for k, _, _ in F}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run_known_pairs(fx_dir):
|
|
50
|
+
print('\n[1] known-pair — R1·R2·R4·R5·P1·P3 (생성 픽스처) + R3 (실물 픽스처)')
|
|
51
|
+
import lane_slide_relations as LSR
|
|
52
|
+
import lane_geometry as LG
|
|
53
|
+
|
|
54
|
+
# R1
|
|
55
|
+
_, cp = codes_of(os.path.join(fx_dir, 'r1_pos.pptx'), LSR)
|
|
56
|
+
_, cn = codes_of(os.path.join(fx_dir, 'r1_neg.pptx'), LSR)
|
|
57
|
+
ok('R1 known-positive 검출') if 'R1' in cp else ng(f'R1 known-positive 미검출 ({cp})')
|
|
58
|
+
ok('R1 known-negative 무검출') if 'R1' not in cn else ng(f'R1 known-negative에서 오탐 ({cn})')
|
|
59
|
+
|
|
60
|
+
# R2
|
|
61
|
+
_, cp = codes_of(os.path.join(fx_dir, 'r2_pos.pptx'), LSR)
|
|
62
|
+
_, cn = codes_of(os.path.join(fx_dir, 'r2_neg.pptx'), LSR)
|
|
63
|
+
ok('R2 known-positive 검출') if 'R2' in cp else ng(f'R2 known-positive 미검출 ({cp})')
|
|
64
|
+
ok('R2 known-negative 무검출') if 'R2' not in cn else ng(f'R2 known-negative에서 오탐 ({cn})')
|
|
65
|
+
|
|
66
|
+
# R3 — 실물 픽스처 (자기참조 회피)
|
|
67
|
+
r3p = os.path.join(HERE, 'fixtures', 'fixture_R3_positive.pptx')
|
|
68
|
+
r3n = os.path.join(HERE, 'fixtures', 'fixture_R3_negative.pptx')
|
|
69
|
+
if os.path.exists(r3p) and os.path.exists(r3n):
|
|
70
|
+
_, cp = codes_of(r3p, LSR)
|
|
71
|
+
_, cn = codes_of(r3n, LSR)
|
|
72
|
+
ok('R3 known-positive(실물 2장) 검출') if 'R3' in cp else ng(f'R3 known-positive 미검출 ({cp})')
|
|
73
|
+
ok('R3 known-negative(실물 2장) 무검출') if 'R3' not in cn else ng(f'R3 known-negative에서 오탐 ({cn})')
|
|
74
|
+
else:
|
|
75
|
+
sk('R3 known-pair — fixture_R3_{positive,negative}.pptx 없음')
|
|
76
|
+
|
|
77
|
+
# R4
|
|
78
|
+
Fp, cp = codes_of(os.path.join(fx_dir, 'r4_pos.pptx'), LSR)
|
|
79
|
+
Fn, cn = codes_of(os.path.join(fx_dir, 'r4_neg.pptx'), LSR)
|
|
80
|
+
ok('R4 known-positive 검출(아웃라이어 500자)') if 'R4' in cp else ng(f'R4 known-positive 미검출 ({cp})')
|
|
81
|
+
ok('R4 known-negative 무검출(동값 분포)') if 'R4' not in cn else ng(f'R4 known-negative에서 오탐 ({cn})')
|
|
82
|
+
|
|
83
|
+
# R5
|
|
84
|
+
Fp, cp = codes_of(os.path.join(fx_dir, 'r5_pos.pptx'), LSR)
|
|
85
|
+
Fn, cn = codes_of(os.path.join(fx_dir, 'r5_neg.pptx'), LSR)
|
|
86
|
+
ok('R5 known-positive 검출(비율 30.0)') if 'R5' in cp else ng(f'R5 known-positive 미검출 ({cp})')
|
|
87
|
+
ok('R5 known-negative 무검출(동일 비율)') if 'R5' not in cn else ng(f'R5 known-negative에서 오탐 ({cn})')
|
|
88
|
+
|
|
89
|
+
# P1 — 3팔(임계 안 소폭 이동은 뜬다 · 임계 밖 큰 이동은 안 뜬다 · 무이동은 안 뜬다)
|
|
90
|
+
lines, stats, _sup, _err = LG.collect_lines(os.path.join(fx_dir, 'p1.pptx'), 200000, 63500)
|
|
91
|
+
p1_lines = [l for l in lines if 'gate_bar' in l]
|
|
92
|
+
bound = stats.get('bound', 0)
|
|
93
|
+
ok(f'P1 결박된 도형-쌍 {bound} > 0 (비교 안함 아님)') if bound > 0 else ng('P1 bound=0 — UNMEASURED')
|
|
94
|
+
ok(f'P1 후보 정확히 1건(임계 안 이동만)') if len(p1_lines) == 1 else ng(f'P1 후보 {len(p1_lines)}건 (want 1) — {p1_lines}')
|
|
95
|
+
if p1_lines:
|
|
96
|
+
ok('P1 유일 후보가 정확히 (1→2p) 소폭 이동 — 큰 이동(2→3p)·무이동(3→4p)은 안 뜬다') \
|
|
97
|
+
if '1p→2' in p1_lines[0].replace(' ', '') else ng(f'P1 후보가 엉뚱한 쌍을 가리킨다 {p1_lines}')
|
|
98
|
+
|
|
99
|
+
# P1-결박 — 🟥 넷이 **같은 이동량**이라 결박 규칙만 재는 계기다(임계·이동량은 변수가 아니다)
|
|
100
|
+
lb, sb, _s, _e = LG.collect_lines(os.path.join(fx_dir, 'p1_binding.pptx'), 200000, 63500)
|
|
101
|
+
hit = {n for n in ('bound_bar', 'remix_bar', 'silent_bar', 'halftext')
|
|
102
|
+
if any(n in l for l in lb)}
|
|
103
|
+
ok('P1 결박 known-negative — 글자가 «다른» 쌍은 후보에서 빠진다(대조 자체가 틀렸다)') \
|
|
104
|
+
if hit == {'bound_bar', 'silent_bar'} \
|
|
105
|
+
else ng(f'P1 후보가 {sorted(hit)} (want bound_bar+silent_bar) — 결박 규칙이 어긋났다')
|
|
106
|
+
ok('🟥 P1 known-positive — 글자 없는 도형(막대·선)이 «후보로» 남는다') \
|
|
107
|
+
if 'silent_bar' in hit else ng('glyph 없는 도형이 잘렸다 — P1 원적 사건이 이 부류다')
|
|
108
|
+
ok(' 그리고 그 줄은 [이름만 결박] 이름표를 달아 근거가 하나뿐임을 드러낸다') \
|
|
109
|
+
if any('silent_bar' in l and '이름만' in l for l in lb) \
|
|
110
|
+
else ng(f'name_only 후보에 이름표가 없다 — bound 와 구분 안 된다 ({lb})')
|
|
111
|
+
ok(f'P1 결박 내역이 셋으로 갈린다 (bound={sb.get("bound",0)} · name_only={sb.get("name_only",0)} · mismatch={sb.get("mismatch",0)})') \
|
|
112
|
+
if sb.get('bound', 0) >= 1 and sb.get('name_only', 0) >= 1 and sb.get('mismatch', 0) >= 2 \
|
|
113
|
+
else ng(f'결박 내역이 셋으로 안 갈린다 — {sb}')
|
|
114
|
+
|
|
115
|
+
# P1-선언면제 — 면제는 «도형»이 아니라 «그 변화»에 걸려야 한다
|
|
116
|
+
p1i = os.path.join(fx_dir, 'p1_intended.pptx')
|
|
117
|
+
decl = [{'slides': [1, 2], 'shapes': ['staged_bar', 'stray_bar'],
|
|
118
|
+
'attrs': ['x', 'cx'], 'why': '격리 막대 얇음→두꺼움 연출'}]
|
|
119
|
+
li, _st, sup, errs = LG.collect_lines(p1i, 200000, 63500, decl)
|
|
120
|
+
ok('면제 known-positive — 선언과 속성이 일치하는 staged_bar 는 후보에서 빠진다') \
|
|
121
|
+
if not any('staged_bar' in l for l in li) else ng(f'staged_bar 가 면제 안 됨 — {li}')
|
|
122
|
+
ok('면제된 것이 «사라지지» 않고 suppressed 로 사유와 함께 나온다') \
|
|
123
|
+
if any('staged_bar' in l and '격리 막대' in l for l in sup) \
|
|
124
|
+
else ng(f'면제가 조용히 사라졌다 — suppressed={sup}')
|
|
125
|
+
ok('🟥 면제 known-negative — 같은 이름을 선언했어도 «다른 속성»이 바뀐 stray_bar 는 그대로 뜬다') \
|
|
126
|
+
if any('stray_bar' in l for l in li) \
|
|
127
|
+
else ng('stray_bar 가 삼켜졌다 — 면제가 그 변화가 아니라 도형 이름에 걸려 있다')
|
|
128
|
+
ok('선언 오류 0건(정상 선언)') if not errs else ng(f'정상 선언에서 오류 {errs}')
|
|
129
|
+
_l2, _s2, sup2, errs2 = LG.collect_lines(p1i, 200000, 63500,
|
|
130
|
+
[dict(decl[0], why=' ')])
|
|
131
|
+
ok('🟥 why 가 빈 선언은 «면제»가 아니라 «오류»다 — 사유 없는 면제는 채널을 지운다') \
|
|
132
|
+
if errs2 and not sup2 else ng(f'why 공백인데 면제됨 (errs={errs2}, sup={sup2})')
|
|
133
|
+
|
|
134
|
+
# P1-뒤집힘 — 🟥 상자 기하가 한 EMU도 안 바뀐다. 변수는 flipH 하나뿐이다.
|
|
135
|
+
# 자리만 보는 계기는 여기서 «어긋남 0» 을 낸다(2026-09-10 실사고의 모양).
|
|
136
|
+
lf, sf, _s, _e = LG.collect_lines(os.path.join(fx_dir, 'p1_flip.pptx'), 200000, 63500)
|
|
137
|
+
ok('🟥 P1 뒤집힘 known-positive — 자리가 같아도 방향이 바뀌면 뜬다') \
|
|
138
|
+
if sf.get('flipped', 0) == 1 and any('turned' in l and '뒤집힘' in l for l in lf) \
|
|
139
|
+
else ng(f'뒤집힘 미검출 — 방향이 계기 밖이다 (stats={sf})')
|
|
140
|
+
ok('P1 뒤집힘 known-negative — 양쪽 무플립인 도형은 안 뜬다(과차단 컨트롤)') \
|
|
141
|
+
if not any('steady' in l for l in lf) else ng(f'steady 오탐 — {lf}')
|
|
142
|
+
|
|
143
|
+
# L14 screen-parity — 덱은 한 벌, 원고만 두 벌(한 변수)
|
|
144
|
+
import lane_screen_parity as SP
|
|
145
|
+
deck = SP.deck_screens(os.path.join(fx_dir, 'sp_deck.pptx'))
|
|
146
|
+
a_ok, w_ok, _ = SP.compare(SP.manuscript_screens(os.path.join(fx_dir, 'sp_ok.md')), deck)
|
|
147
|
+
a_bad, w_bad, _ = SP.compare(SP.manuscript_screens(os.path.join(fx_dir, 'sp_bad.md')), deck)
|
|
148
|
+
ok('L14 known-negative — 원고와 덱이 같으면 조용하다') \
|
|
149
|
+
if not a_ok and not w_ok else ng(f'같은 한 쌍에서 오탐 (짝없음={a_ok}, 문구={w_ok})')
|
|
150
|
+
sides = {x[2] for x in a_bad}
|
|
151
|
+
ok('L14 known-positive — 양쪽 방향의 «짝 없는 줄» 을 다 잡는다(원고에만·화면에만)') \
|
|
152
|
+
if sides == {'원고에만', '화면에만'} else ng(f'한 방향만 잡는다 — {sides}')
|
|
153
|
+
ok('🟥 L14 — «짝 없음» 과 «문구만 다름» 을 갈라서 낸다(합치면 계기가 거짓말한다)') \
|
|
154
|
+
if len(a_bad) == 2 and len(w_bad) == 1 else ng(f'분류 이상 (짝없음 {len(a_bad)}, 문구 {len(w_bad)})')
|
|
155
|
+
man_short = SP.manuscript_screens(os.path.join(fx_dir, 'sp_ok.md'))[:1]
|
|
156
|
+
cfg = {'surfaces_by_id': {'built_deck': {'path': os.path.join(fx_dir, 'sp_deck.pptx')},
|
|
157
|
+
'script': {'path': os.path.join(fx_dir, 'sp_ok.md'),
|
|
158
|
+
'canonical_for': ['화면 문자열']}}}
|
|
159
|
+
_f, n14 = SP.scan(cfg, '.')
|
|
160
|
+
ok('L14 scan() 래퍼가 표면 선언에서 원고를 찾아 돈다') \
|
|
161
|
+
if any('screen-parity' in x and '짝 없는 줄 0' in x for x in n14) else ng(f'scan() 이상 — {n14}')
|
|
162
|
+
|
|
163
|
+
# P3
|
|
164
|
+
lines3, _s3, _u3, _e3 = LG.collect_lines(os.path.join(fx_dir, 'p3.pptx'), 200000, 63500)
|
|
165
|
+
p3_lines = [l for l in lines3 if 'stub' in l]
|
|
166
|
+
ok('P3 후보 정확히 1건(임계 안 틀/겹침만)') if len(p3_lines) == 1 else ng(f'P3 후보 {len(p3_lines)}건 (want 1) — {p3_lines}')
|
|
167
|
+
|
|
168
|
+
# P4 attr-consistency — 축 넷 각각 known-pair.
|
|
169
|
+
# 🟥 두 팔의 도형 수·이름·자리가 같고 «갈리는 값 하나»만 다르다 — negative 가 «볼 게 없어서»
|
|
170
|
+
# 조용한 것이면 죽은 컨트롤이라 아무것도 못 잰다.
|
|
171
|
+
import lane_attr_consistency as AC
|
|
172
|
+
lp, _sp, ep, stp = AC.collect(os.path.join(fx_dir, 'p4_pos.pptx'), {})
|
|
173
|
+
ln_, _sn, en, stn = AC.collect(os.path.join(fx_dir, 'p4_neg.pptx'), {})
|
|
174
|
+
ok('P4 선언 오류 0건(양팔)') if not ep and not en else ng(f'P4 선언 오류 pos={ep} neg={en}')
|
|
175
|
+
ok(f'P4 컨트롤 살아 있음 — 양팔 도형 수 동일 ({stp.get("shapes")} = {stn.get("shapes")}) '
|
|
176
|
+
f'· 크기 읽힌 도형 {stp.get("sized")}개') \
|
|
177
|
+
if stp.get('shapes') == stn.get('shapes') and stp.get('sized', 0) >= 6 \
|
|
178
|
+
else ng(f'P4 컨트롤 비대칭/미측정 — pos={stp} neg={stn}')
|
|
179
|
+
for ax, label in (('dash', '같은 점선 굵기 갈림'), ('algn', '한 줄 정렬 갈림'),
|
|
180
|
+
('mirror', '좌우 대칭 크기 갈림'), ('echo', '같은 문장 크기 갈림')):
|
|
181
|
+
ok(f'P4 {ax} known-positive 검출 ({label})') if stp.get(ax, 0) >= 1 \
|
|
182
|
+
else ng(f'P4 {ax} known-positive 미검출 — {lp}')
|
|
183
|
+
ok(f'P4 {ax} known-negative 무검출') if stn.get(ax, 0) == 0 \
|
|
184
|
+
else ng(f'P4 {ax} known-negative 오탐 — {ln_}')
|
|
185
|
+
# 면제 채널 — P1 과 같은 규율(축+장에 걸린다 · 사유 필수 · 조용히 안 삼킨다)
|
|
186
|
+
lm, sm, em, _ = AC.collect(os.path.join(fx_dir, 'p4_pos.pptx'),
|
|
187
|
+
{'intended': [{'axis': 'mirror', 'slides': [1],
|
|
188
|
+
'why': '빌드 강조 교체 — 크기가 강조를 나른다'}]})
|
|
189
|
+
ok('P4 면제 known-positive — 선언한 축(mirror)만 빠지고 사유와 함께 나온다') \
|
|
190
|
+
if any('mirror' in l and '빌드 강조' in l for l in sm) \
|
|
191
|
+
and not any('[mirror]' in l for l in lm) else ng(f'P4 면제 이상 — sup={sm}')
|
|
192
|
+
ok('🟥 P4 면제 known-negative — 선언 안 한 축(dash·algn·echo)은 그대로 뜬다') \
|
|
193
|
+
if all(any(f'[{a}]' in l for l in lm) for a in ('dash', 'algn', 'echo')) \
|
|
194
|
+
else ng(f'다른 축까지 삼켜졌다 — {lm}')
|
|
195
|
+
_l, _s, ew, _st = AC.collect(os.path.join(fx_dir, 'p4_pos.pptx'),
|
|
196
|
+
{'intended': [{'axis': 'mirror', 'slides': [1], 'why': ''}]})
|
|
197
|
+
ok('🟥 P4 why 빈 선언은 면제가 아니라 오류다') if ew else ng('why 공백인데 통과')
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _shrink_deck(src, dst, drop_last, consistent):
|
|
201
|
+
"""장을 덜어낸 사본. `consistent=True` 면 sldIdLst 도 같이 줄인다(«의도한 삭제»),
|
|
202
|
+
False 면 파일만 줄인다(**2026-09-10 실사고 형태** — 목록은 120인데 파일이 102)."""
|
|
203
|
+
import zipfile, re
|
|
204
|
+
zi = zipfile.ZipFile(src)
|
|
205
|
+
names = [n for n in zi.namelist() if re.match(r'ppt/slides/slide\d+\.xml$', n)]
|
|
206
|
+
keep_n = len(names) - drop_last
|
|
207
|
+
drop = set(sorted(names, key=lambda n: int(re.search(r'(\d+)', n).group(1)))[keep_n:])
|
|
208
|
+
zo = zipfile.ZipFile(dst, 'w', zipfile.ZIP_DEFLATED)
|
|
209
|
+
for n in zi.namelist():
|
|
210
|
+
if n in drop:
|
|
211
|
+
continue
|
|
212
|
+
data = zi.read(n)
|
|
213
|
+
if consistent and n == 'ppt/presentation.xml':
|
|
214
|
+
x = data.decode('utf-8')
|
|
215
|
+
m = re.search(r'<p:sldIdLst>(.*?)</p:sldIdLst>', x, re.S)
|
|
216
|
+
ids = re.findall(r'<p:sldId\b[^>]*/>', m.group(1))
|
|
217
|
+
x = x.replace(m.group(1), ''.join(ids[:keep_n]), 1)
|
|
218
|
+
data = x.encode('utf-8')
|
|
219
|
+
# «의도한 삭제» 는 관계·Content_Types 도 같이 지운다 — 파워포인트가 그렇게 저장한다(2026-09-11: zip 수준 관계 검사가
|
|
220
|
+
# 끊긴 관계를 잡게 되자 이 픽스처가 «깨진 패키지» 로 읽혔다. 픽스처가 실물 형태가 아니었던 것)
|
|
221
|
+
if consistent and n == 'ppt/_rels/presentation.xml.rels':
|
|
222
|
+
x = data.decode('utf-8')
|
|
223
|
+
for dn in drop:
|
|
224
|
+
x = re.sub(r'<Relationship\b[^>]*Target="%s"[^>]*/>' % re.escape(dn.replace('ppt/', '')), '', x)
|
|
225
|
+
data = x.encode('utf-8')
|
|
226
|
+
if consistent and n == '[Content_Types].xml':
|
|
227
|
+
x = data.decode('utf-8')
|
|
228
|
+
for dn in drop:
|
|
229
|
+
x = re.sub(r'<Override\b[^>]*PartName="/%s"[^>]*/>' % re.escape(dn), '', x)
|
|
230
|
+
data = x.encode('utf-8')
|
|
231
|
+
zo.writestr(n, data)
|
|
232
|
+
zo.close()
|
|
233
|
+
return keep_n
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def run_safe_install(fx_dir):
|
|
237
|
+
"""산출물 무결성 게이트 — 🟥 2026-09-10 실사고(120장 정본이 102장으로 덮임)의 회귀 앵커."""
|
|
238
|
+
print('\n[2-b] safe_install — 산출물 무결성 게이트 (실사고 회귀 앵커)')
|
|
239
|
+
import subprocess, shutil, zipfile, re
|
|
240
|
+
here = os.path.join(HERE, 'safe_install.py')
|
|
241
|
+
if not os.path.exists(here):
|
|
242
|
+
return ng('safe_install.py 없음 — NOT_WIRED (0 아님)')
|
|
243
|
+
src = os.path.join(fx_dir, 'p1.pptx') # 4장짜리 정상 pptx
|
|
244
|
+
dest = os.path.join(fx_dir, 'si_dest.pptx')
|
|
245
|
+
shutil.copyfile(src, dest)
|
|
246
|
+
|
|
247
|
+
def run(cand, *extra):
|
|
248
|
+
r = subprocess.run([sys.executable, here, cand, dest, *extra],
|
|
249
|
+
capture_output=True, text=True)
|
|
250
|
+
return r.returncode, r.stdout + r.stderr
|
|
251
|
+
|
|
252
|
+
def slides(p):
|
|
253
|
+
return len([n for n in zipfile.ZipFile(p).namelist()
|
|
254
|
+
if re.match(r'ppt/slides/slide\d+\.xml$', n)])
|
|
255
|
+
|
|
256
|
+
# known-negative — 멀쩡한 후보는 통과해야 한다(컨트롤: 이게 막히면 계기가 과차단이다)
|
|
257
|
+
rc, out = run(src, '--dry-run')
|
|
258
|
+
ok('known-negative — 정상 후보는 통과한다') if rc == 0 else ng(f'정상 후보를 막았다 (rc={rc})\n{out}')
|
|
259
|
+
|
|
260
|
+
# known-positive ① — 실사고 형태: 파일만 잘리고 sldIdLst 는 그대로
|
|
261
|
+
trunc = os.path.join(fx_dir, 'si_truncated.pptx')
|
|
262
|
+
_shrink_deck(src, trunc, 2, consistent=False)
|
|
263
|
+
rc, out = run(trunc)
|
|
264
|
+
# 2026-09-11: rels 그래프 검증이 앞서 걸린다(목록이 가리키는 슬라이드가 zip 에 없다) — 어느 쪽이든 «구조 사유로 거부»
|
|
265
|
+
ok('🟥 실사고 재현 — 파일 수 ↔ sldIdLst 어긋남을 막는다') \
|
|
266
|
+
if rc == 1 and ('어긋난다' in out or 'zip 에 없다' in out) else ng(f'실사고를 안 막았다 (rc={rc})\n{out}')
|
|
267
|
+
|
|
268
|
+
# known-positive ② — 목록까지 일관되게 줄어든 «축소». ④ 축소 규칙만 재는 팔이다
|
|
269
|
+
small = os.path.join(fx_dir, 'si_small.pptx')
|
|
270
|
+
_shrink_deck(src, small, 2, consistent=True)
|
|
271
|
+
rc, out = run(small)
|
|
272
|
+
ok('축소는 기본 거부한다(비가역 표면은 fail-closed)') \
|
|
273
|
+
if rc == 1 and '줄었다' in out else ng(f'축소가 그냥 통과했다 (rc={rc})\n{out}')
|
|
274
|
+
rc, out = run(small, '--allow-shrink')
|
|
275
|
+
ok('🟥 --allow-shrink 만으로는 안 된다 — 사유가 없으면 거부') \
|
|
276
|
+
if rc == 1 else ng(f'사유 없는 축소가 통과했다 (rc={rc})\n{out}')
|
|
277
|
+
rc, out = run(small, '--allow-shrink', '--why', '38p 의도 삭제')
|
|
278
|
+
ok('사유를 적은 명시 축소는 통과한다') if rc == 0 else ng(f'명시 축소를 막았다 (rc={rc})\n{out}')
|
|
279
|
+
|
|
280
|
+
# known-positive ③ — 깨진 zip
|
|
281
|
+
corrupt = os.path.join(fx_dir, 'si_corrupt.pptx')
|
|
282
|
+
with open(src, 'rb') as f, open(corrupt, 'wb') as g:
|
|
283
|
+
g.write(f.read(2048))
|
|
284
|
+
shutil.copyfile(src, dest)
|
|
285
|
+
rc, out = run(corrupt)
|
|
286
|
+
ok('깨진 zip 을 막는다') if rc == 1 else ng(f'깨진 zip 이 통과했다 (rc={rc})\n{out}')
|
|
287
|
+
|
|
288
|
+
# 🟥 그리고 «막았다»가 «정본이 안 바뀌었다»와 같은 말인지 따로 확인한다
|
|
289
|
+
# (막았다고 말하면서 이미 덮어썼을 수 있다 — 판정과 부작용은 다른 것이다)
|
|
290
|
+
ok(f'거부된 동안 정본이 그대로 {slides(dest)}장') if slides(dest) == slides(src) \
|
|
291
|
+
else ng(f'거부했는데 정본이 {slides(dest)}장으로 바뀌었다 — 부작용이 새고 있다')
|
|
292
|
+
|
|
293
|
+
# ── 2026-09-11 codex 감사 S5 + B2 (설치 표면) — 각각 known-pair ───────────────────
|
|
294
|
+
def rewrite(src_p, dst_p, fn):
|
|
295
|
+
zi = zipfile.ZipFile(src_p); zo = zipfile.ZipFile(dst_p, 'w', zipfile.ZIP_DEFLATED)
|
|
296
|
+
for n in zi.namelist():
|
|
297
|
+
d = zi.read(n)
|
|
298
|
+
if n == 'ppt/presentation.xml':
|
|
299
|
+
d = fn(d.decode('utf-8')).encode('utf-8')
|
|
300
|
+
zo.writestr(n, d)
|
|
301
|
+
zo.close()
|
|
302
|
+
shutil.copyfile(src, dest)
|
|
303
|
+
# S1 목록이 비어 있어도 «검사 생략» 이 아니다
|
|
304
|
+
empty_lst = os.path.join(fx_dir, 'si_emptylist.pptx')
|
|
305
|
+
rewrite(src, empty_lst, lambda x: re.sub(r'(<p:sldIdLst\b[^>]*>).*?(</p:sldIdLst>)', r'\1\2', x, flags=re.S))
|
|
306
|
+
rc, out = run(empty_lst)
|
|
307
|
+
ok('S1 sldIdLst 가 비면 거부한다(0 은 «생략» 이 아니다)') if rc == 1 and '어긋난다' in out \
|
|
308
|
+
else ng(f'빈 목록이 통과했다 (rc={rc})\n{out}')
|
|
309
|
+
# S2 같은 장을 두 번 가리키는 목록
|
|
310
|
+
dup_lst = os.path.join(fx_dir, 'si_dup.pptx')
|
|
311
|
+
def _dup(x):
|
|
312
|
+
m = re.search(r'<p:sldIdLst\b[^>]*>(.*?)</p:sldIdLst>', x, re.S)
|
|
313
|
+
ids = re.findall(r'<p:sldId\b[^>]*/>', m.group(1))
|
|
314
|
+
return x.replace(m.group(1), ids[0] + ''.join(ids[1:-1]) + ids[0], 1) # 마지막을 첫 장 중복으로
|
|
315
|
+
rewrite(src, dup_lst, _dup)
|
|
316
|
+
rc, out = run(dup_lst)
|
|
317
|
+
ok('S2 같은 장을 두 번 가리키는 목록을 거부한다') if rc == 1 and ('중복' in out or '어긋난다' in out) \
|
|
318
|
+
else ng(f'중복 참조가 통과했다 (rc={rc})\n{out}')
|
|
319
|
+
# S3 기존본을 못 읽으면 반영하지 않는다
|
|
320
|
+
with open(dest, 'wb') as g:
|
|
321
|
+
g.write(b'not a zip')
|
|
322
|
+
rc, out = run(src)
|
|
323
|
+
still = open(dest, 'rb').read() == b'not a zip'
|
|
324
|
+
ok('S3 못 읽는 기존본 위에는 덮어쓰지 않는다(UNMEASURED ≠ 통과)') if rc == 1 and still \
|
|
325
|
+
else ng(f'못 읽는 기존본을 덮었다 (rc={rc}, unchanged={still})\n{out}')
|
|
326
|
+
shutil.copyfile(src, dest)
|
|
327
|
+
# S4 `--why --allow-shrink` — 플래그를 사유로 먹지 않는다
|
|
328
|
+
rc, out = run(small, '--why', '--allow-shrink')
|
|
329
|
+
ok('S4 --why 뒤에 플래그가 오면 사유가 아니다(거부)') if rc != 0 and slides(dest) == slides(src) \
|
|
330
|
+
else ng(f'플래그가 사유로 통했다 (rc={rc})\n{out}')
|
|
331
|
+
# B2 `--expect 2` 가 앞에 와도 후보로 안 읽힌다
|
|
332
|
+
r = subprocess.run([sys.executable, here, '--expect', '2', src, dest], capture_output=True, text=True)
|
|
333
|
+
ok('B2 --expect 값이 후보로 오파싱되지 않는다') if r.returncode == 1 and '--expect' in r.stdout \
|
|
334
|
+
else ng(f'옵션 파싱 오류 (rc={r.returncode})\n{r.stdout}{r.stderr}')
|
|
335
|
+
# S5/B1 반영은 «검사한 바이트» 를 옆에 쓴 뒤 교체 — 임시 파일이 남지 않고 정본 해시 = 후보 해시
|
|
336
|
+
rc, out = run(src)
|
|
337
|
+
import hashlib
|
|
338
|
+
same = hashlib.sha256(open(src, 'rb').read()).digest() == hashlib.sha256(open(dest, 'rb').read()).digest()
|
|
339
|
+
ok('S5 반영 후 정본 바이트 == 후보 바이트 · 임시 파일 없음') \
|
|
340
|
+
if rc == 0 and same and not os.path.exists(dest + '.safe_install.tmp') \
|
|
341
|
+
else ng(f'반영 경로 이상 (rc={rc} same={same})\n{out}')
|
|
342
|
+
|
|
343
|
+
# ── R2 (codex 09-11 13:04) 설치 표면 S4 ──
|
|
344
|
+
def rezip(src_p, dst_p, fn_map):
|
|
345
|
+
"""fn_map: {zip이름: (새이름 or None, bytes 변환 fn or None)}. 나머지는 그대로."""
|
|
346
|
+
zi = zipfile.ZipFile(src_p); zo = zipfile.ZipFile(dst_p, 'w', zipfile.ZIP_DEFLATED)
|
|
347
|
+
for n in zi.namelist():
|
|
348
|
+
d = zi.read(n)
|
|
349
|
+
if n in fn_map:
|
|
350
|
+
nn, fn = fn_map[n]
|
|
351
|
+
if fn: d = fn(d)
|
|
352
|
+
n = nn or n
|
|
353
|
+
zo.writestr(n, d)
|
|
354
|
+
zo.close()
|
|
355
|
+
# S1 기존본의 슬라이드 부품 이름이 관례 밖(page1.xml) → 0장으로 접혀 축소가 통과하던 구멍: 기존본 불일치는 거부
|
|
356
|
+
odd_dest = os.path.join(fx_dir, 'si_odd_dest.pptx')
|
|
357
|
+
names = [n for n in zipfile.ZipFile(src).namelist() if re.match(r'ppt/slides/slide\d+\.xml$', n)]
|
|
358
|
+
ren = {n: (n.replace('slide', 'page'), None) for n in names}
|
|
359
|
+
ren['ppt/_rels/presentation.xml.rels'] = (None, lambda d: d.replace(b'slides/slide', b'slides/page'))
|
|
360
|
+
ren['[Content_Types].xml'] = (None, lambda d: d.replace(b'slides/slide', b'slides/page'))
|
|
361
|
+
rezip(src, odd_dest, ren)
|
|
362
|
+
shutil.copyfile(odd_dest, dest)
|
|
363
|
+
rc, out = run(small)
|
|
364
|
+
still_odd = zipfile.ZipFile(dest).namelist() == zipfile.ZipFile(odd_dest).namelist()
|
|
365
|
+
ok('S1 기존본을 못 세면(부품 이름 관례 밖) 축소 후보를 거부하고 덮지 않는다') if rc == 1 and still_odd \
|
|
366
|
+
else ng(f'세지 못한 기존본 위에 축소가 통과했다 (rc={rc} unchanged={still_odd})\n{out}')
|
|
367
|
+
shutil.copyfile(src, dest)
|
|
368
|
+
# S3 rels 가 슬라이드 아닌 부품(theme)을 가리켜도 «zip 에 있다» 로 통과하던 구멍
|
|
369
|
+
theme_cand = os.path.join(fx_dir, 'si_theme_target.pptx')
|
|
370
|
+
def _retarget(d):
|
|
371
|
+
x = d.decode('utf-8'); first = re.search(r'Target="slides/slide\d+\.xml"', x).group(0)
|
|
372
|
+
return x.replace(first, 'Target="theme/theme1.xml"', 1).encode('utf-8')
|
|
373
|
+
rezip(src, theme_cand, {'ppt/_rels/presentation.xml.rels': (None, _retarget)})
|
|
374
|
+
rc, out = run(theme_cand)
|
|
375
|
+
ok('S3 sldId 가 슬라이드 아닌 부품을 가리키면 거부한다') if rc == 1 and '슬라이드 부품이 아니다' in out \
|
|
376
|
+
else ng(f'theme 을 가리키는 목록이 통과했다 (rc={rc})\n{out}')
|
|
377
|
+
# S4 r:id 없는 sldId 항목은 계수에서 사라지지 않는다
|
|
378
|
+
norid = os.path.join(fx_dir, 'si_norid.pptx')
|
|
379
|
+
rezip(src, norid, {'ppt/presentation.xml': (None, lambda d: d.replace(b'</p:sldIdLst>', b'<p:sldId id="999"/></p:sldIdLst>'))})
|
|
380
|
+
rc, out = run(norid)
|
|
381
|
+
ok('S4 r:id 없는 sldId 항목은 거부 사유가 된다') if rc == 1 and 'r:id 가 없다' in out \
|
|
382
|
+
else ng(f'r:id 없는 항목이 통과했다 (rc={rc})\n{out}')
|
|
383
|
+
# S2 정본 경로가 심링크면 거부 · 임시 파일은 mkstemp(고유) — 예측 가능한 .tmp 이름을 미리 심어도 정본은 무사
|
|
384
|
+
link_dest = os.path.join(fx_dir, 'si_link_dest.pptx')
|
|
385
|
+
if os.path.lexists(link_dest): os.remove(link_dest)
|
|
386
|
+
os.symlink(dest, link_dest)
|
|
387
|
+
r = subprocess.run([sys.executable, here, src, link_dest], capture_output=True, text=True)
|
|
388
|
+
planted = dest + '.safe_install.tmp'
|
|
389
|
+
if os.path.lexists(planted): os.remove(planted)
|
|
390
|
+
os.symlink(dest, planted)
|
|
391
|
+
before = open(dest, 'rb').read()
|
|
392
|
+
rc2, out2 = run(src)
|
|
393
|
+
after = open(dest, 'rb').read()
|
|
394
|
+
ok('S2 심링크 정본 거부(rc=1) · 심어둔 .tmp 심링크가 있어도 정본 바이트는 후보와 같다(고유 임시 파일)') \
|
|
395
|
+
if r.returncode == 1 and '심링크' in r.stdout and rc2 == 0 and after == open(src, 'rb').read() \
|
|
396
|
+
else ng(f'S2 link rc={r.returncode} · planted rc={rc2} same={after == open(src, "rb").read()}\n{r.stdout}{out2}')
|
|
397
|
+
os.remove(planted); os.remove(link_dest)
|
|
398
|
+
|
|
399
|
+
# ── R3 (codex 09-11 13:1x) 설치 표면 S5 — python-pptx 오라클 + 두 제공자 합의 ──
|
|
400
|
+
# S1 접두 q: + 부품 이름 관례 밖 기존본 — 구조 계수 0/0/0 인데 pptx 는 2장 → 불일치 → 거부(0장으로 접히지 않는다)
|
|
401
|
+
q_dest = os.path.join(fx_dir, 'si_qprefix_dest.pptx')
|
|
402
|
+
def _q(d):
|
|
403
|
+
return d.replace(b'<p:', b'<q:').replace(b'</p:', b'</q:').replace(b'xmlns:p=', b'xmlns:q=')
|
|
404
|
+
qm = dict(ren); qm['ppt/presentation.xml'] = (None, _q)
|
|
405
|
+
rezip(src, q_dest, qm)
|
|
406
|
+
shutil.copyfile(q_dest, dest)
|
|
407
|
+
rc, out = run(small)
|
|
408
|
+
ok('S1 접두 q:·부품 이름 관례 밖 기존본 → 두 제공자 불일치로 거부(축소 미통과)') if rc == 1 and '불일치' in out or (rc == 1 and '못 읽는다' in out) \
|
|
409
|
+
else ng(f'q: 기존본 위에 축소가 통과했다 (rc={rc})\n{out}')
|
|
410
|
+
shutil.copyfile(src, dest)
|
|
411
|
+
# S2 깨진 슬라이드 XML (zip CRC 는 정상) → pptx 파싱 실패 → 거부
|
|
412
|
+
broken = os.path.join(fx_dir, 'si_broken_slide.pptx')
|
|
413
|
+
rezip(src, broken, {names[0]: (None, lambda d: b'<broken')})
|
|
414
|
+
rc, out = run(broken)
|
|
415
|
+
ok('S2 깨진 슬라이드 XML(zip 정상) 은 거부된다(pptx 오라클)') if rc == 1 and '못 읽는다' in out else ng(f'깨진 슬라이드가 통과했다 (rc={rc})\n{out}')
|
|
416
|
+
# S3 TargetMode=External 관계 → 거부
|
|
417
|
+
ext = os.path.join(fx_dir, 'si_external_rel.pptx')
|
|
418
|
+
def _ext(d):
|
|
419
|
+
x = d.decode('utf-8'); m = re.search(r'<Relationship\b[^>]*Target="slides/slide\d+\.xml"[^>]*/>', x).group(0)
|
|
420
|
+
return x.replace(m, m[:-2] + ' TargetMode="External"/>', 1).encode('utf-8')
|
|
421
|
+
rezip(src, ext, {'ppt/_rels/presentation.xml.rels': (None, _ext)})
|
|
422
|
+
rc, out = run(ext)
|
|
423
|
+
ok('S3 External 관계는 슬라이드가 아니다 — 거부') if rc == 1 else ng(f'External 관계가 통과했다 (rc={rc})\n{out}')
|
|
424
|
+
# S5 끊긴 심링크 정본 → 거부(첫 설치로 흐르지 않는다)
|
|
425
|
+
dang = os.path.join(fx_dir, 'si_dangling.pptx')
|
|
426
|
+
if os.path.lexists(dang): os.remove(dang)
|
|
427
|
+
os.symlink(os.path.join(fx_dir, 'no_such_target.pptx'), dang)
|
|
428
|
+
r = subprocess.run([sys.executable, here, src, dang], capture_output=True, text=True)
|
|
429
|
+
ok('S5 끊긴 심링크 정본은 거부(rc=1) · 링크 그대로') if r.returncode == 1 and os.path.islink(dang) and not os.path.exists(dang) \
|
|
430
|
+
else ng(f'끊긴 심링크에 설치됐다 (rc={r.returncode} islink={os.path.islink(dang)} exists={os.path.exists(dang)})')
|
|
431
|
+
os.remove(dang)
|
|
432
|
+
# S4 검사 뒤 정본이 바뀌면 중단 — 정본 교체를 «후보 파일 자체를 정본으로 두는» 순간에 잡기 어렵다 → 계기 함수로 잰다:
|
|
433
|
+
# d_hash 를 검사 후 바꾸는 대신, 정본에 «검사 전» 1장·«설치 직전» 다른 바이트를 쓰는 레이스를 monkeypatch 로 재현
|
|
434
|
+
import importlib.util
|
|
435
|
+
spec = importlib.util.spec_from_file_location('safe_install_mod', here); SI = importlib.util.module_from_spec(spec); spec.loader.exec_module(SI)
|
|
436
|
+
race_dest = os.path.join(fx_dir, 'si_race_dest.pptx'); shutil.copyfile(small, race_dest)
|
|
437
|
+
_orig_mkstemp = SI.tempfile.mkstemp
|
|
438
|
+
def _racy_mkstemp(*a, **k):
|
|
439
|
+
shutil.copyfile(src, race_dest) # 다른 손이 4장짜리로 덮는다 — 이제 1장 후보는 «축소» 다
|
|
440
|
+
return _orig_mkstemp(*a, **k)
|
|
441
|
+
SI.tempfile.mkstemp = _racy_mkstemp
|
|
442
|
+
import io as _io, contextlib
|
|
443
|
+
buf = _io.StringIO()
|
|
444
|
+
with contextlib.redirect_stdout(buf):
|
|
445
|
+
rc4 = SI.main(['safe_install.py', small, race_dest])
|
|
446
|
+
SI.tempfile.mkstemp = _orig_mkstemp
|
|
447
|
+
ok('S4 검사 뒤 정본이 바뀌면 중단(rc=1) · 4장 정본 보존') if rc4 == 1 and slides(race_dest) == slides(src) and '바뀌었다' in buf.getvalue() \
|
|
448
|
+
else ng(f'S4 rc={rc4} dest={slides(race_dest)}장\n{buf.getvalue()}')
|
|
449
|
+
|
|
450
|
+
# ── R4 (codex 09-11 18:02) 설치 표면 ──
|
|
451
|
+
# S1 nvSpPr 없는 도형(파서가 도형 «수» 만 세면 통과) → 도형 하나하나를 읽어 거부
|
|
452
|
+
noname = os.path.join(fx_dir, 'si_noname_shape.pptx')
|
|
453
|
+
def _strip_nv(d):
|
|
454
|
+
return re.sub(rb'<p:nvSpPr>.*?</p:nvSpPr>', b'', d, count=1, flags=re.S)
|
|
455
|
+
rezip(src, noname, {names[0]: (None, _strip_nv)})
|
|
456
|
+
rc, out = run(noname)
|
|
457
|
+
ok('S1 nvSpPr 없는 도형은 오라클이 거부한다') if rc == 1 and '못 읽는다' in out else ng(f'깨진 도형이 통과했다 (rc={rc})\n{out}')
|
|
458
|
+
# S2 같은 게이트 둘이 동시에 돌면 직렬화된다 — 잠금 안에서 두 번째가 «바뀐 정본» 을 본다
|
|
459
|
+
import threading, fcntl
|
|
460
|
+
lock_dest = os.path.join(fx_dir, 'si_lock_dest.pptx'); shutil.copyfile(small, lock_dest)
|
|
461
|
+
lock_path = lock_dest + '.safe_install.lock'
|
|
462
|
+
lf = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600); fcntl.flock(lf, fcntl.LOCK_EX)
|
|
463
|
+
res = {}
|
|
464
|
+
def _worker():
|
|
465
|
+
r = subprocess.run([sys.executable, here, small, lock_dest], capture_output=True, text=True); res['rc'] = r.returncode; res['out'] = r.stdout
|
|
466
|
+
th = threading.Thread(target=_worker); th.start(); th.join(1.5)
|
|
467
|
+
blocked_while_held = th.is_alive()
|
|
468
|
+
shutil.copyfile(src, lock_dest) # 잠금을 쥔 «다른 설치» 가 4장으로 바꿨다
|
|
469
|
+
fcntl.flock(lf, fcntl.LOCK_UN); os.close(lf); th.join(30)
|
|
470
|
+
ok('S2 잠금 대기 중이던 설치가 «바뀐 정본(4장)» 을 보고 축소로 거부한다 (대기=%s)' % blocked_while_held) \
|
|
471
|
+
if blocked_while_held and res.get('rc') == 1 and slides(lock_dest) == slides(src) and '줄었다' in res.get('out', '') \
|
|
472
|
+
else ng(f'S2 blocked={blocked_while_held} rc={res.get("rc")} dest={slides(lock_dest)}장\n{res.get("out","")[:300]}')
|
|
473
|
+
|
|
474
|
+
# ── R5 (codex 09-11 18:13) 설치 표면 ──
|
|
475
|
+
# S1 그룹 안의 깨진 도형(nvSpPr 없음) — 실물 pptx 를 python-pptx 로 만들어(그룹+텍스트박스) XML 을 깬다
|
|
476
|
+
from pptx import Presentation as _Pres
|
|
477
|
+
from pptx.util import Inches as _In
|
|
478
|
+
gp = os.path.join(fx_dir, 'si_group_src.pptx'); prs = _Pres(); sl = prs.slides.add_slide(prs.slide_layouts[6])
|
|
479
|
+
grp = sl.shapes.add_group_shape(); tb = grp.shapes.add_textbox(_In(1), _In(1), _In(2), _In(1)); tb.text_frame.text = 'child'
|
|
480
|
+
prs.save(gp)
|
|
481
|
+
g_dest = os.path.join(fx_dir, 'si_group_dest.pptx'); shutil.copyfile(gp, g_dest)
|
|
482
|
+
gbad = os.path.join(fx_dir, 'si_group_bad.pptx')
|
|
483
|
+
gnames = [n for n in zipfile.ZipFile(gp).namelist() if re.match(r'ppt/slides/slide\d+\.xml$', n)]
|
|
484
|
+
rezip(gp, gbad, {gnames[0]: (None, lambda d: re.sub(rb'<p:sp>\s*<p:nvSpPr>.*?</p:nvSpPr>', b'<p:sp>', d, count=1, flags=re.S))})
|
|
485
|
+
r = subprocess.run([sys.executable, here, gbad, g_dest], capture_output=True, text=True)
|
|
486
|
+
r_ok = subprocess.run([sys.executable, here, gp, g_dest, '--dry-run'], capture_output=True, text=True)
|
|
487
|
+
ok('S1 그룹 자식의 nvSpPr 결손 → 거부 · 정상 그룹 덱 컨트롤 통과') if r.returncode == 1 and '못 읽는다' in r.stdout and r_ok.returncode == 0 \
|
|
488
|
+
else ng(f'S1 bad rc={r.returncode} ctrl rc={r_ok.returncode}\n{r.stdout[:200]}{r_ok.stdout[:200]}')
|
|
489
|
+
# S2 그림 부품이 zip 에서 빠진 덱 → 거부
|
|
490
|
+
pp = os.path.join(fx_dir, 'si_pic_src.pptx'); prs = _Pres(); sl = prs.slides.add_slide(prs.slide_layouts[6])
|
|
491
|
+
png = os.path.join(fx_dir, 'dot.png')
|
|
492
|
+
from PIL import Image as _Img
|
|
493
|
+
_Img.new('RGB', (2, 2), (255, 0, 0)).save(png) # 손으로 친 hex 는 PIL verify 를 못 넘겼다 — 실물 PNG 로
|
|
494
|
+
sl.shapes.add_picture(png, _In(1), _In(1)); prs.save(pp)
|
|
495
|
+
p_dest = os.path.join(fx_dir, 'si_pic_dest.pptx'); shutil.copyfile(pp, p_dest)
|
|
496
|
+
pbad = os.path.join(fx_dir, 'si_pic_bad.pptx')
|
|
497
|
+
zi = zipfile.ZipFile(pp); zo = zipfile.ZipFile(pbad, 'w', zipfile.ZIP_DEFLATED)
|
|
498
|
+
for n in zi.namelist():
|
|
499
|
+
if not n.startswith('ppt/media/'): zo.writestr(n, zi.read(n))
|
|
500
|
+
zo.close()
|
|
501
|
+
r = subprocess.run([sys.executable, here, pbad, p_dest], capture_output=True, text=True)
|
|
502
|
+
r_ok = subprocess.run([sys.executable, here, pp, p_dest, '--dry-run'], capture_output=True, text=True)
|
|
503
|
+
ok('S2 그림 부품 결손 → 거부 · 정상 그림 덱 컨트롤 통과') if r.returncode == 1 and '못 읽는다' in r.stdout and r_ok.returncode == 0 \
|
|
504
|
+
else ng(f'S2 bad rc={r.returncode} ctrl rc={r_ok.returncode}\n{r.stdout[:200]}{r_ok.stdout[:200]}')
|
|
505
|
+
|
|
506
|
+
# ── R6 (codex 09-11 23:07) 설치 표면 ──
|
|
507
|
+
# S2 이미지 바이트가 깨졌는데 zip CRC 는 정상 → PIL 디코드에서 거부
|
|
508
|
+
pbad2 = os.path.join(fx_dir, 'si_pic_badbytes.pptx')
|
|
509
|
+
zi = zipfile.ZipFile(pp); zo = zipfile.ZipFile(pbad2, 'w', zipfile.ZIP_DEFLATED)
|
|
510
|
+
for n in zi.namelist():
|
|
511
|
+
zo.writestr(n, b'not an image' if n.startswith('ppt/media/') else zi.read(n))
|
|
512
|
+
zo.close()
|
|
513
|
+
r = subprocess.run([sys.executable, here, pbad2, p_dest], capture_output=True, text=True)
|
|
514
|
+
ok('S2b 깨진 이미지 바이트(CRC 정상) → 거부') if r.returncode == 1 and '못 읽는다' in r.stdout else ng(f'S2b rc={r.returncode}\n{r.stdout[:200]}')
|
|
515
|
+
# S1 차트 부품 결손 → 관계 걷기에서 거부 (python-pptx 로 차트 덱을 만든다)
|
|
516
|
+
from pptx.chart.data import CategoryChartData as _CD
|
|
517
|
+
from pptx.enum.chart import XL_CHART_TYPE as _XL
|
|
518
|
+
cp = os.path.join(fx_dir, 'si_chart_src.pptx'); prs = _Pres(); sl = prs.slides.add_slide(prs.slide_layouts[6])
|
|
519
|
+
cd = _CD(); cd.categories = ['a', 'b']; cd.add_series('s', (1, 2)); sl.shapes.add_chart(_XL.COLUMN_CLUSTERED, _In(1), _In(1), _In(4), _In(3), cd); prs.save(cp)
|
|
520
|
+
c_dest = os.path.join(fx_dir, 'si_chart_dest.pptx'); shutil.copyfile(cp, c_dest)
|
|
521
|
+
cbad = os.path.join(fx_dir, 'si_chart_bad.pptx')
|
|
522
|
+
zi = zipfile.ZipFile(cp); zo = zipfile.ZipFile(cbad, 'w', zipfile.ZIP_DEFLATED)
|
|
523
|
+
for n in zi.namelist():
|
|
524
|
+
if not n.startswith('ppt/charts/'): zo.writestr(n, zi.read(n))
|
|
525
|
+
zo.close()
|
|
526
|
+
r = subprocess.run([sys.executable, here, cbad, c_dest], capture_output=True, text=True)
|
|
527
|
+
r_ok = subprocess.run([sys.executable, here, cp, c_dest, '--dry-run'], capture_output=True, text=True)
|
|
528
|
+
ok('S1b 차트 부품 결손 → 거부 · 정상 차트 덱 컨트롤 통과') if r.returncode == 1 and '못 읽는다' in r.stdout and r_ok.returncode == 0 \
|
|
529
|
+
else ng(f'S1b bad rc={r.returncode} ctrl rc={r_ok.returncode}\n{r.stdout[:200]}{r_ok.stdout[:200]}')
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _mini_deck(path, slides_xml, sld_attr_order='id-first', rel_attr_order='id-first'):
|
|
533
|
+
"""최소 pptx. slides_xml = [슬라이드 본문 XML …] (발표 순서). 속성 순서를 골라 «순서에 기댄 정규식»을 잡는다."""
|
|
534
|
+
import zipfile
|
|
535
|
+
z = zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED)
|
|
536
|
+
z.writestr('[Content_Types].xml', '<Types/>')
|
|
537
|
+
ids, rels = [], []
|
|
538
|
+
for i, body in enumerate(slides_xml, 1):
|
|
539
|
+
fn = 'slides/slide%d.xml' % (len(slides_xml) + 1 - i) # 파일 번호를 «거꾸로» — 위치 ≠ 파일번호
|
|
540
|
+
rid = 'rId%d' % i
|
|
541
|
+
ids.append('<p:sldId id="%d" r:id="%s"/>' % (255 + i, rid) if sld_attr_order == 'id-first'
|
|
542
|
+
else '<p:sldId r:id="%s" id="%d"/>' % (rid, 255 + i))
|
|
543
|
+
rels.append('<Relationship Id="%s" Target="%s"/>' % (rid, fn) if rel_attr_order == 'id-first'
|
|
544
|
+
else '<Relationship Target="%s" Id="%s"/>' % (fn, rid))
|
|
545
|
+
z.writestr('ppt/' + fn, '<p:sld xmlns:p="p" xmlns:a="a"><p:cSld><p:spTree>%s</p:spTree></p:cSld></p:sld>' % body)
|
|
546
|
+
z.writestr('ppt/presentation.xml', '<p:presentation><p:sldSz cx="12192000" cy="6858000"/><p:sldIdLst>%s</p:sldIdLst></p:presentation>' % ''.join(ids))
|
|
547
|
+
z.writestr('ppt/_rels/presentation.xml.rels', '<Relationships>%s</Relationships>' % ''.join(rels))
|
|
548
|
+
z.close()
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _sp(name, x, y, cx, cy, text='', sz=None, extra='', algn=None, ln=''):
|
|
552
|
+
rpr = ('<a:rPr sz="%d"/>' % sz) if sz else '<a:rPr/>'
|
|
553
|
+
ppr = ('<a:pPr algn="%s"/>' % algn) if algn else ''
|
|
554
|
+
return ('<p:sp><p:nvSpPr><p:cNvPr id="1" name="%s"/></p:nvSpPr><p:spPr><a:xfrm%s><a:off x="%d" y="%d"/>'
|
|
555
|
+
'<a:ext cx="%d" cy="%d"/></a:xfrm>%s</p:spPr><p:txBody><a:p>%s<a:r>%s<a:t>%s</a:t></a:r></a:p></p:txBody></p:sp>'
|
|
556
|
+
% (name, extra, x, y, cx, cy, ln, ppr, rpr, text))
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def run_codex_audit_regressions(fx_dir):
|
|
560
|
+
print('\n[2-c] 2026-09-11 codex 감사 A 회귀 — 레인 셋(geometry · attr · screen-parity)')
|
|
561
|
+
import lane_geometry as LG, lane_attr_consistency as LA, lane_screen_parity as LS
|
|
562
|
+
# A1 속성 순서 — sldId r:id 가 앞에 와도 두 장 다 읽는다 (세 레인 공통 _order)
|
|
563
|
+
for order in ('id-first', 'rid-first'):
|
|
564
|
+
d = os.path.join(fx_dir, 'ma_%s.pptx' % order)
|
|
565
|
+
_mini_deck(d, [_sp('A', 0, 0, 100, 100, 'one'), _sp('B', 0, 0, 100, 100, 'two')], order, 'target-first' if order == 'rid-first' else 'id-first')
|
|
566
|
+
n_g = len(LG.load_slides(d)); n_a = len(LA.load(d)[0]); n_s = len(LS.deck_screens(d))
|
|
567
|
+
ok('A1 %s: geometry %d · attr %d · parity %d 장 (2 기대)' % (order, n_g, n_a, n_s)) if (n_g, n_a, n_s) == (2, 2, 2) \
|
|
568
|
+
else ng('A1 %s: 속성 순서에 따라 장이 사라진다 — geometry %d attr %d parity %d' % (order, n_g, n_a, n_s))
|
|
569
|
+
# A2 flipH="true" 도 방향으로 읽는다
|
|
570
|
+
d = os.path.join(fx_dir, 'ma_flip.pptx')
|
|
571
|
+
_mini_deck(d, [_sp('arrow', 0, 0, 100, 100, extra=' flipH="true"'), _sp('arrow', 0, 0, 100, 100, extra=' flipH="1"')])
|
|
572
|
+
fl = [s['arrow'][5] for s in LG.load_slides(d)]
|
|
573
|
+
ok('A2 flipH="true" 와 "1" 이 같은 방향 값(H)') if fl == ['H', 'H'] else ng('A2 flip 추출 %r (want [H, H])' % fl)
|
|
574
|
+
# A3 geometry.intended 끝점 검증 — [1, 99] · [1, "x"] 는 오류, [1, 2] 만 면제
|
|
575
|
+
idx, errs = LG._intent_index([{'slides': [1, 99], 'shapes': ['s'], 'attrs': ['x'], 'why': 'w'},
|
|
576
|
+
{'slides': [1, 'x'], 'shapes': ['s'], 'attrs': ['x'], 'why': 'w'},
|
|
577
|
+
{'slides': [1, 2], 'shapes': ['s'], 'attrs': ['x'], 'why': 'w'}])
|
|
578
|
+
ok('A3 intended 끝점: 비연속·비정수 2건 오류 · [1,2] 1건 등재') if len(errs) == 2 and len(idx) == 1 \
|
|
579
|
+
else ng('A3 intended 끝점 검증 — errs=%d idx=%d' % (len(errs), len(idx)))
|
|
580
|
+
# A4 mirror: 2800 vs 2850 은 다르다 · endParaRPr 는 안 센다
|
|
581
|
+
d = os.path.join(fx_dir, 'ma_mirror.pptx')
|
|
582
|
+
body = (_sp('L', 914400, 914400, 3657600, 914400, 'left', 2800) +
|
|
583
|
+
_sp('R', 7620000, 914400, 3657600, 914400, 'right', 2850).replace('</a:p>', '<a:endParaRPr sz="2800"/></a:p>'))
|
|
584
|
+
_mini_deck(d, [body])
|
|
585
|
+
S, sw = LA.load(d)
|
|
586
|
+
ok('A4 크기 소수 보존 + endParaRPr 제외: %s vs %s' % (S[0][0]['szs'], S[0][1]['szs'])) \
|
|
587
|
+
if S[0][0]['szs'] == (28.0,) and S[0][1]['szs'] == (28.5,) else ng('A4 szs = %r / %r' % (S[0][0]['szs'], S[0][1]['szs']))
|
|
588
|
+
# A5 algn: 문단 정렬만 · 크기 상속이어도 정렬은 대조된다 · ln algn 은 무시
|
|
589
|
+
d = os.path.join(fx_dir, 'ma_algn.pptx')
|
|
590
|
+
body = (_sp('a', 0, 914400, 3657600, 914400, 'x', algn='l', ln='<a:ln algn="ctr"><a:solidFill/></a:ln>') +
|
|
591
|
+
_sp('b', 5000000, 914400, 3657600, 914400, 'y', algn='r', ln='<a:ln algn="ctr"><a:solidFill/></a:ln>'))
|
|
592
|
+
_mini_deck(d, [body])
|
|
593
|
+
S, sw = LA.load(d)
|
|
594
|
+
hits = list(LA.ax_algn(S, sw, {}))
|
|
595
|
+
ok('A5 pPr 정렬 l/r 이 갈림으로 잡힌다(크기 상속·ln algn 무시)') if len(hits) == 1 and S[0][0]['algn'] == ('l',) \
|
|
596
|
+
else ng('A5 algn 축 — hits=%d algn=%r' % (len(hits), S[0][0]['algn']))
|
|
597
|
+
# A6 장 번호 = 발표 위치 (파일 번호가 거꾸로인 덱)
|
|
598
|
+
d = os.path.join(fx_dir, 'ma_pos.pptx')
|
|
599
|
+
_mini_deck(d, [_sp('t', 0, 0, 100, 100, 'same sentence here', 2800), _sp('t', 0, 0, 100, 100, 'same sentence here', 3200)])
|
|
600
|
+
S, sw = LA.load(d)
|
|
601
|
+
ok('A6 a[slide] 가 발표 위치 [1, 2] (파일 번호 [2, 1] 아님)') if [s[0]['slide'] for s in S] == [1, 2] \
|
|
602
|
+
else ng('A6 slide 번호 %r' % [s[0]['slide'] for s in S])
|
|
603
|
+
# A7 🖥️(FE0F) 변이 · 다중성 · 엔티티 · 문단 경계
|
|
604
|
+
mp = os.path.join(fx_dir, 'ma_man.md')
|
|
605
|
+
with open(mp, 'w', encoding='utf-8') as f:
|
|
606
|
+
f.write('### U1 · x\n🖥️\nR&D\nTitle\nTitle\n🗣\n말\n')
|
|
607
|
+
man = LS.manuscript_screens(mp)
|
|
608
|
+
ok('A7-1 🖥️ 변이 선택자 뒤의 줄을 읽는다(3줄)') if man and len(man[0][1]) == 3 else ng('A7-1 원고 파싱 %r' % man)
|
|
609
|
+
d = os.path.join(fx_dir, 'ma_par.pptx')
|
|
610
|
+
body = ('<p:sp><p:nvSpPr><p:cNvPr id="1" name="g"/></p:nvSpPr><p:txBody><a:p><a:r><a:rPr/><a:t>R&D</a:t></a:r></a:p>'
|
|
611
|
+
'<a:p><a:r><a:rPr/><a:t>Title</a:t></a:r></a:p></p:txBody></p:sp>')
|
|
612
|
+
_mini_deck(d, [body])
|
|
613
|
+
deck = LS.deck_screens(d)
|
|
614
|
+
ok('A7-2 문단 단위 추출 + 엔티티 해제: %r' % deck[0]) if deck[0] == ['R&D', 'Title'] else ng('A7-2 deck lines %r' % deck[0])
|
|
615
|
+
absent, wording, st = LS.compare(man, deck)
|
|
616
|
+
ok('A7-3 다중성 보존 — 원고 Title×2 vs 덱 Title×1 → 짝 없는 줄 1') if len(absent) == 1 and len(wording) == 0 \
|
|
617
|
+
else ng('A7-3 absent=%r wording=%r' % (absent, wording))
|
|
618
|
+
|
|
619
|
+
# A8 알갱이 불일치(덱 세션 실측 47→279) — 원고는 도형 단위, 덱은 문단 단위. 2단계 대조가 «같은 도형의 문단 join» 을 짝으로 본다
|
|
620
|
+
mp2 = os.path.join(fx_dir, 'ma_man2.md')
|
|
621
|
+
with open(mp2, 'w', encoding='utf-8') as f:
|
|
622
|
+
f.write('### U1 · x\n🖥\n1.무엇이 문제였나 2.무엇을 만들었나 3.무엇을 가져가나\n🗣\n말\n')
|
|
623
|
+
d2 = os.path.join(fx_dir, 'ma_multi.pptx')
|
|
624
|
+
body3 = ('<p:sp><p:nvSpPr><p:cNvPr id="1" name="g"/></p:nvSpPr><p:txBody>'
|
|
625
|
+
'<a:p><a:r><a:rPr/><a:t>1.무엇이 문제였나</a:t></a:r></a:p>'
|
|
626
|
+
'<a:p><a:r><a:rPr/><a:t>2.무엇을 만들었나</a:t></a:r></a:p>'
|
|
627
|
+
'<a:p><a:r><a:rPr/><a:t>3.무엇을 가져가나</a:t></a:r></a:p></p:txBody></p:sp>')
|
|
628
|
+
_mini_deck(d2, [body3])
|
|
629
|
+
man2 = LS.manuscript_screens(mp2); grp = LS.deck_screens_grouped(d2); deck2 = [[t for sh_ in sl for t in sh_] for sl in grp]
|
|
630
|
+
a1, w1, st1 = LS.compare(man2, deck2) # 1단계만 — 오탐 형태 재현(1 원고-only + 3 화면-only)
|
|
631
|
+
a2, w2, st2 = LS.compare(man2, deck2, deck_groups=grp) # 2단계
|
|
632
|
+
ok('A8 다문단 도형: 1단계만이면 짝없음 %d(오탐, stage=1 표기) · 2단계면 0 (join 1건, stage=2)' % len(a1)) \
|
|
633
|
+
if len(a1) == 4 and len(a2) == 0 and st2.get('joined') == 1 and st1.get('stage') == 1 and st2.get('stage') == 2 \
|
|
634
|
+
else ng('A8 a1=%d a2=%d joined=%r stage=%r/%r' % (len(a1), len(a2), st2.get('joined'), st1.get('stage'), st2.get('stage')))
|
|
635
|
+
# A8 컨트롤 — 덱에서 문단 하나가 정말 빠지면 2단계로도 안 맞아야 한다(join 이 원고 줄과 달라진다)
|
|
636
|
+
d3 = os.path.join(fx_dir, 'ma_multi_missing.pptx')
|
|
637
|
+
_mini_deck(d3, [body3.replace('<a:p><a:r><a:rPr/><a:t>2.무엇을 만들었나</a:t></a:r></a:p>', '')])
|
|
638
|
+
grp3 = LS.deck_screens_grouped(d3); deck3 = [[t for sh_ in sl for t in sh_] for sl in grp3]
|
|
639
|
+
a3, w3, st3 = LS.compare(man2, deck3, threshold=0.90, deck_groups=grp3)
|
|
640
|
+
ok('A8 컨트롤 — 문단 소실 덱은 2단계로도 짝없음/문구차이로 남는다') if (len(a3) + len(w3)) >= 1 \
|
|
641
|
+
else ng('A8 컨트롤 — 문단이 빠졌는데 0 (a3=%r w3=%r)' % (a3, w3))
|
|
642
|
+
# A9 죽은 선언 — 아무것도 안 거는 intended 항목이 🟥 로 보고된다 (geometry · attr 둘 다)
|
|
643
|
+
dg = os.path.join(fx_dir, 'ma_dead_geo.pptx')
|
|
644
|
+
_mini_deck(dg, [_sp('bar', 914400, 914400, 100000, 100000, 'x'), _sp('bar', 914400 + 20000, 914400, 100000, 100000, 'x')])
|
|
645
|
+
S_g = LG.load_slides(dg)
|
|
646
|
+
_l, _st, sup, errs = LG.p1_lines(S_g, 200000, [
|
|
647
|
+
{'slides': [1, 2], 'shapes': ['bar'], 'attrs': ['x'], 'why': '산 선언'},
|
|
648
|
+
{'slides': [1, 2], 'shapes': ['ghost'], 'attrs': ['y'], 'why': '죽은 선언'}])
|
|
649
|
+
dead = [e for e in errs if '죽은 선언' in e]
|
|
650
|
+
ok('A9 geometry: 산 선언 1 면제 · 죽은 선언 1 보고 (%s)' % (dead[0][:40] if dead else '-')) \
|
|
651
|
+
if len(sup) == 1 and len(dead) == 1 and 'intended[2]' in dead[0] else ng('A9 geometry sup=%d errs=%r' % (len(sup), errs))
|
|
652
|
+
S_a, sw_a = LA.load(os.path.join(fx_dir, 'ma_algn.pptx'))
|
|
653
|
+
_l2, sup2, errs2, st2 = LA.collect(os.path.join(fx_dir, 'ma_algn.pptx'), {
|
|
654
|
+
'intended': [{'axis': 'algn', 'slides': [1], 'why': '산 선언'}, {'axis': 'dash', 'slides': [1], 'why': '죽은 선언'}]})
|
|
655
|
+
dead2 = [e for e in errs2 if '죽은 선언' in e]
|
|
656
|
+
ok('A9 attr: 산 선언 1 면제 · 죽은 선언 1 보고') if len(sup2) == 1 and len(dead2) == 1 and 'intended[2]' in dead2[0] \
|
|
657
|
+
else ng('A9 attr sup=%d errs=%r' % (len(sup2), errs2))
|
|
658
|
+
|
|
659
|
+
# ── R2 (codex 09-11 13:04) 레인 A5–A10 · B11–B12 ──
|
|
660
|
+
mp3 = os.path.join(fx_dir, 'r2_man.md')
|
|
661
|
+
def _man(lines):
|
|
662
|
+
with open(mp3, 'w', encoding='utf-8') as f:
|
|
663
|
+
f.write('### U1 · x\n🖥\n' + '\n'.join(lines) + '\n🗣\n말\n')
|
|
664
|
+
return LS.manuscript_screens(mp3)
|
|
665
|
+
def _shape(*paras):
|
|
666
|
+
return ('<p:sp><p:nvSpPr><p:cNvPr id="1" name="g"/></p:nvSpPr><p:txBody>' +
|
|
667
|
+
''.join('<a:p><a:r><a:rPr/><a:t>%s</a:t></a:r></a:p>' % t for t in paras) + '</p:txBody></p:sp>')
|
|
668
|
+
def _cmp(man, deck_path, **kw):
|
|
669
|
+
grp = LS.deck_screens_grouped(deck_path); dk = [[t for sh_ in sl for t in sh_] for sl in grp]
|
|
670
|
+
return LS.compare(man, dk, deck_groups=grp, **kw)
|
|
671
|
+
# A5 유사 join 은 조용히 사라지지 않는다 — 문구 차이로 남는다
|
|
672
|
+
d = os.path.join(fx_dir, 'r2_fuzzy.pptx'); _mini_deck(d, [_shape('Revenue', 'grew')])
|
|
673
|
+
a5, w5, st5 = _cmp(_man(['Revenue fell']), d)
|
|
674
|
+
ok('A5 유사 join → 문구 차이 1(짝없음 0, joined 1)') if len(w5) == 1 and len(a5) == 0 and st5['joined'] == 1 \
|
|
675
|
+
else ng('A5 a=%r w=%r st=%r' % (a5, w5, st5))
|
|
676
|
+
# B11 1단계에서 일부가 맞았어도 나머지를 잇는다
|
|
677
|
+
d = os.path.join(fx_dir, 'r2_partial.pptx'); _mini_deck(d, [_shape('Title', 'Alpha', 'Beta')])
|
|
678
|
+
a11, w11, st11 = _cmp(_man(['Title', 'AlphaBeta']), d)
|
|
679
|
+
ok('B11 부분 일치 뒤 나머지 join → 어긋남 0') if not a11 and not w11 and st11['joined'] == 1 \
|
|
680
|
+
else ng('B11 a=%r w=%r st=%r' % (a11, w11, st11))
|
|
681
|
+
# B12 중복 문단이 크래시 대신 어긋남으로
|
|
682
|
+
d = os.path.join(fx_dir, 'r2_dup.pptx'); _mini_deck(d, [_shape('X', 'X')])
|
|
683
|
+
try:
|
|
684
|
+
a12, w12, st12 = _cmp(_man(['X', 'XX']), d)
|
|
685
|
+
ok('B12 중복 문단: 크래시 없이 어긋남 %d' % (len(a12) + len(w12))) if (len(a12) + len(w12)) >= 1 \
|
|
686
|
+
else ng('B12 중복 문단이 0 어긋남 (a=%r w=%r)' % (a12, w12))
|
|
687
|
+
except Exception as e:
|
|
688
|
+
ng('B12 크래시 %s: %s' % (type(e).__name__, e))
|
|
689
|
+
# A10 표(graphicFrame) 글자도 화면이다
|
|
690
|
+
d = os.path.join(fx_dir, 'r2_table.pptx')
|
|
691
|
+
tbl = ('<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="2" name="tbl"/></p:nvGraphicFramePr><a:graphic><a:graphicData>'
|
|
692
|
+
'<a:tbl><a:tr><a:tc><a:txBody><a:p><a:r><a:rPr/><a:t>Unreported change</a:t></a:r></a:p></a:txBody></a:tc></a:tr></a:tbl>'
|
|
693
|
+
'</a:graphicData></a:graphic></p:graphicFrame>')
|
|
694
|
+
_mini_deck(d, [_shape('Title') + tbl])
|
|
695
|
+
a10, w10, st10 = _cmp(_man(['Title']), d)
|
|
696
|
+
ok('A10 표 글자가 «화면에만» 으로 잡힌다') if any(x[2] == '화면에만' and 'Unreported' in x[3] for x in a10) \
|
|
697
|
+
else ng('A10 표 글자 미추출 a=%r' % a10)
|
|
698
|
+
# A6 delta() 의 선언 오류는 현재본의 것
|
|
699
|
+
dg_b = os.path.join(fx_dir, 'r2_geo_base.pptx'); dg_c = os.path.join(fx_dir, 'r2_geo_cur.pptx')
|
|
700
|
+
_mini_deck(dg_b, [_sp('box', 914400, 914400, 100000, 100000, 'x'), _sp('box', 914400 + 10000, 914400, 100000, 100000, 'x')])
|
|
701
|
+
_mini_deck(dg_c, [_sp('box', 914400, 914400, 100000, 100000, 'x'), _sp('box', 914400, 914400, 100000, 100000, 'x')])
|
|
702
|
+
_n, _g, _sb, _sc, _sup, errs6 = LG.delta(dg_b, dg_c, 200000, 63500,
|
|
703
|
+
[{'slides': [1, 2], 'shapes': ['box'], 'attrs': ['x'], 'why': 'w'}])
|
|
704
|
+
ok('A6 delta: 현재본에서 죽은 선언이 보고된다') if any('죽은 선언' in e for e in errs6) \
|
|
705
|
+
else ng('A6 delta errs=%r' % errs6)
|
|
706
|
+
# A7 echo 면제는 그룹의 모든 장이 선언돼야
|
|
707
|
+
d = os.path.join(fx_dir, 'r2_echo.pptx')
|
|
708
|
+
_mini_deck(d, [_sp('label', 0, 0, 100, 100, 'same sentence here', 2800), _sp('label', 0, 0, 100, 100, 'same sentence here', 3200),
|
|
709
|
+
_sp('label', 0, 0, 100, 100, 'same sentence here', 4400)])
|
|
710
|
+
l7, sup7, e7, st7 = LA.collect(d, {'axes': ['echo'], 'intended': [{'axis': 'echo', 'slides': [1, 2], 'shapes': ['label'], 'why': 'w'}]})
|
|
711
|
+
l7b, sup7b, e7b, st7b = LA.collect(d, {'axes': ['echo'], 'intended': [{'axis': 'echo', 'slides': [1, 2, 3], 'shapes': ['label'], 'why': 'w'}]})
|
|
712
|
+
ok('A7 echo: [1,2] 만 선언 → 후보로 남음(1) · [1,2,3] 선언 → 면제(0)') if len(l7) == 1 and len(sup7) == 0 and len(l7b) == 0 and len(sup7b) == 1 \
|
|
713
|
+
else ng('A7 partial: lines=%d sup=%d · full: lines=%d sup=%d' % (len(l7), len(sup7), len(l7b), len(sup7b)))
|
|
714
|
+
# A8 런 경계는 글자가 아니다
|
|
715
|
+
d = os.path.join(fx_dir, 'r2_runs.pptx')
|
|
716
|
+
two_runs = ('<p:sp><p:nvSpPr><p:cNvPr id="1" name="t"/></p:nvSpPr><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="100" cy="100"/></a:xfrm></p:spPr>'
|
|
717
|
+
'<p:txBody><a:p><a:r><a:rPr sz="3200"/><a:t>Alpha</a:t></a:r><a:r><a:rPr sz="3200"/><a:t>BetaGamma</a:t></a:r></a:p></p:txBody></p:sp>')
|
|
718
|
+
_mini_deck(d, [_sp('t', 0, 0, 100, 100, 'AlphaBetaGamma', 2800), two_runs])
|
|
719
|
+
S8, _ = LA.load(d)
|
|
720
|
+
ok('A8 두 런 «Alpha»+«BetaGamma» = «AlphaBetaGamma» (공백 삽입 없음)') if S8[1][0]['text'] == 'AlphaBetaGamma' \
|
|
721
|
+
else ng('A8 text=%r' % S8[1][0]['text'])
|
|
722
|
+
# A9 순서 보존 — 좌 28/32 ↔ 우 32/28 은 다르다
|
|
723
|
+
d = os.path.join(fx_dir, 'r2_order.pptx')
|
|
724
|
+
def _two(name, x, s1, s2):
|
|
725
|
+
return ('<p:sp><p:nvSpPr><p:cNvPr id="1" name="%s"/></p:nvSpPr><p:spPr><a:xfrm><a:off x="%d" y="914400"/><a:ext cx="3657600" cy="914400"/></a:xfrm></p:spPr>'
|
|
726
|
+
'<p:txBody><a:p><a:r><a:rPr sz="%d"/><a:t>Alpha</a:t></a:r><a:r><a:rPr sz="%d"/><a:t>Beta</a:t></a:r></a:p></p:txBody></p:sp>' % (name, x, s1, s2))
|
|
727
|
+
_mini_deck(d, [_two('L', 914400, 2800, 3200) + _two('R', 7620000, 3200, 2800)])
|
|
728
|
+
S9, sw9 = LA.load(d)
|
|
729
|
+
hits9 = list(LA.ax_mirror(S9, sw9, {}))
|
|
730
|
+
ok('A9 mirror: (28,32) vs (32,28) 가 갈림으로 잡힌다') if len(hits9) == 1 and S9[0][0]['szs'] == (28.0, 32.0) \
|
|
731
|
+
else ng('A9 hits=%d szs=%r/%r' % (len(hits9), S9[0][0]['szs'], S9[0][1]['szs']))
|
|
732
|
+
|
|
733
|
+
# ── R3 (codex 09-11 13:1x) 레인 A6–A12 · B13–B14 ──
|
|
734
|
+
# A6 여는 태그에 속성이 있어도 도형이다 (셋 다)
|
|
735
|
+
d = os.path.join(fx_dir, 'r3_attr_tag.pptx')
|
|
736
|
+
_mini_deck(d, [_sp('h', 0, 0, 100, 100, 'Hidden by parser').replace('<p:sp>', '<p:sp useBgFill="1">')])
|
|
737
|
+
n_g = len(LG.load_slides(d)[0]); n_a = len(LA.load(d)[0][0]); n_s = len(LS.deck_screens(d)[0])
|
|
738
|
+
ok('A6 <p:sp useBgFill="1"> — geometry %d · attr %d · parity %d (전부 1)' % (n_g, n_a, n_s)) if (n_g, n_a, n_s) == (1, 1, 1) \
|
|
739
|
+
else ng('A6 속성 있는 여는 태그에서 도형 소실 g=%d a=%d s=%d' % (n_g, n_a, n_s))
|
|
740
|
+
# A7 geometry: 런이 갈려도 같은 글자면 결박되고 flip 이 보고된다
|
|
741
|
+
d = os.path.join(fx_dir, 'r3_geo_runs.pptx')
|
|
742
|
+
split_run = ('<p:sp><p:nvSpPr><p:cNvPr id="1" name="arrow"/></p:nvSpPr><p:spPr><a:xfrm flipH="1"><a:off x="0" y="0"/><a:ext cx="100" cy="100"/></a:xfrm></p:spPr>'
|
|
743
|
+
'<p:txBody><a:p><a:r><a:rPr/><a:t>AB</a:t></a:r><a:r><a:rPr/><a:t>CD</a:t></a:r></a:p></p:txBody></p:sp>')
|
|
744
|
+
_mini_deck(d, [_sp('arrow', 0, 0, 100, 100, 'ABCD'), split_run])
|
|
745
|
+
l7, st7, _s, _e = LG.p1_lines(LG.load_slides(d), 200000)
|
|
746
|
+
ok('A7 geometry: AB+CD 런이 ABCD 로 결박되고 뒤집힘 1건 보고') if any('뒤집힘' in l for l in l7) and st7.get('mismatch', 0) == 0 \
|
|
747
|
+
else ng('A7 lines=%r stats=%r' % (l7, st7))
|
|
748
|
+
# A8 delta 신원 — 앞 16자가 같은 두 이름을 합치지 않는다
|
|
749
|
+
db = os.path.join(fx_dir, 'r3_delta_b.pptx'); dc = os.path.join(fx_dir, 'r3_delta_c.pptx')
|
|
750
|
+
A = 'abcdefghijklmnopA'; B = 'abcdefghijklmnopB'
|
|
751
|
+
# B 는 멀리(x=5,000,000) 둔다 — 겹치면 P3 인접 후보가 델타에 섞인다(계기가 아니라 픽스처의 문제)
|
|
752
|
+
_mini_deck(db, [_sp(A, 0, 0, 100, 100, 'x') + _sp(B, 5000000, 0, 100, 100, 'x'), _sp(A, 10, 0, 100, 100, 'x') + _sp(B, 5000000, 0, 100, 100, 'x')])
|
|
753
|
+
_mini_deck(dc, [_sp(A, 0, 0, 100, 100, 'x') + _sp(B, 5000000, 0, 100, 100, 'x'), _sp(A, 0, 0, 100, 100, 'x') + _sp(B, 5000010, 0, 100, 100, 'x')])
|
|
754
|
+
new8, gone8, *_ = LG.delta(db, dc, 200000, 63500)
|
|
755
|
+
ok('A8 delta: 새 1 · 사라짐 1 (이름 전체가 신원)') if len(new8) == 1 and len(gone8) == 1 else ng('A8 new=%d gone=%d' % (len(new8), len(gone8)))
|
|
756
|
+
# A9 문자열 shapes 선언은 오류 (geometry · attr)
|
|
757
|
+
_i9, e9 = LG._intent_index([{'slides': [1, 2], 'shapes': 'ab', 'attrs': ['x'], 'why': 'w'}])
|
|
758
|
+
_i9b, e9b = LA._intent_index([{'axis': 'dash', 'slides': [1], 'shapes': 'ab', 'why': 'w'}])
|
|
759
|
+
ok('A9 shapes:"ab" → geometry·attr 둘 다 선언 오류(면제 0)') if len(e9) == 1 and not _i9 and len(e9b) == 1 and not _i9b \
|
|
760
|
+
else ng('A9 geo errs=%r idx=%r · attr errs=%r idx=%r' % (e9, dict(_i9), e9b, dict(_i9b)))
|
|
761
|
+
# A10 구간: AB@28+CD@32 vs A@28+BCD@32 는 다르다 · ABCD@28 vs AB@28+CD@28 은 같다
|
|
762
|
+
def _runs(name, x, *runs):
|
|
763
|
+
return ('<p:sp><p:nvSpPr><p:cNvPr id="1" name="%s"/></p:nvSpPr><p:spPr><a:xfrm><a:off x="%d" y="914400"/><a:ext cx="3657600" cy="914400"/></a:xfrm></p:spPr>'
|
|
764
|
+
'<p:txBody><a:p>%s</a:p></p:txBody></p:sp>' % (name, x, ''.join('<a:r><a:rPr sz="%d"/><a:t>%s</a:t></a:r>' % (sz, t) for t, sz in runs)))
|
|
765
|
+
d = os.path.join(fx_dir, 'r3_spans.pptx')
|
|
766
|
+
# 장 1: t=AB@28+CD@32 · u=ABCD@28 · 장 2: t=A@28+BCD@32 · u=AB@28+CD@28 → echo 는 t 만(1건), u 는 같은 구간이라 0
|
|
767
|
+
_mini_deck(d, [_runs('t', 0, ('ABCDEFGH', 2800), ('IJKLMNOP', 3200)) + _runs('u', 5000000, ('QRSTUVWXQRSTUVWX', 2800)),
|
|
768
|
+
_runs('t', 0, ('ABCDEFG', 2800), ('HIJKLMNOP', 3200)) + _runs('u', 5000000, ('QRSTUVWX', 2800), ('QRSTUVWX', 2800))])
|
|
769
|
+
S10, sw10 = LA.load(d)
|
|
770
|
+
sp_t1, sp_u1, sp_t2, sp_u2 = S10[0][0]['spans'], S10[0][1]['spans'], S10[1][0]['spans'], S10[1][1]['spans']
|
|
771
|
+
e10 = list(LA.ax_echo(S10, sw10, {}))
|
|
772
|
+
ok('A10 구간: t %s≠%s → echo 1 · u 같은 구간 → 0 (echo 총 %d)' % (sp_t1, sp_t2, len(e10))) \
|
|
773
|
+
if sp_t1 != sp_t2 and sp_u1 == sp_u2 == ((16, 28.0),) and len(e10) == 1 and 'ABCDEFGH' in e10[0][2] and 'u' not in [n for _s, ns, _l in e10 for n in ns] \
|
|
774
|
+
else ng('A10 t=%r/%r u=%r/%r echo=%r' % (sp_t1, sp_t2, sp_u1, sp_u2, [l for _s, _n, l in e10]))
|
|
775
|
+
# A11 정렬 슬롯: [ctr, 기본] vs [기본, ctr] 는 다르다
|
|
776
|
+
def _paras(name, x, *al):
|
|
777
|
+
return ('<p:sp><p:nvSpPr><p:cNvPr id="1" name="%s"/></p:nvSpPr><p:spPr><a:xfrm><a:off x="%d" y="914400"/><a:ext cx="3657600" cy="914400"/></a:xfrm></p:spPr><p:txBody>%s</p:txBody></p:sp>'
|
|
778
|
+
% (name, x, ''.join('<a:p>%s<a:r><a:rPr/><a:t>p</a:t></a:r></a:p>' % ('<a:pPr algn="%s"/>' % a_ if a_ else '') for a_ in al)))
|
|
779
|
+
d = os.path.join(fx_dir, 'r3_algn_slots.pptx'); _mini_deck(d, [_paras('a', 0, 'ctr', None) + _paras('b', 5000000, None, 'ctr')])
|
|
780
|
+
S11, sw11 = LA.load(d)
|
|
781
|
+
ok('A11 정렬 슬롯 %s vs %s → 갈림 1' % (S11[0][0]['algn'], S11[0][1]['algn'])) if S11[0][0]['algn'] != S11[0][1]['algn'] and len(list(LA.ax_algn(S11, sw11, {}))) == 1 \
|
|
782
|
+
else ng('A11 algn=%r/%r' % (S11[0][0]['algn'], S11[0][1]['algn']))
|
|
783
|
+
# A12 엔티티 동치: & 와 & 는 같은 문장
|
|
784
|
+
d = os.path.join(fx_dir, 'r3_entity.pptx')
|
|
785
|
+
_mini_deck(d, [_sp('e', 0, 0, 100, 100, 'Research & Development', 2800), _sp('e', 0, 0, 100, 100, 'Research & Development', 3200)])
|
|
786
|
+
S12, sw12 = LA.load(d)
|
|
787
|
+
ok('A12 &/& 같은 키 → echo 크기 갈림 1') if len(list(LA.ax_echo(S12, sw12, {}))) == 1 else ng('A12 texts=%r' % [S12[0][0]['text'], S12[1][0]['text']])
|
|
788
|
+
# B13 도형 전체 정확 join 을 먼저 소비 — 원고 AB·ABX ↔ 도형 [AB,X]·[A,B] → 0
|
|
789
|
+
d = os.path.join(fx_dir, 'r3_greedy.pptx'); _mini_deck(d, [_shape('AB', 'X') + _shape('A', 'B')])
|
|
790
|
+
a13, w13, st13 = _cmp(_man(['AB', 'ABX']), d)
|
|
791
|
+
ok('B13 정확 도형 join 우선 → 어긋남 0') if not a13 and not w13 else ng('B13 a=%r w=%r' % (a13, w13))
|
|
792
|
+
# B14 부분 선언이 완전 선언을 가리지 않는다(순서 무관)
|
|
793
|
+
d = os.path.join(fx_dir, 'r3_echo_order.pptx')
|
|
794
|
+
_mini_deck(d, [_sp('label', 0, 0, 100, 100, 'same sentence here', 2800), _sp('label', 0, 0, 100, 100, 'same sentence here', 3200)])
|
|
795
|
+
decl = [{'axis': 'echo', 'slides': [1], 'shapes': ['label'], 'why': '부분'}, {'axis': 'echo', 'slides': [1, 2], 'shapes': ['label'], 'why': '완전'}]
|
|
796
|
+
l14a, s14a, e14a, _ = LA.collect(d, {'axes': ['echo'], 'intended': decl})
|
|
797
|
+
l14b, s14b, e14b, _ = LA.collect(d, {'axes': ['echo'], 'intended': list(reversed(decl))})
|
|
798
|
+
ok('B14 [부분,완전]·[완전,부분] 둘 다 면제 1 · 후보 0') if len(s14a) == 1 and len(s14b) == 1 and not l14a and not l14b \
|
|
799
|
+
else ng('B14 a: sup=%d lines=%d · b: sup=%d lines=%d · errs=%r' % (len(s14a), len(l14a), len(s14b), len(l14b), e14a))
|
|
800
|
+
|
|
801
|
+
# ── R4 (codex 09-11 18:02) 레인 A3–A7 · B8 ──
|
|
802
|
+
# A3 ⓪ 에서 통째로 소비된 도형은 ② 가 다시 못 빌린다 — 원고 AB·AB ↔ 도형 [A,B]·[A]·[B] → 둘째 AB 는 짝 없음
|
|
803
|
+
d = os.path.join(fx_dir, 'r4_owner.pptx'); _mini_deck(d, [_shape('A', 'B') + _shape('A') + _shape('B')])
|
|
804
|
+
a3, w3, st3 = _cmp(_man(['AB', 'AB']), d)
|
|
805
|
+
ok('A3 도형 소유권: 둘째 AB 는 짝 없음(absent %d · joined %d)' % (len(a3), st3['joined'])) if len(a3) == 1 and st3['joined'] == 1 \
|
|
806
|
+
else ng('A3 a=%r w=%r st=%r' % (a3, w3, st3))
|
|
807
|
+
# A4 <a:fld> 런의 크기도 구간에 들어간다
|
|
808
|
+
def _fld(name, x, sz):
|
|
809
|
+
return ('<p:sp><p:nvSpPr><p:cNvPr id="1" name="%s"/></p:nvSpPr><p:spPr><a:xfrm><a:off x="%d" y="914400"/><a:ext cx="3657600" cy="914400"/></a:xfrm></p:spPr>'
|
|
810
|
+
'<p:txBody><a:p><a:fld id="{x}" type="slidenum"><a:rPr sz="%d"/><a:t>7</a:t></a:fld></a:p></p:txBody></p:sp>' % (name, x, sz))
|
|
811
|
+
d = os.path.join(fx_dir, 'r4_fld.pptx'); _mini_deck(d, [_fld('L', 914400, 2800) + _fld('R', 7620000, 3200)])
|
|
812
|
+
S4, sw4 = LA.load(d)
|
|
813
|
+
ok('A4 fld 런 구간 %s vs %s → mirror 1' % (S4[0][0]['spans'], S4[0][1]['spans'])) if S4[0][0]['spans'] == ((1, 28.0),) and len(list(LA.ax_mirror(S4, sw4, {}))) == 1 \
|
|
814
|
+
else ng('A4 spans=%r/%r' % (S4[0][0]['spans'], S4[0][1]['spans']))
|
|
815
|
+
# A5 <a:off y= x=> 순서 — geometry·attr 둘 다 도형을 잃지 않는다
|
|
816
|
+
d = os.path.join(fx_dir, 'r4_offorder.pptx')
|
|
817
|
+
swapped = _sp('m', 35000, 0, 100, 100, 'x').replace('<a:off x="35000" y="0"/>', '<a:off y="0" x="35000"/>')
|
|
818
|
+
_mini_deck(d, [_sp('m', 0, 0, 100, 100, 'x'), swapped])
|
|
819
|
+
l5, st5, _s, _e = LG.p1_lines(LG.load_slides(d), 200000)
|
|
820
|
+
n_a5 = len(LA.load(d)[0][1])
|
|
821
|
+
ok('A5 off 속성 순서 뒤집혀도 geometry 후보 1 · attr 도형 1') if len(l5) == 1 and n_a5 == 1 else ng('A5 geo=%r attr=%d' % (l5, n_a5))
|
|
822
|
+
# A6 geometry 엔티티 동치 — R&D 와 R&D 는 같은 글자로 결박
|
|
823
|
+
d = os.path.join(fx_dir, 'r4_geo_entity.pptx'); _mini_deck(d, [_sp('e', 0, 0, 100, 100, 'R&D'), _sp('e', 35000, 0, 100, 100, 'R&D')])
|
|
824
|
+
l6, st6, _s, _e = LG.p1_lines(LG.load_slides(d), 200000)
|
|
825
|
+
ok('A6 geometry: 엔티티 표기가 달라도 결박(mismatch 0) · 후보 1') if len(l6) == 1 and st6.get('mismatch', 0) == 0 else ng('A6 lines=%r st=%r' % (l6, st6))
|
|
826
|
+
# A7 slides 원소 1.9 · true 는 오류
|
|
827
|
+
_i7, e7 = LA._intent_index([{'axis': 'dash', 'slides': [1.9], 'why': 'w'}, {'axis': 'dash', 'slides': [True], 'why': 'w'}])
|
|
828
|
+
ok('A7 slides [1.9]·[true] → 선언 오류 2 · 면제 0') if len(e7) == 2 and not _i7 else ng('A7 errs=%r idx=%r' % (e7, dict(_i7)))
|
|
829
|
+
# B8 mirror: 글자 수 다른 AB / ABCDE 같은 28pt → 후보 0 · (28,32)/(32,28) 는 여전히 1
|
|
830
|
+
d = os.path.join(fx_dir, 'r4_mirror_len.pptx')
|
|
831
|
+
_mini_deck(d, [_sp('L', 914400, 914400, 3657600, 914400, 'AB', 2800) + _sp('R', 7620000, 914400, 3657600, 914400, 'ABCDE', 2800)])
|
|
832
|
+
S8, sw8 = LA.load(d)
|
|
833
|
+
S9b, sw9b = LA.load(os.path.join(fx_dir, 'r2_order.pptx'))
|
|
834
|
+
ok('B8 mirror: 글자 수만 다른 쌍 0 · 크기 순열 다른 쌍 1') if len(list(LA.ax_mirror(S8, sw8, {}))) == 0 and len(list(LA.ax_mirror(S9b, sw9b, {}))) == 1 \
|
|
835
|
+
else ng('B8 len-only=%d order=%d' % (len(list(LA.ax_mirror(S8, sw8, {}))), len(list(LA.ax_mirror(S9b, sw9b, {})))))
|
|
836
|
+
|
|
837
|
+
# ── R5 (codex 09-11 18:13) 레인 A3–A7 ──
|
|
838
|
+
# A3 그룹 xfrm 의 flipH 가 자식에 합성된다
|
|
839
|
+
d = os.path.join(fx_dir, 'r5_grpflip.pptx')
|
|
840
|
+
child = _sp('arrow', 0, 0, 100, 100, 'x')
|
|
841
|
+
grp_flipped = ('<p:grpSp><p:nvGrpSpPr><p:cNvPr id="9" name="g"/></p:nvGrpSpPr><p:grpSpPr><a:xfrm flipH="1"><a:off x="0" y="0"/><a:ext cx="100" cy="100"/>'
|
|
842
|
+
'<a:chOff x="0" y="0"/><a:chExt cx="100" cy="100"/></a:xfrm></p:grpSpPr>' + child + '</p:grpSp>')
|
|
843
|
+
grp_plain = grp_flipped.replace('<a:xfrm flipH="1">', '<a:xfrm>')
|
|
844
|
+
_mini_deck(d, [grp_plain, grp_flipped])
|
|
845
|
+
l3, st3, _s, _e = LG.p1_lines(LG.load_slides(d), 200000)
|
|
846
|
+
ok('A3 그룹 뒤집힘이 자식에 합성 → 뒤집힘 1건') if any('뒤집힘' in l for l in l3) else ng('A3 lines=%r st=%r' % (l3, st3))
|
|
847
|
+
# A4 sldSz 속성 순서 뒤집혀도 폭 20.0 (기본값 13.333 로 안 떨어진다)
|
|
848
|
+
d = os.path.join(fx_dir, 'r5_sldsz.pptx'); _mini_deck(d, [_sp('a', 0, 0, 100, 100, 'x')])
|
|
849
|
+
import zipfile
|
|
850
|
+
zi = zipfile.ZipFile(d); data = {n: zi.read(n) for n in zi.namelist()}; zi.close()
|
|
851
|
+
data['ppt/presentation.xml'] = data['ppt/presentation.xml'].replace(b'<p:sldSz cx="12192000" cy="6858000"/>', b'<p:sldSz cy="6858000" cx="18288000"/>')
|
|
852
|
+
zo = zipfile.ZipFile(d, 'w'); [zo.writestr(n, v) for n, v in data.items()]; zo.close()
|
|
853
|
+
_S4, sw4b = LA.load(d)
|
|
854
|
+
ok('A4 sldSz cy·cx 순서 → 폭 %.1f (20.0)' % sw4b) if abs(sw4b - 20.0) < 0.01 else ng('A4 sw=%r' % sw4b)
|
|
855
|
+
# A5 같은 글자면 구간 전체 비교 — AB@28+CD@32 vs A@28+BCD@32 (B 가 커졌다) → mirror 1
|
|
856
|
+
d = os.path.join(fx_dir, 'r5_sametext.pptx')
|
|
857
|
+
_mini_deck(d, [_runs('L', 914400, ('AB', 2800), ('CD', 3200)) + _runs('R', 7620000, ('A', 2800), ('BCD', 3200))])
|
|
858
|
+
S5, sw5 = LA.load(d)
|
|
859
|
+
ok('A5 같은 글자·다른 경계 → mirror 1') if len(list(LA.ax_mirror(S5, sw5, {}))) == 1 else ng('A5 spans=%r/%r' % (S5[0][0]['spans'], S5[0][1]['spans']))
|
|
860
|
+
# A6 한 장에 같은 이름 둘 — 선언 ["A","B"] 가 셋째 A 를 같이 면제하지 못한다
|
|
861
|
+
d = os.path.join(fx_dir, 'r5_dupname.pptx')
|
|
862
|
+
def _ln(name, x, w_emu, dash='dash'):
|
|
863
|
+
return ('<p:sp><p:nvSpPr><p:cNvPr id="1" name="%s"/></p:nvSpPr><p:spPr><a:xfrm><a:off x="%d" y="0"/><a:ext cx="100" cy="100"/></a:xfrm>'
|
|
864
|
+
'<a:ln w="%d"><a:prstDash val="%s"/></a:ln></p:spPr><p:txBody><a:p><a:r><a:rPr/><a:t>l</a:t></a:r></a:p></p:txBody></p:sp>' % (name, x, w_emu, dash))
|
|
865
|
+
_mini_deck(d, [_ln('A', 0, 12700) + _ln('B', 1000000, 25400) + _ln('A', 2000000, 63500)])
|
|
866
|
+
l6, s6, e6, _ = LA.collect(d, {'axes': ['dash'], 'intended': [{'axis': 'dash', 'slides': [1], 'shapes': ['A', 'B'], 'why': 'w'}]})
|
|
867
|
+
_mini_deck(d, [_ln('A', 0, 12700) + _ln('B', 1000000, 25400)])
|
|
868
|
+
l6b, s6b, e6b, _ = LA.collect(d, {'axes': ['dash'], 'intended': [{'axis': 'dash', 'slides': [1], 'shapes': ['A', 'B'], 'why': 'w'}]})
|
|
869
|
+
# 두 번째 팔: shapes 없는 «그 장 전체» 선언도 이름이 겹치는 그룹은 못 면제한다(AMBIG 가드의 고유 몫)
|
|
870
|
+
_mini_deck(d, [_ln('A', 0, 12700) + _ln('B', 1000000, 25400) + _ln('A', 2000000, 63500)])
|
|
871
|
+
l6c, s6c, e6c, _ = LA.collect(d, {'axes': ['dash'], 'intended': [{'axis': 'dash', 'slides': [1], 'why': 'w'}]})
|
|
872
|
+
ok('A6 같은 이름 둘 → 면제 안 됨(후보 1, 장 전체 선언도 1) · 이름 유일 컨트롤 → 면제 1') \
|
|
873
|
+
if len(l6) == 1 and not s6 and not l6b and len(s6b) == 1 and len(l6c) == 1 and not s6c \
|
|
874
|
+
else ng('A6 dup: lines=%d sup=%d · uniq: lines=%d sup=%d · whole-slide: lines=%d sup=%d' % (len(l6), len(s6), len(l6b), len(s6b), len(l6c), len(s6c)))
|
|
875
|
+
# A7 <a:t xmlns:a=…> 도 글자다 (parity · attr · geometry)
|
|
876
|
+
d = os.path.join(fx_dir, 'r5_xmlns_t.pptx')
|
|
877
|
+
_mini_deck(d, [_sp('u', 0, 0, 100, 100, 'UNLISTED').replace('<a:t>', '<a:t xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">')])
|
|
878
|
+
t_par = LS.deck_screens(d)[0]; t_attr = LA.load(d)[0][0][0]['text']; t_geo = LG.load_slides(d)[0]['u'][4]
|
|
879
|
+
ok('A7 xmlns 붙은 <a:t>: parity %r · attr %r · geo %r' % (t_par, t_attr, t_geo)) if t_par == ['UNLISTED'] and t_attr == 'UNLISTED' and t_geo == 'UNLISTED' \
|
|
880
|
+
else ng('A7 par=%r attr=%r geo=%r' % (t_par, t_attr, t_geo))
|
|
881
|
+
|
|
882
|
+
# ── R6 (codex 09-11 23:07) 레인 — 정규식 독자 → oox 트리 독자 교체의 회귀 ──
|
|
883
|
+
def _grp(inner, flip='', off=(0, 0), ext=(100, 100), choff=(0, 0), chext=(100, 100)):
|
|
884
|
+
xf = '<a:xfrm%s><a:off x="%d" y="%d"/><a:ext cx="%d" cy="%d"/><a:chOff x="%d" y="%d"/><a:chExt cx="%d" cy="%d"/></a:xfrm>' % (
|
|
885
|
+
(' flipH="1"' if flip == 'H' else ''), off[0], off[1], ext[0], ext[1], choff[0], choff[1], chext[0], chext[1])
|
|
886
|
+
return '<p:grpSp><p:nvGrpSpPr><p:cNvPr id="9" name="g"/></p:nvGrpSpPr><p:grpSpPr>%s</p:grpSpPr>%s</p:grpSp>' % (xf, inner)
|
|
887
|
+
# A3 중첩 그룹: 안쪽 그룹의 flipH 가 화살표에 합성 · 바깥 그룹 flip 은 «안쪽 그룹 뒤의 형제» 에도 합성
|
|
888
|
+
child = _sp('arrow', 0, 0, 100, 100, 'x'); sib = _sp('sib', 0, 0, 100, 100, 'y')
|
|
889
|
+
d = os.path.join(fx_dir, 'r6_nested.pptx')
|
|
890
|
+
_mini_deck(d, [_grp(_grp(child) + sib), _grp(_grp(child, flip='H') + sib)])
|
|
891
|
+
l3, st3, _s, _e = LG.p1_lines(LG.load_slides(d), 200000)
|
|
892
|
+
d2 = os.path.join(fx_dir, 'r6_nested_outer.pptx')
|
|
893
|
+
_mini_deck(d2, [_grp(_grp(child) + sib), _grp(_grp(child) + sib, flip='H')])
|
|
894
|
+
l3b, _st, _s, _e = LG.p1_lines(LG.load_slides(d2), 200000)
|
|
895
|
+
ok('A3 중첩: 안쪽 flip → arrow 뒤집힘 1 · 바깥 flip → arrow·sib 둘 다 뒤집힘') \
|
|
896
|
+
if sum('뒤집힘' in l and 'arrow' in l for l in l3) == 1 and sum('뒤집힘' in l for l in l3b) == 2 \
|
|
897
|
+
else ng('A3 inner=%r outer=%r' % (l3, l3b))
|
|
898
|
+
# A4 그룹 이동이 자식 절대 좌표에 합성 — geometry 후보 1 · attr x 가 달라져 echo 후보 1
|
|
899
|
+
d = os.path.join(fx_dir, 'r6_grpmove.pptx')
|
|
900
|
+
_mini_deck(d, [_grp(_sp('t', 0, 0, 100, 100, 'same sentence here', 2800), off=(0, 0)), _grp(_sp('t', 0, 0, 100, 100, 'same sentence here', 2800), off=(35000, 0))])
|
|
901
|
+
l4, st4, _s, _e = LG.p1_lines(LG.load_slides(d), 200000)
|
|
902
|
+
S4, sw4 = LA.load(d)
|
|
903
|
+
e4 = list(LA.ax_echo(S4, sw4, {}))
|
|
904
|
+
d = os.path.join(fx_dir, 'r6_grpmove_big.pptx')
|
|
905
|
+
_mini_deck(d, [_grp(_sp('t', 0, 0, 100, 100, 'same sentence here', 2800), off=(0, 0)), _grp(_sp('t', 0, 0, 100, 100, 'same sentence here', 2800), off=(914400, 0))])
|
|
906
|
+
S4b, sw4b = LA.load(d); e4b = list(LA.ax_echo(S4b, sw4b, {}))
|
|
907
|
+
ok('A4 그룹 이동 35000 EMU → geometry 후보 1 (%s) · 1인치 이동 → echo x 갈림 1' % (len(l4),)) \
|
|
908
|
+
if len(l4) == 1 and abs(S4[1][0]['x'] - 35000 / 914400) < 1e-6 and len(e4b) == 1 and len(e4) == 0 \
|
|
909
|
+
else ng('A4 geo=%r x2=%r echo35k=%d echo1in=%d' % (l4, S4[1][0]['x'], len(e4), len(e4b)))
|
|
910
|
+
# A5 같은 장 «다른 자리» 의 같은 이름이 거울 면제를 막는다
|
|
911
|
+
d = os.path.join(fx_dir, 'r6_dup_elsewhere.pptx')
|
|
912
|
+
_mini_deck(d, [_sp('A', 914400, 914400, 3657600, 914400, 'left', 2800) + _sp('B', 7620000, 914400, 3657600, 914400, 'right', 3200) + _sp('A', 0, 5000000, 100, 100, 'else')])
|
|
913
|
+
l5, s5, e5, _ = LA.collect(d, {'axes': ['mirror'], 'intended': [{'axis': 'mirror', 'slides': [1], 'shapes': ['A', 'B'], 'why': 'w'}]})
|
|
914
|
+
ok('A5 다른 자리의 같은 이름 A → 거울 면제 안 됨(후보 1)') if len(l5) == 1 and not s5 else ng('A5 lines=%d sup=%d' % (len(l5), len(s5)))
|
|
915
|
+
# A6 <q:sldId xmlns:q=…> 도 슬라이드다 — 세 레인 모두 3장
|
|
916
|
+
d = os.path.join(fx_dir, 'r6_qsld.pptx'); _mini_deck(d, [_sp('a', 0, 0, 100, 100, 'FIRST'), _sp('b', 0, 0, 100, 100, 'HIDDEN'), _sp('c', 0, 0, 100, 100, 'THIRD')])
|
|
917
|
+
zi = zipfile.ZipFile(d); data = {n: zi.read(n) for n in zi.namelist()}; zi.close()
|
|
918
|
+
import re
|
|
919
|
+
pres = data["ppt/presentation.xml"].decode(); ids = re.findall(r'<p:sldId [^>]*/>', pres)
|
|
920
|
+
pres = pres.replace(ids[1], ids[1].replace('<p:sldId ', '<q:sldId xmlns:q="http://schemas.openxmlformats.org/presentationml/2006/main" '), 1)
|
|
921
|
+
data['ppt/presentation.xml'] = pres.encode()
|
|
922
|
+
zo = zipfile.ZipFile(d, 'w'); [zo.writestr(n, v) for n, v in data.items()]; zo.close()
|
|
923
|
+
n6 = (len(LG.load_slides(d)), len(LA.load(d)[0]), len(LS.deck_screens(d)))
|
|
924
|
+
ok('A6 q:sldId — geometry·attr·parity 전부 3장 %s' % (n6,)) if n6 == (3, 3, 3) else ng('A6 %r' % (n6,))
|
|
925
|
+
# B7 공백 길이 차이는 크기 차이가 아니다 — "A B" vs "A B" 28pt → mirror 0
|
|
926
|
+
d = os.path.join(fx_dir, 'r6_ws.pptx')
|
|
927
|
+
_mini_deck(d, [_sp('L', 914400, 914400, 3657600, 914400, 'A B', 2800) + _sp('R', 7620000, 914400, 3657600, 914400, 'A B', 2800)])
|
|
928
|
+
S7, sw7 = LA.load(d)
|
|
929
|
+
ok('B7 공백 길이만 다른 거울 쌍 → 후보 0 (spans %s/%s)' % (S7[0][0]['spans'], S7[0][1]['spans'])) if len(list(LA.ax_mirror(S7, sw7, {}))) == 0 \
|
|
930
|
+
else ng('B7 spans=%r/%r' % (S7[0][0]['spans'], S7[0][1]['spans']))
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def run_r7_regressions(fx_dir):
|
|
934
|
+
"""R7 (codex 2026-09-11, commit 1551a4b) — 3S·4A·1B 각각 known-pair. 되돌림 프로브는 세션 마커에 기록."""
|
|
935
|
+
print('\n[2-d] R7 codex 감사 회귀 — S1 rels 철자 · S2 r:embed 참조 · S3 잘린 JPEG · A4 flip 반사 · A5 AlternateContent · A6 공백 크기 · A7 자리표시자 중복 · B8 xmlns 공백')
|
|
936
|
+
import subprocess, shutil, zipfile, re, io
|
|
937
|
+
import lane_geometry as LG
|
|
938
|
+
import lane_attr_consistency as LA
|
|
939
|
+
import oox
|
|
940
|
+
here = os.path.join(HERE, 'safe_install.py')
|
|
941
|
+
src = os.path.join(fx_dir, 'p1.pptx')
|
|
942
|
+
dest = os.path.join(fx_dir, 'r7_dest.pptx')
|
|
943
|
+
|
|
944
|
+
def run(cand):
|
|
945
|
+
shutil.copyfile(src, dest)
|
|
946
|
+
r = subprocess.run([sys.executable, here, cand, dest, '--dry-run'], capture_output=True, text=True)
|
|
947
|
+
return r.returncode, r.stdout + r.stderr
|
|
948
|
+
|
|
949
|
+
def rewrite(src_p, dst_p, part, fn):
|
|
950
|
+
zi = zipfile.ZipFile(src_p); zo = zipfile.ZipFile(dst_p, 'w', zipfile.ZIP_DEFLATED)
|
|
951
|
+
for n in zi.namelist():
|
|
952
|
+
d = zi.read(n)
|
|
953
|
+
if n == part:
|
|
954
|
+
d = fn(d)
|
|
955
|
+
zo.writestr(n, d)
|
|
956
|
+
zo.close()
|
|
957
|
+
|
|
958
|
+
# ── S1 rels 를 `<q:Relationship xmlns:q=…>` + 홑따옴표로 다시 쓴 뒤 대상을 끊는다 ──
|
|
959
|
+
rels_part = 'ppt/slides/_rels/slide1.xml.rels'
|
|
960
|
+
def _requote(d, break_target):
|
|
961
|
+
x = d.decode('utf-8')
|
|
962
|
+
x = re.sub(r'<Relationship\b', '<q:Relationship xmlns:q="http://schemas.openxmlformats.org/package/2006/relationships"', x)
|
|
963
|
+
x = re.sub(r'\b(Id|Type|Target)="([^"]*)"', lambda m: "%s='%s'" % (m.group(1), m.group(2)), x)
|
|
964
|
+
if break_target:
|
|
965
|
+
x = x.replace("Target='../slideLayouts/", "Target='../slideLayouts/NOPE_", 1)
|
|
966
|
+
return x.encode('utf-8')
|
|
967
|
+
ok_p = os.path.join(fx_dir, 'r7_s1_ok.pptx'); rewrite(src, ok_p, rels_part, lambda d: _requote(d, False))
|
|
968
|
+
bad_p = os.path.join(fx_dir, 'r7_s1_bad.pptx'); rewrite(src, bad_p, rels_part, lambda d: _requote(d, True))
|
|
969
|
+
rc_ok, out_ok = run(ok_p); rc_bad, out_bad = run(bad_p)
|
|
970
|
+
ok('S1 q:Relationship+홑따옴표 — 멀쩡하면 통과(rc=0) · 대상 끊기면 거부(끊긴 관계)') \
|
|
971
|
+
if rc_ok == 0 and rc_bad == 1 and '끊긴 관계' in out_bad else ng('S1 ok=%s bad=%s\n%s\n%s' % (rc_ok, rc_bad, out_ok[-300:], out_bad[-300:]))
|
|
972
|
+
|
|
973
|
+
# ── S2 슬라이드 XML 이 r:id="rId999" 를 참조하는데 rels 에 없다 ──
|
|
974
|
+
slide_part = 'ppt/slides/slide1.xml'
|
|
975
|
+
def _hlink(rid):
|
|
976
|
+
def f(d):
|
|
977
|
+
x = d.decode('utf-8')
|
|
978
|
+
x = re.sub(r'<p:cNvPr ([^>]*?)/>', r'<p:cNvPr \1><a:hlinkClick r:id="%s"/></p:cNvPr>' % rid, x, count=1)
|
|
979
|
+
return x.encode('utf-8')
|
|
980
|
+
return f
|
|
981
|
+
existing = re.search(r'Id="(rId\d+)"', zipfile.ZipFile(src).read(rels_part).decode()).group(1)
|
|
982
|
+
ok_p = os.path.join(fx_dir, 'r7_s2_ok.pptx'); rewrite(src, ok_p, slide_part, _hlink(existing))
|
|
983
|
+
bad_p = os.path.join(fx_dir, 'r7_s2_bad.pptx'); rewrite(src, bad_p, slide_part, _hlink('rId999'))
|
|
984
|
+
rc_ok, out_ok = run(ok_p); rc_bad, out_bad = run(bad_p)
|
|
985
|
+
ok('S2 r:id 참조 — 실재 rId 통과 · rId999 거부(끊긴 참조)') \
|
|
986
|
+
if rc_ok == 0 and rc_bad == 1 and '끊긴 참조' in out_bad else ng('S2 ok=%s bad=%s\n%s\n%s' % (rc_ok, rc_bad, out_ok[-300:], out_bad[-300:]))
|
|
987
|
+
|
|
988
|
+
# ── S3 잘린 JPEG — verify() 는 통과시키고 load() 만 죽는 형태 ──
|
|
989
|
+
try:
|
|
990
|
+
from PIL import Image
|
|
991
|
+
from pptx import Presentation
|
|
992
|
+
from pptx.util import Inches
|
|
993
|
+
except ImportError as e:
|
|
994
|
+
sk('S3 PIL/python-pptx 부재: %s' % e)
|
|
995
|
+
else:
|
|
996
|
+
jpg = os.path.join(fx_dir, 'r7.jpg')
|
|
997
|
+
Image.new('RGB', (256, 256), (200, 30, 30)).save(jpg, 'JPEG', quality=95)
|
|
998
|
+
prs = Presentation(src); prs.slides[0].shapes.add_picture(jpg, Inches(1), Inches(1)); pic_p = os.path.join(fx_dir, 'r7_s3_ok.pptx'); prs.save(pic_p)
|
|
999
|
+
media = [n for n in zipfile.ZipFile(pic_p).namelist() if n.startswith('ppt/media/')]
|
|
1000
|
+
assert len(media) == 1, media
|
|
1001
|
+
bad_p = os.path.join(fx_dir, 'r7_s3_bad.pptx'); rewrite(pic_p, bad_p, media[0], lambda d: d[: len(d) // 2])
|
|
1002
|
+
with Image.open(io.BytesIO(zipfile.ZipFile(bad_p).read(media[0]))) as im:
|
|
1003
|
+
try:
|
|
1004
|
+
im.verify(); v_ok = True
|
|
1005
|
+
except Exception:
|
|
1006
|
+
v_ok = False
|
|
1007
|
+
rc_ok, out_ok = run(pic_p); rc_bad, out_bad = run(bad_p)
|
|
1008
|
+
ok('S3 잘린 JPEG — verify() 는 통과(%s, 컨트롤: 이 레인이 재는 구멍이 실재) · 설치는 거부 · 온전한 그림은 통과' % v_ok) \
|
|
1009
|
+
if v_ok and rc_ok == 0 and rc_bad == 1 else ng('S3 verify=%s ok=%s bad=%s\n%s' % (v_ok, rc_ok, rc_bad, out_bad[-400:]))
|
|
1010
|
+
|
|
1011
|
+
# ── A4 그룹 flipH 가 자식 «자리» 를 반사한다 — 단일 · 중첩 · rot 잔여 ──
|
|
1012
|
+
def _grp(inner, flip='', off=(0, 0), ext=(1000, 1000), choff=(0, 0), chext=(1000, 1000), rot=None):
|
|
1013
|
+
xf = '<a:xfrm%s%s><a:off x="%d" y="%d"/><a:ext cx="%d" cy="%d"/><a:chOff x="%d" y="%d"/><a:chExt cx="%d" cy="%d"/></a:xfrm>' % (
|
|
1014
|
+
(' flipH="1"' if flip == 'H' else ''), (' rot="%d"' % rot if rot else ''), off[0], off[1], ext[0], ext[1], choff[0], choff[1], chext[0], chext[1])
|
|
1015
|
+
return '<p:grpSp><p:nvGrpSpPr><p:cNvPr id="9" name="g"/></p:nvGrpSpPr><p:grpSpPr>%s</p:grpSpPr>%s</p:grpSp>' % (xf, inner)
|
|
1016
|
+
ch = _sp('c', 0, 0, 100, 100, 'x')
|
|
1017
|
+
d = os.path.join(fx_dir, 'r7_flip.pptx')
|
|
1018
|
+
_mini_deck(d, [_grp(ch, flip='H'), # 단일: x 0 → 900
|
|
1019
|
+
_grp(_grp(ch, ext=(500, 500), chext=(500, 500)), flip='H'), # 중첩(바깥 flip): 안쪽 상자 500~1000, 내부도 거울 → 900
|
|
1020
|
+
_grp(ch, rot=5400000), # rot 잔여
|
|
1021
|
+
_grp(ch)]) # 컨트롤: 0
|
|
1022
|
+
z = zipfile.ZipFile(d); order = oox.slide_order(z) # _mini_deck 은 파일 번호를 거꾸로 매긴다 — 발표 순서로 읽는다
|
|
1023
|
+
xs = [[s_['x'] for s_ in oox.walk_slide(z, fn)] for fn in order]
|
|
1024
|
+
rot_flag = oox.walk_slide(z, order[2])[0].get('rot_unmeasured'); rot_ctrl = oox.walk_slide(z, order[3])[0].get('rot_unmeasured')
|
|
1025
|
+
ok('A4 flipH 반사 — 단일 %s · 중첩 %s · 컨트롤 %s · rot 잔여 표시 %s/%s' % (xs[0], xs[1], xs[3], rot_flag, rot_ctrl)) \
|
|
1026
|
+
if xs[0] == [900] and xs[1] == [900] and xs[3] == [0] and rot_flag and not rot_ctrl \
|
|
1027
|
+
else ng('A4 xs=%r rot=%s/%s' % (xs, rot_flag, rot_ctrl))
|
|
1028
|
+
|
|
1029
|
+
# ── A5 mc:AlternateContent — Choice 안의 도형을 세고, Choice 없으면 Fallback ──
|
|
1030
|
+
alt = ('<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">'
|
|
1031
|
+
'<mc:Choice Requires="a">%s</mc:Choice><mc:Fallback>%s</mc:Fallback></mc:AlternateContent>') # R9 A7a: Requires 는 «아는» 접두여야 Choice 가 선택된다
|
|
1032
|
+
d = os.path.join(fx_dir, 'r7_alt.pptx')
|
|
1033
|
+
_mini_deck(d, [alt % (_sp('choice', 0, 0, 100, 100, 'a'), _sp('fallback', 0, 0, 100, 100, 'b')),
|
|
1034
|
+
alt.replace('<mc:Choice Requires="a">%s</mc:Choice>', '') % (_sp('fallback', 0, 0, 100, 100, 'b'),)])
|
|
1035
|
+
z = zipfile.ZipFile(d); order = oox.slide_order(z)
|
|
1036
|
+
n1 = [s_['name'] for s_ in oox.walk_slide(z, order[0])]; n2 = [s_['name'] for s_ in oox.walk_slide(z, order[1])]
|
|
1037
|
+
ok('A5 AlternateContent — Choice 도형 %s · Fallback 만이면 %s' % (n1, n2)) if n1 == ['choice'] and n2 == ['fallback'] else ng('A5 %r %r' % (n1, n2))
|
|
1038
|
+
|
|
1039
|
+
# ── A6 공백 «런» 의 크기 변화가 숨지 않는다 — B7(공백 길이만 다름=0)은 유지 ──
|
|
1040
|
+
def _runs(name, x, runs):
|
|
1041
|
+
body = ''.join('<a:r><a:rPr sz="%d"/><a:t>%s</a:t></a:r>' % (sz, t) for t, sz in runs)
|
|
1042
|
+
return ('<p:sp><p:nvSpPr><p:cNvPr id="1" name="%s"/></p:nvSpPr><p:spPr><a:xfrm><a:off x="%d" y="914400"/>'
|
|
1043
|
+
'<a:ext cx="3657600" cy="914400"/></a:xfrm></p:spPr><p:txBody><a:p>%s</a:p></p:txBody></p:sp>' % (name, x, body))
|
|
1044
|
+
d = os.path.join(fx_dir, 'r7_ws_size.pptx')
|
|
1045
|
+
_mini_deck(d, [_runs('L', 914400, [('abc', 2800), (' ', 3200), ('def', 2800)]) + _runs('R', 7620000, [('abc def', 2800)]),
|
|
1046
|
+
_runs('L', 914400, [('A B', 2800)]) + _runs('R', 7620000, [('A B', 2800)])])
|
|
1047
|
+
S, sw = LA.load(d)
|
|
1048
|
+
m = list(LA.ax_mirror(S, sw, {}))
|
|
1049
|
+
# 🟥 R8 A4 로 뒤집음: 공백은 크기를 갖지 않는다(앞 글자 상속) — 32pt 공백 «런» 은 이 레인이 못 본다(잔여, 이름으로).
|
|
1050
|
+
# R7 의 기대값((3,28),(1,32),(3,28) · 후보 1) 은 실물 런 분할에서 오탐을 냈다(Opus R8 A4 실행 프로브).
|
|
1051
|
+
ok('A6(R8 역전) 32pt 공백 런 → 상속 (7,28) · 후보 0 · 공백 길이만 다름 → 0 · spans %s' % (S[0][0]['spans'],)) \
|
|
1052
|
+
if len(m) == 0 and S[0][0]["spans"] == ((7, 28.0),) and S[1][0]['spans'] == S[1][1]['spans'] \
|
|
1053
|
+
else ng('A6 m=%r spans=%r/%r' % (m, S[0][0]['spans'], S[1][0]['spans']))
|
|
1054
|
+
|
|
1055
|
+
# ── A7 xfrm 없는 자리표시자와 같은 이름 → 살아남은 도형이 dup ──
|
|
1056
|
+
ph = '<p:sp><p:nvSpPr><p:cNvPr id="2" name="A"/><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:p><a:r><a:t>t</a:t></a:r></a:p></p:txBody></p:sp>'
|
|
1057
|
+
d = os.path.join(fx_dir, 'r7_ph_dup.pptx')
|
|
1058
|
+
_mini_deck(d, [ph + _sp('A', 914400, 914400, 100, 100, 'x'), _sp('A', 914400, 914400, 100, 100, 'x')])
|
|
1059
|
+
S, _ = LA.load(d)
|
|
1060
|
+
ok('A7 자리표시자(xfrm 없음)와 겹치는 이름 → dup=%s · 홀로면 %s' % (S[0][0]['dup'], S[1][0]['dup'])) \
|
|
1061
|
+
if len(S[0]) == 1 and S[0][0]['dup'] and not S[1][0]['dup'] else ng('A7 %r' % (S,))
|
|
1062
|
+
|
|
1063
|
+
# ── B8 `xmlns:p = "…"` (= 양옆 공백) 도 선언이다 — 주입이 중복 속성을 만들면 안 된다 ──
|
|
1064
|
+
d = os.path.join(fx_dir, 'r7_xmlns_ws.pptx'); _mini_deck(d, [_sp('a', 0, 0, 100, 100, 'x')])
|
|
1065
|
+
d2 = os.path.join(fx_dir, 'r7_xmlns_ws2.pptx')
|
|
1066
|
+
rewrite(d, d2, 'ppt/slides/slide1.xml', lambda b: b.replace(b'xmlns:p="p"', b'xmlns:p = "p"', 1))
|
|
1067
|
+
try:
|
|
1068
|
+
n8 = len(oox.walk_slide(zipfile.ZipFile(d2), 1)); err = None
|
|
1069
|
+
except Exception as e:
|
|
1070
|
+
n8, err = None, e
|
|
1071
|
+
ok('B8 xmlns:p = "…" 공백 선언 → 파싱 1 도형') if n8 == 1 else ng('B8 n=%r err=%r' % (n8, err))
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def run_r8_regressions(fx_dir):
|
|
1075
|
+
"""R8 (Opus 팔, 2026-09-12, commit 4f738ad) — 1S·4A·7B 각각 known-pair."""
|
|
1076
|
+
print('\n[2-e] R8 Opus 감사 회귀 — S1 사후검증 실패 rc=3+복원 · A2 분류 실패≠결함 · A3 intended 원소형 · A4 공백/br 상속 · A5 flip 면제 · B6 빈 Choice · B7 절대 Target · B8 ppt/ 밖 r:id · B9 attrs 문자열 · B10 주석 뒤 루트 · B11 배율 미정의 · B12 제목 줄 id')
|
|
1077
|
+
import subprocess, shutil, zipfile, re, io, os as _os
|
|
1078
|
+
import lane_geometry as LG
|
|
1079
|
+
import lane_attr_consistency as LA
|
|
1080
|
+
import lane_screen_parity as LS
|
|
1081
|
+
import oox
|
|
1082
|
+
import safe_install as SI
|
|
1083
|
+
here = os.path.join(HERE, 'safe_install.py')
|
|
1084
|
+
src = os.path.join(fx_dir, 'p1.pptx')
|
|
1085
|
+
|
|
1086
|
+
def rewrite(src_p, dst_p, part, fn, add=None):
|
|
1087
|
+
zi = zipfile.ZipFile(src_p); zo = zipfile.ZipFile(dst_p, 'w', zipfile.ZIP_DEFLATED)
|
|
1088
|
+
for n in zi.namelist():
|
|
1089
|
+
d = zi.read(n)
|
|
1090
|
+
if n == part:
|
|
1091
|
+
d = fn(d)
|
|
1092
|
+
zo.writestr(n, d)
|
|
1093
|
+
for n, d in (add or {}).items():
|
|
1094
|
+
zo.writestr(n, d)
|
|
1095
|
+
zo.close()
|
|
1096
|
+
|
|
1097
|
+
def run(cand, dest, *extra):
|
|
1098
|
+
r = subprocess.run([sys.executable, here, cand, dest, *extra], capture_output=True, text=True)
|
|
1099
|
+
return r.returncode, r.stdout + r.stderr
|
|
1100
|
+
|
|
1101
|
+
# ── S1: os.replace 직후 다른 손이 정본을 건드리면 → rc=3 + 이전 정본 복원 ──
|
|
1102
|
+
dest = os.path.join(fx_dir, 'r8_s1_dest.pptx'); shutil.copyfile(src, dest)
|
|
1103
|
+
prev = open(dest, 'rb').read()
|
|
1104
|
+
small = os.path.join(fx_dir, 'si_small.pptx') # [2-b] 가 만든 3장 축소본
|
|
1105
|
+
real_replace = _os.replace
|
|
1106
|
+
hit = {'n': 0}
|
|
1107
|
+
def evil_replace(a, b):
|
|
1108
|
+
real_replace(a, b)
|
|
1109
|
+
if b == dest and hit['n'] == 0: # 첫 교체(설치) 직후 한 번만 — 복원 교체는 건드리지 않는다
|
|
1110
|
+
hit['n'] += 1
|
|
1111
|
+
with open(b, 'ab') as g:
|
|
1112
|
+
g.write(b'corrupt')
|
|
1113
|
+
_os.replace = evil_replace
|
|
1114
|
+
try:
|
|
1115
|
+
import contextlib
|
|
1116
|
+
buf = io.StringIO()
|
|
1117
|
+
with contextlib.redirect_stdout(buf):
|
|
1118
|
+
rc = SI.main(['safe_install', small, dest, '--allow-shrink', '--why', 'test'])
|
|
1119
|
+
finally:
|
|
1120
|
+
_os.replace = real_replace
|
|
1121
|
+
after = open(dest, 'rb').read()
|
|
1122
|
+
ok('S1 사후검증 실패 → rc=%s(3) · 이전 정본 복원 %s' % (rc, after == prev)) if rc == 3 and after == prev \
|
|
1123
|
+
else ng('S1 rc=%r restored=%r\n%s' % (rc, after == prev, buf.getvalue()[-300:]))
|
|
1124
|
+
shutil.copyfile(src, dest)
|
|
1125
|
+
rc, out = run(small, dest, '--allow-shrink', '--why', 'ctrl')
|
|
1126
|
+
ok('S1 컨트롤 — 정상 축소 반영 rc=0') if rc == 0 else ng('S1 ctrl rc=%s\n%s' % (rc, out[-200:]))
|
|
1127
|
+
|
|
1128
|
+
# ── A2: prstGeom 없는 합법 p:sp (python-pptx shape_type NotImplementedError) 는 결함이 아니다 ──
|
|
1129
|
+
noprst = os.path.join(fx_dir, 'r8_noprst.pptx')
|
|
1130
|
+
def _strip_prst(d):
|
|
1131
|
+
x = d.decode('utf-8')
|
|
1132
|
+
x2 = re.sub(r'<a:prstGeom\b.*?</a:prstGeom>', '', x, count=1, flags=re.S)
|
|
1133
|
+
x2 = x2.replace(' txBox="1"', '', 1) # 자리표시자도 글상자도 아닌 «맨» p:sp — python-pptx 가 분류 못 하는 조합
|
|
1134
|
+
assert x2 != x, 'fixture: prstGeom 없음'
|
|
1135
|
+
return x2.encode('utf-8')
|
|
1136
|
+
rewrite(src, noprst, 'ppt/slides/slide1.xml', _strip_prst)
|
|
1137
|
+
try:
|
|
1138
|
+
from pptx import Presentation as _P
|
|
1139
|
+
sh0 = [s_ for s_ in _P(noprst).slides[0].shapes][0]
|
|
1140
|
+
try:
|
|
1141
|
+
sh0.shape_type; nie = False
|
|
1142
|
+
except NotImplementedError:
|
|
1143
|
+
nie = True
|
|
1144
|
+
except Exception as e:
|
|
1145
|
+
nie = None
|
|
1146
|
+
dest2 = os.path.join(fx_dir, 'r8_a2_dest.pptx'); shutil.copyfile(noprst, dest2)
|
|
1147
|
+
rc_c, out_c = run(noprst, dest2, '--dry-run') # 후보가 그 도형을 품음
|
|
1148
|
+
rc_d, out_d = run(src, dest2, '--dry-run') # 정본이 그 도형을 품음 — 교착이던 자리
|
|
1149
|
+
ok('A2 prstGeom 없는 도형(python-pptx NotImplementedError=%s) — 후보 rc=%s · 정본 rc=%s (둘 다 0)' % (nie, rc_c, rc_d)) \
|
|
1150
|
+
if nie and rc_c == 0 and rc_d == 0 else ng('A2 nie=%r cand=%s dest=%s\n%s' % (nie, rc_c, rc_d, (out_c + out_d)[-300:]))
|
|
1151
|
+
|
|
1152
|
+
# ── A3: intended shapes 원소가 섞이면 «그 항목만» 오류, 레인은 산다 ──
|
|
1153
|
+
idx3, errs3 = LA._intent_index([{'axis': 'dash', 'slides': [1], 'shapes': ['A', 1], 'why': 'r'},
|
|
1154
|
+
{'axis': 'dash', 'slides': [1], 'shapes': ['B'], 'why': 'r'}])
|
|
1155
|
+
ok('A3 shapes=["A",1] → 오류 1 · 정상 항목은 색인됨 %s' % (dict(idx3),)) \
|
|
1156
|
+
if len(errs3) == 1 and ('dash', 1) in idx3 and len(idx3[('dash', 1)]) == 1 else ng('A3 errs=%r idx=%r' % (errs3, dict(idx3)))
|
|
1157
|
+
|
|
1158
|
+
# ── A4: 런 경계 공백 · <a:br/> 는 크기 차이가 아니다 ──
|
|
1159
|
+
def _runs(name, x, runs):
|
|
1160
|
+
body = ''.join(('<a:br/>' if t == '\n' else '<a:r><a:rPr sz="%d"/><a:t>%s</a:t></a:r>' % (sz, t)) for t, sz in runs)
|
|
1161
|
+
return ('<p:sp><p:nvSpPr><p:cNvPr id="1" name="%s"/></p:nvSpPr><p:spPr><a:xfrm><a:off x="%d" y="914400"/>'
|
|
1162
|
+
'<a:ext cx="3657600" cy="914400"/></a:xfrm></p:spPr><p:txBody><a:p>%s</a:p></p:txBody></p:sp>' % (name, x, body))
|
|
1163
|
+
d = os.path.join(fx_dir, 'r8_ws.pptx')
|
|
1164
|
+
_mini_deck(d, [_runs('T', 914400, [('abc ', 2800), ('def gh', 3200)]), _runs('T', 914400, [('abc', 2800), (' def gh', 3200)]),
|
|
1165
|
+
_runs('T', 914400, [('abc', 2800), ('\n', None), ('def', 2800)]), _runs('T', 914400, [('abc def', 2800)]),
|
|
1166
|
+
_runs('T', 914400, [('abc ', 2800), ('def gh', 3600)])]) # 컨트롤: 같은 글자, 진짜 크기 갈림(32→36)
|
|
1167
|
+
S, sw = LA.load(d)
|
|
1168
|
+
e_all = list(LA.ax_echo(S, sw, {}))
|
|
1169
|
+
sp = [S[i][0]['spans'] for i in range(5)]
|
|
1170
|
+
ok('A4 경계 공백 %s=%s · br %s=%s · 컨트롤(진짜 갈림) echo 후보 %d' % (sp[0], sp[1], sp[2], sp[3], len(e_all))) \
|
|
1171
|
+
if sp[0] == sp[1] == ((4, 28.0), (6, 32.0)) and sp[2] == sp[3] == ((7, 28.0),) and sp[4] == ((4, 28.0), (6, 36.0)) and len(e_all) >= 1 and not any('None' in str(l) for l in e_all) \
|
|
1172
|
+
else ng('A4 spans=%r echo=%r' % (sp, e_all))
|
|
1173
|
+
|
|
1174
|
+
# ── A5: 뒤집힘을 attrs:[flip] 로 면제 · 선언 없으면 후보 · 다른 속성만 선언하면 안 맞음 ──
|
|
1175
|
+
Sf = [{'s1': (0, 0, 100, 100, 't', '', None)}, {'s1': (0, 0, 100, 100, 't', 'H', None)}]
|
|
1176
|
+
l_none, _st, sup_none, err_none = LG.p1_lines(Sf, 200000)
|
|
1177
|
+
l_dec, _st, sup_dec, err_dec = LG.p1_lines(Sf, 200000, [{'slides': [1, 2], 'shapes': ['s1'], 'attrs': ['flip'], 'why': '반전 연출'}])
|
|
1178
|
+
l_wrong, _st, sup_w, err_w = LG.p1_lines(Sf, 200000, [{'slides': [1, 2], 'shapes': ['s1'], 'attrs': ['x'], 'why': '엉뚱'}])
|
|
1179
|
+
ok('A5 flip 면제 — 선언 없음 후보 %d · [flip] 선언 후보 %d/면제 %d/오류 %d · [x] 선언 후보 %d/죽은 선언 %d' % (len(l_none), len(l_dec), len(sup_dec), len(err_dec), len(l_wrong), len(err_w))) \
|
|
1180
|
+
if len(l_none) == 1 and len(l_dec) == 0 and len(sup_dec) == 1 and not err_dec and len(l_wrong) == 1 and len(err_w) == 1 \
|
|
1181
|
+
else ng('A5 none=%r dec=%r/%r/%r wrong=%r/%r' % (l_none, l_dec, sup_dec, err_dec, l_wrong, err_w))
|
|
1182
|
+
|
|
1183
|
+
# ── B6: 빈 <mc:Choice/> 는 Fallback 이 아니다 ──
|
|
1184
|
+
alt_empty = ('<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">'
|
|
1185
|
+
'<mc:Choice Requires="a"/><mc:Fallback>%s</mc:Fallback></mc:AlternateContent>') # R9: 아는 접두 + 빈 Choice = 아무것도 안 그림
|
|
1186
|
+
d = os.path.join(fx_dir, 'r8_alt_empty.pptx'); _mini_deck(d, [alt_empty % _sp('FB', 0, 0, 100, 100, 'b')])
|
|
1187
|
+
n6 = [s_['name'] for s_ in oox.walk_slide(zipfile.ZipFile(d), 1)]
|
|
1188
|
+
ok('B6 빈 Choice → 도형 %s (Fallback 안 걸음)' % (n6,)) if n6 == [] else ng('B6 %r' % (n6,))
|
|
1189
|
+
|
|
1190
|
+
# ── B7: 절대 Target(/ppt/slides/slide1.xml) — slide_order · safe_install 둘 다 ──
|
|
1191
|
+
absr = os.path.join(fx_dir, 'r8_abs.pptx')
|
|
1192
|
+
rewrite(src, absr, 'ppt/_rels/presentation.xml.rels', lambda b: b.replace(b'Target="slides/', b'Target="/ppt/slides/'))
|
|
1193
|
+
assert b'/ppt/slides/' in zipfile.ZipFile(absr).read('ppt/_rels/presentation.xml.rels')
|
|
1194
|
+
try:
|
|
1195
|
+
n7 = len(oox.slide_order(zipfile.ZipFile(absr))); e7 = None
|
|
1196
|
+
except Exception as e:
|
|
1197
|
+
n7, e7 = None, e
|
|
1198
|
+
dest7 = os.path.join(fx_dir, 'r8_b7_dest.pptx'); shutil.copyfile(src, dest7)
|
|
1199
|
+
rc7, out7 = run(absr, dest7, '--dry-run')
|
|
1200
|
+
ok('B7 절대 Target — slide_order %s장 · safe_install rc=%s' % (n7, rc7)) if n7 == 4 and rc7 == 0 else ng('B7 n=%r err=%r rc=%s\n%s' % (n7, e7, rc7, out7[-200:]))
|
|
1201
|
+
|
|
1202
|
+
# ── B8: ppt/ 밖 부품의 끊긴 r:id 도 거부 ──
|
|
1203
|
+
cust = ('<?xml version="1.0"?><Properties xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
|
1204
|
+
'<x r:id="rIdNOPE"/></Properties>').encode()
|
|
1205
|
+
outside = os.path.join(fx_dir, 'r8_outside.pptx'); rewrite(src, outside, None, None, add={'docProps/custom.xml': cust})
|
|
1206
|
+
dest8 = os.path.join(fx_dir, 'r8_b8_dest.pptx'); shutil.copyfile(src, dest8)
|
|
1207
|
+
rc8, out8 = run(outside, dest8, '--dry-run')
|
|
1208
|
+
ok('B8 docProps/custom.xml 의 끊긴 r:id → 거부 rc=%s' % rc8) if rc8 == 1 and '끊긴 참조' in out8 else ng('B8 rc=%s\n%s' % (rc8, out8[-200:]))
|
|
1209
|
+
|
|
1210
|
+
# ── B9: attrs 문자열 → 오류 ──
|
|
1211
|
+
idx9, err9 = LG._intent_index([{'slides': [1, 2], 'shapes': ['s1'], 'attrs': 'xy', 'why': '연출'}])
|
|
1212
|
+
ok('B9 attrs="xy" 문자열 → 오류 1 · 색인 0') if len(err9) == 1 and not idx9 else ng('B9 idx=%r err=%r' % (dict(idx9), err9))
|
|
1213
|
+
|
|
1214
|
+
# ── B10: 루트 앞 주석 안의 태그 ──
|
|
1215
|
+
try:
|
|
1216
|
+
r10 = oox.parse_xml('<!-- <p:ghost/> --><p:sld xmlns:q="Q"><p:x/></p:sld>'); ok10 = oox.local(r10.tag) == 'sld'
|
|
1217
|
+
except Exception as e:
|
|
1218
|
+
ok10 = e
|
|
1219
|
+
ok('B10 주석 뒤 루트 파싱') if ok10 is True else ng('B10 %r' % (ok10,))
|
|
1220
|
+
|
|
1221
|
+
# ── B11: ext 없는 flip 그룹 → geo_unmeasured · geometry 통계 unmeasured ──
|
|
1222
|
+
xf_noext = '<a:xfrm flipH="1"><a:off x="0" y="0"/><a:chOff x="0" y="0"/><a:chExt cx="1000" cy="1000"/></a:xfrm>'
|
|
1223
|
+
grp = '<p:grpSp><p:nvGrpSpPr><p:cNvPr id="9" name="g"/></p:nvGrpSpPr><p:grpSpPr>%s</p:grpSpPr>%s</p:grpSp>' % (xf_noext, _sp('c', 100, 0, 50, 50, 'x'))
|
|
1224
|
+
d = os.path.join(fx_dir, 'r8_noext.pptx'); _mini_deck(d, [grp, grp])
|
|
1225
|
+
w11 = oox.walk_slide(zipfile.ZipFile(d), 1)
|
|
1226
|
+
_l, st11, _s, _e = LG.p1_lines(LG.load_slides(d), 200000)
|
|
1227
|
+
ok('B11 ext 없는 그룹 → geo_unmeasured=%s · geometry stats unmeasured=%s (bound 0)' % (w11[0].get('geo_unmeasured'), st11.get('unmeasured'))) \
|
|
1228
|
+
if w11 and w11[0].get('geo_unmeasured') is True and st11.get('unmeasured') == 1 and not st11.get('bound') else ng('B11 w=%r st=%r' % (w11, st11))
|
|
1229
|
+
|
|
1230
|
+
# ── B12: '·' 없는 제목 — id 는 제목 줄 한 줄 ──
|
|
1231
|
+
man = os.path.join(fx_dir, 'r8_man.md')
|
|
1232
|
+
open(man, 'w', encoding='utf-8').write('### u1 · 첫 단위\n🖥\n한 줄\n🗣 말\n### u2 제목에 점 없음\n🖥\n두 줄\n🗣 말\n')
|
|
1233
|
+
units = LS.manuscript_screens(man)
|
|
1234
|
+
ok('B12 단위 id %s (한 줄, 블록 전체 아님)' % ([u for u, _ in units],)) if len(units) == 2 and '\n' not in units[1][0] and units[1][0].startswith('u2') \
|
|
1235
|
+
else ng('B12 %r' % (units,))
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def run_r9_regressions(fx_dir):
|
|
1239
|
+
"""R9 (codex R8 팔, 2026-09-12, commit 4f738ad) — 6S·3A 중 미수리분 각각 known-pair(S4·S6·A7b 는 R8 이 이미 닫음)."""
|
|
1240
|
+
print('\n[2-f] R9 codex 감사 회귀 — S1 자리 밖 .rels · S2 External 이 embed 만족 · S3 빈 r:embed · S5 깨진 범용 XML 부품 · A7a Requires 미지원 Choice · A8 _paras 선택 분기 · A9 geometry 자리표시자 중복')
|
|
1241
|
+
import subprocess, shutil, zipfile, re, io
|
|
1242
|
+
import lane_geometry as LG
|
|
1243
|
+
import oox
|
|
1244
|
+
here = os.path.join(HERE, 'safe_install.py')
|
|
1245
|
+
src = os.path.join(fx_dir, 'p1.pptx')
|
|
1246
|
+
|
|
1247
|
+
def rewrite(src_p, dst_p, fn_map, add=None, drop=()):
|
|
1248
|
+
zi = zipfile.ZipFile(src_p); zo = zipfile.ZipFile(dst_p, 'w', zipfile.ZIP_DEFLATED)
|
|
1249
|
+
for n in zi.namelist():
|
|
1250
|
+
if n in drop:
|
|
1251
|
+
continue
|
|
1252
|
+
d = zi.read(n)
|
|
1253
|
+
if n in fn_map:
|
|
1254
|
+
d = fn_map[n](d)
|
|
1255
|
+
zo.writestr(n, d)
|
|
1256
|
+
for n, d in (add or {}).items():
|
|
1257
|
+
zo.writestr(n, d)
|
|
1258
|
+
zo.close()
|
|
1259
|
+
|
|
1260
|
+
def run(cand):
|
|
1261
|
+
dest = os.path.join(fx_dir, 'r9_dest.pptx'); shutil.copyfile(src, dest)
|
|
1262
|
+
r = subprocess.run([sys.executable, here, cand, dest, '--dry-run'], capture_output=True, text=True)
|
|
1263
|
+
return r.returncode, r.stdout + r.stderr
|
|
1264
|
+
|
|
1265
|
+
# 그림 한 장 든 덱(R7 S3 픽스처 재사용) — embed 참조가 실재하는 실물
|
|
1266
|
+
pic = os.path.join(fx_dir, 'r7_s3_ok.pptx')
|
|
1267
|
+
if not os.path.exists(pic):
|
|
1268
|
+
return sk('R9 그림 덱 픽스처(r7_s3_ok.pptx) 없음')
|
|
1269
|
+
rels = 'ppt/slides/_rels/slide1.xml.rels'; slide = 'ppt/slides/slide1.xml'
|
|
1270
|
+
rtxt = zipfile.ZipFile(pic).read(rels).decode()
|
|
1271
|
+
rid = re.search(r'Id="(rId\d+)"[^>]*image', rtxt) or re.search(r'image[^>]*Id="(rId\d+)"', rtxt)
|
|
1272
|
+
rid = rid.group(1) if rid else None
|
|
1273
|
+
stxt = zipfile.ZipFile(pic).read(slide).decode(); assert rid and ('r:embed="%s"' % rid) in stxt, (rid, rtxt[:300])
|
|
1274
|
+
|
|
1275
|
+
# S1 자리 밖 .rels 가 참조를 만족시키면 안 된다
|
|
1276
|
+
d = os.path.join(fx_dir, 'r9_s1.pptx')
|
|
1277
|
+
rewrite(pic, d, {}, add={'ppt/slides/not_rels/slide1.xml.rels': zipfile.ZipFile(pic).read(rels)}, drop=(rels,))
|
|
1278
|
+
rc1, o1 = run(d)
|
|
1279
|
+
ok('S1 자리 밖 .rels(not_rels/) → 거부 rc=%s' % rc1) if rc1 == 1 and '_rels/ 디렉터리 밖' in o1 else ng('S1 rc=%s\n%s' % (rc1, o1[-300:]))
|
|
1280
|
+
# S2 External 관계가 embed 를 만족시키면 안 된다 (컨트롤: r:link 는 External 이라도 통과)
|
|
1281
|
+
d = os.path.join(fx_dir, 'r9_s2.pptx')
|
|
1282
|
+
rewrite(pic, d, {rels: lambda b: re.sub(r'(<Relationship\b[^>]*Id="%s"[^>]*?)(/?>)' % rid, lambda m: m.group(1).replace('Target="', 'TargetMode="External" Target="https://example.invalid/') + m.group(2), b.decode()).encode()})
|
|
1283
|
+
assert 'TargetMode="External"' in zipfile.ZipFile(d).read(rels).decode()
|
|
1284
|
+
rc2, o2 = run(d)
|
|
1285
|
+
d2 = os.path.join(fx_dir, 'r9_s2_ctrl.pptx')
|
|
1286
|
+
rewrite(pic, d2, {rels: lambda b: b.replace(b'</Relationships>', b'<Relationship Id="rIdX" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.invalid/" TargetMode="External"/></Relationships>'),
|
|
1287
|
+
slide: lambda b: re.sub(r'<p:cNvPr ([^>]*?)/>', r'<p:cNvPr \1><a:hlinkClick r:id="rIdX"/></p:cNvPr>', b.decode(), count=1).encode()})
|
|
1288
|
+
rc2c, o2c = run(d2)
|
|
1289
|
+
ok('S2 External 관계 → embed 거부 rc=%s · 컨트롤(External hyperlink r:id) 통과 rc=%s' % (rc2, rc2c)) if rc2 == 1 and 'External 관계' in o2 and rc2c == 0 else ng('S2 rc=%s ctrl=%s\n%s\n%s' % (rc2, rc2c, o2[-200:], o2c[-200:]))
|
|
1290
|
+
# S3 빈 r:embed=""
|
|
1291
|
+
d = os.path.join(fx_dir, 'r9_s3.pptx')
|
|
1292
|
+
rewrite(pic, d, {slide: lambda b: b.replace(('r:embed="%s"' % rid).encode(), b'r:embed=""', 1)})
|
|
1293
|
+
rc3, o3 = run(d)
|
|
1294
|
+
ok('S3 빈 r:embed="" → 거부 rc=%s' % rc3) if rc3 == 1 and '끊긴 참조' in o3 else ng('S3 rc=%s\n%s' % (rc3, o3[-300:]))
|
|
1295
|
+
# S5 깨진 범용 XML 부품 (관계로 연결)
|
|
1296
|
+
d = os.path.join(fx_dir, 'r9_s5.pptx')
|
|
1297
|
+
rewrite(src, d, {'ppt/_rels/presentation.xml.rels': lambda b: b.replace(b'</Relationships>', b'<Relationship Id="rIdCUST" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="custom/item1.xml"/></Relationships>'),
|
|
1298
|
+
'[Content_Types].xml': lambda b: b.replace(b'</Types>', b'<Override PartName="/ppt/custom/item1.xml" ContentType="application/xml"/></Types>')},
|
|
1299
|
+
add={'ppt/custom/item1.xml': b'<broken'})
|
|
1300
|
+
rc5, o5 = run(d)
|
|
1301
|
+
ok('S5 깨진 범용 XML 부품 → 거부 rc=%s' % rc5) if rc5 == 1 and 'XML 이 깨졌다' in o5 else ng('S5 rc=%s\n%s' % (rc5, o5[-300:]))
|
|
1302
|
+
# A7a Requires 가 모르는 네임스페이스 → Fallback · 아는 네임스페이스면 빈 Choice 라도 Choice
|
|
1303
|
+
alt = ('<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:f="urn:unknown-ext">'
|
|
1304
|
+
'<mc:Choice Requires="f"><f:shape/></mc:Choice><mc:Fallback>%s</mc:Fallback></mc:AlternateContent>')
|
|
1305
|
+
alt2 = ('<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main">'
|
|
1306
|
+
'<mc:Choice Requires="a14"/><mc:Fallback>%s</mc:Fallback></mc:AlternateContent>')
|
|
1307
|
+
d = os.path.join(fx_dir, 'r9_req.pptx'); _mini_deck(d, [alt % _sp('FB', 0, 0, 100, 100, 'b'), alt2 % _sp('FB2', 0, 0, 100, 100, 'b')])
|
|
1308
|
+
z = zipfile.ZipFile(d); order = oox.slide_order(z)
|
|
1309
|
+
n7a = [s_['name'] for s_ in oox.walk_slide(z, order[0])]; n7b = [s_['name'] for s_ in oox.walk_slide(z, order[1])]
|
|
1310
|
+
ok('A7a Requires 미지원 → Fallback %s · 지원+빈 Choice → %s' % (n7a, n7b)) if n7a == ['FB'] and n7b == [] else ng('A7a %r %r' % (n7a, n7b))
|
|
1311
|
+
# A8 _paras 는 선택 분기만
|
|
1312
|
+
body = ('<p:sp><p:nvSpPr><p:cNvPr id="1" name="T"/></p:nvSpPr><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="100" cy="100"/></a:xfrm></p:spPr><p:txBody>'
|
|
1313
|
+
'<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice Requires="a"><a:p><a:r><a:t>VISIBLE</a:t></a:r></a:p></mc:Choice>'
|
|
1314
|
+
'<mc:Fallback><a:p><a:r><a:t>HIDDEN</a:t></a:r></a:p></mc:Fallback></mc:AlternateContent></p:txBody></p:sp>')
|
|
1315
|
+
d = os.path.join(fx_dir, 'r9_paras.pptx'); _mini_deck(d, [body])
|
|
1316
|
+
tx = oox.para_texts(oox.walk_slide(zipfile.ZipFile(d), 1)[0]['paras'])
|
|
1317
|
+
ok('A8 _paras 선택 분기만 → %s' % (tx,)) if tx == ['VISIBLE'] else ng('A8 %r' % (tx,))
|
|
1318
|
+
# A9 geometry: xfrm 없는 자리표시자와 같은 이름 → 결박에서 제외(모호)
|
|
1319
|
+
ph = '<p:sp><p:nvSpPr><p:cNvPr id="2" name="A"/><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:p><a:r><a:t>t</a:t></a:r></a:p></p:txBody></p:sp>'
|
|
1320
|
+
d = os.path.join(fx_dir, 'r9_geo_dup.pptx')
|
|
1321
|
+
_mini_deck(d, [ph + _sp('A', 0, 0, 100, 100, 'x') + _sp('B', 0, 0, 100, 100, 'y'), ph + _sp('A', 35000, 0, 100, 100, 'x') + _sp('B', 0, 0, 100, 100, 'y')])
|
|
1322
|
+
S9 = LG.load_slides(d)
|
|
1323
|
+
l9, st9, sup9, err9 = LG.p1_lines(S9, 200000, [{'slides': [1, 2], 'shapes': ['A'], 'attrs': ['x'], 'why': '연출'}])
|
|
1324
|
+
ok('A9 자리표시자와 겹치는 A → 결박 제외(bound %s · 면제 %d · 죽은 선언 %d) · B 는 결박' % (st9.get('bound'), len(sup9), len(err9))) \
|
|
1325
|
+
if 'A' not in S9[0] and 'B' in S9[0] and len(sup9) == 0 and len(err9) == 1 else ng('A9 S=%r st=%r sup=%r err=%r' % ([sorted(x) for x in S9], st9, sup9, err9))
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def run_baseline_delta(fx_dir):
|
|
1329
|
+
print('\n[2] --baseline 델타 모드 — 편집 전/후 한 쌍')
|
|
1330
|
+
import lane_geometry as LG
|
|
1331
|
+
new, gone, st_b, st_c, _sup, _err = LG.delta(os.path.join(fx_dir, 'geo_base.pptx'),
|
|
1332
|
+
os.path.join(fx_dir, 'geo_edited.pptx'), 200000, 63500)
|
|
1333
|
+
ok('델타 새 어긋남 정확히 1건') if len(new) == 1 else ng(f'델타 새 어긋남 {len(new)}건 (want 1)')
|
|
1334
|
+
ok('델타 사라진 어긋남 0건(깨끗한 기준본)') if len(gone) == 0 else ng(f'델타 사라진 어긋남 {len(gone)}건 (want 0)')
|
|
1335
|
+
cb, cc = st_b.get('bound', 0), st_c.get('bound', 0)
|
|
1336
|
+
ok(f'기준본 결박 {cb} > 0 · 현재본 결박 {cc} > 0') if cb > 0 and cc > 0 \
|
|
1337
|
+
else ng(f'기준본/현재본 결박 0 있음 — UNMEASURED (bound_b={cb}, bound_c={cc})')
|
|
1338
|
+
# scan(cfg, root) 래퍼로도 동일하게 도는지 확인 (preprep.py 가 실제로 부르는 경로)
|
|
1339
|
+
cfg = {'surfaces_by_id': {'built_deck': {'path': os.path.join(fx_dir, 'geo_edited.pptx')}},
|
|
1340
|
+
'geometry': {'baseline': os.path.join(fx_dir, 'geo_base.pptx')}}
|
|
1341
|
+
fP, nP = LG.scan(cfg, '.')
|
|
1342
|
+
ok('scan(cfg, root) 래퍼가 델타 모드로 도고 새 어긋남 1건을 notes 에 낸다') \
|
|
1343
|
+
if any('새 어긋남 1건' in n for n in nP) else ng(f'scan() 델타 출력 이상 — {nP}')
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
REAL_BASE = os.environ.get('PREPREP_REAL_CORPUS', '') # 기본값 없음 — 다른 머신에서 거짓 SKIP 방지(SKIP != PASS)
|
|
1347
|
+
REAL_TARGETS = [
|
|
1348
|
+
('ifkakao26_slides_v1.3_pre-engine-strip_20260904_210857.pptx', 'R1', 40,
|
|
1349
|
+
'선두 줄이 «그러나»로 뒤집는데'),
|
|
1350
|
+
('ifkakao26_slides_v1.3_pre-40p_20260904_213452.pptx', 'R2', 41,
|
|
1351
|
+
'«이 셋/세 가지»라 부르는데 번호가 없다'),
|
|
1352
|
+
('ifkakao26_slides_v1.3_pre-38trim_20260904_210428.pptx', 'R4', 38,
|
|
1353
|
+
'화면 250자'),
|
|
1354
|
+
('ifkakao26_slides_v1.3_pre-40p_20260904_213452.pptx', 'R5', 112,
|
|
1355
|
+
'29.2자/초'),
|
|
1356
|
+
]
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def run_real_corpus():
|
|
1360
|
+
print('\n[3] 실물 재현 (PREPREP_REAL_CORPUS 백업, 있을 때만) — 원장이 인용한 실사고 4+1건')
|
|
1361
|
+
if not os.path.isdir(REAL_BASE):
|
|
1362
|
+
sk(f'실물 코퍼스 미지정/미존재({REAL_BASE or "PREPREP_REAL_CORPUS unset"}) — SKIP, 통과 아님')
|
|
1363
|
+
return
|
|
1364
|
+
import lane_slide_relations as LSR
|
|
1365
|
+
import lane_geometry as LG
|
|
1366
|
+
for fname, code, want_page, want_sub in REAL_TARGETS:
|
|
1367
|
+
p = os.path.join(REAL_BASE, fname)
|
|
1368
|
+
if not os.path.exists(p):
|
|
1369
|
+
sk(f'{fname} 없음 — 재현 건너뜀')
|
|
1370
|
+
continue
|
|
1371
|
+
deck = LSR.Deck(p)
|
|
1372
|
+
F = LSR.analyze(deck)
|
|
1373
|
+
hit = [x for x in F if x[0] == code and abs(x[1] - want_page) <= 1 and want_sub in x[2]]
|
|
1374
|
+
ok(f'{code} 실물 재현 — {fname} 약 {want_page}p — {hit[0][2][:60] if hit else ""}') \
|
|
1375
|
+
if hit else ng(f'{code} 실물 미재현 — {fname}')
|
|
1376
|
+
|
|
1377
|
+
# P1/P3 36p (보너스 확인 — 원장이 명시적으로 요구한 4건에는 없지만 신호 자체의 원적 사건)
|
|
1378
|
+
align36 = os.path.join(REAL_BASE, 'ifkakao26_slides_v1.3_pre-align36_222442.pptx')
|
|
1379
|
+
if os.path.exists(align36):
|
|
1380
|
+
lines, st, _sp, _er = LG.collect_lines(align36, 200000, 63500)
|
|
1381
|
+
hit_p1 = any('s5017' in l and '35000' in l and '35p' in l.replace(' ', '') for l in lines)
|
|
1382
|
+
hit_p3 = any('s5016' in l and 's5017' in l and '35000' in l for l in lines)
|
|
1383
|
+
# 🟥 결박을 좁힌 뒤에도 **원적 사건이 그대로 잡히는가**. 이 줄이 빨개지면 좁힌 것이
|
|
1384
|
+
# 오탐만이 아니라 진짜까지 잘라냈다는 뜻이다(다시 넓히든, 사각으로 이름을 붙이든).
|
|
1385
|
+
ok(f'P1 실물 재현 — 35p→36p s5017 x -35,000 (결박 {st.get("bound",0)}쌍)') \
|
|
1386
|
+
if hit_p1 else ng(f'P1 실물 미재현 — 🟥 결박 축소가 원적 사건을 잘랐는지 확인하라 ({st})')
|
|
1387
|
+
ok('P3 실물 재현 — 36p s5016→s5017 겹침 35,000') if hit_p3 else ng('P3 실물 미재현')
|
|
1388
|
+
else:
|
|
1389
|
+
sk('ifkakao26_slides_v1.3_pre-align36_222442.pptx 없음 — P1/P3 보너스 재현 건너뜀')
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def main():
|
|
1393
|
+
try:
|
|
1394
|
+
import mk_slide_fixtures
|
|
1395
|
+
except ImportError as e:
|
|
1396
|
+
print(f'❌ python-pptx 또는 픽스처 모듈 부재 — UNMEASURED: {e}')
|
|
1397
|
+
return 2
|
|
1398
|
+
import tempfile
|
|
1399
|
+
fx_dir = tempfile.mkdtemp(prefix='preprep_lanes_rp_')
|
|
1400
|
+
mk_slide_fixtures.build_all(fx_dir)
|
|
1401
|
+
print(f'픽스처 생성: {fx_dir}')
|
|
1402
|
+
|
|
1403
|
+
run_known_pairs(fx_dir)
|
|
1404
|
+
run_safe_install(fx_dir)
|
|
1405
|
+
run_codex_audit_regressions(fx_dir)
|
|
1406
|
+
run_r7_regressions(fx_dir)
|
|
1407
|
+
run_r8_regressions(fx_dir)
|
|
1408
|
+
run_r9_regressions(fx_dir)
|
|
1409
|
+
run_baseline_delta(fx_dir)
|
|
1410
|
+
run_real_corpus()
|
|
1411
|
+
|
|
1412
|
+
print(f'\n결과: {PASS} passed · {FAIL} failed · {SKIP} skipped (skip != pass)')
|
|
1413
|
+
return 1 if FAIL else 0
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
if __name__ == '__main__':
|
|
1417
|
+
sys.exit(main())
|