diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py
index ec4b586..ab3c2a1 100644
--- a/src/phase_z2_pipeline.py
+++ b/src/phase_z2_pipeline.py
@@ -128,6 +128,11 @@ GRID_GAP = 14 # zone 간격 (사용자 직설 2026-05-07)
# token-based font (var(--font-body) 11px 등) 기준 최소 가독 높이.
DEFAULT_ZONE_MIN_HEIGHT_PX = 100
+# Step 14 image_aspect_mismatch tolerance — |natural_ratio - rendered_ratio| > TOL ⇒ fail.
+# Local anchor : IMP-15 실행-1 (Gitea issue #45) — image axis acceptance criteria.
+# Spec doc row (PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC) update deferred to IMP-15 실행-4.
+IMAGE_ASPECT_DELTA_TOL = 0.05
+
# content_weight 계산 가중치
CONTENT_WEIGHT_COEFFS = {
"text_per_chars": 800, # text_len / 800 = score
@@ -2208,7 +2213,53 @@ def run_overflow_check(html_path: Path) -> dict:
});
});
- return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px };
+ // IMP-15 실행-1 (issue #45) — image_events[] for image_aspect_mismatch detection.
+ // 하나의 entry per under .slide. natural vs rendered aspect 비교.
+ // zone_position : closest('.zone') data-zone-position. 없으면 literal "unknown".
+ const image_events = [];
+ slide.querySelectorAll('img').forEach((img) => {
+ const parentZone = img.closest('.zone');
+ const zonePos = parentZone
+ ? (parentZone.getAttribute('data-zone-position') || 'unknown')
+ : 'unknown';
+ const zoneTid = parentZone
+ ? (parentZone.getAttribute('data-template-id') || '?')
+ : '?';
+ const imgRect = img.getBoundingClientRect();
+ const rendered_w = imgRect.width;
+ const rendered_h = imgRect.height;
+ const natural_w = img.naturalWidth;
+ const natural_h = img.naturalHeight;
+ const natural_ratio = (natural_w > 0 && natural_h > 0)
+ ? (natural_w / natural_h)
+ : null;
+ const rendered_ratio = (rendered_w > 0 && rendered_h > 0)
+ ? (rendered_w / rendered_h)
+ : null;
+ const delta = (natural_ratio !== null && rendered_ratio !== null)
+ ? (rendered_ratio - natural_ratio)
+ : null;
+ image_events.push({
+ src: img.getAttribute('src') || '',
+ zone_position: zonePos,
+ zone_template_id: zoneTid,
+ natural_w: natural_w,
+ natural_h: natural_h,
+ rendered_w: Math.round(rendered_w),
+ rendered_h: Math.round(rendered_h),
+ natural_ratio: natural_ratio,
+ rendered_ratio: rendered_ratio,
+ delta: delta,
+ bbox: {
+ x: Math.round(imgRect.left - slideRect.left),
+ y: Math.round(imgRect.top - slideRect.top),
+ w: Math.round(rendered_w),
+ h: Math.round(rendered_h),
+ },
+ });
+ });
+
+ return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px, image_events };
""")
screenshot_path = html_path.parent / "preview.png"
@@ -2248,6 +2299,25 @@ def run_overflow_check(html_path: Path) -> dict:
f"(content {c['scrollHeight']} vs container {c['clientHeight']})"
)
+ # IMP-15 실행-1 (issue #45) — image_aspect_mismatch aggregation.
+ # |natural_ratio - rendered_ratio| > IMAGE_ASPECT_DELTA_TOL ⇒ fail_reason append.
+ # Entries with null ratio (image not loaded / natural dims = 0) are skipped (no false positive).
+ for ev in result.get("image_events", []):
+ delta = ev.get("delta")
+ if delta is None:
+ continue
+ if abs(delta) > IMAGE_ASPECT_DELTA_TOL:
+ n_ratio = ev.get("natural_ratio")
+ r_ratio = ev.get("rendered_ratio")
+ src = ev.get("src", "")
+ pos = ev.get("zone_position", "unknown")
+ tid = ev.get("zone_template_id", "?")
+ fail_reasons.append(
+ f"image aspect mismatch in zone--{pos}: "
+ f"natural={n_ratio:.3f} rendered={r_ratio:.3f} delta={delta:+.3f} "
+ f"(template={tid}, tol={IMAGE_ASPECT_DELTA_TOL}, src={src})"
+ )
+
result["passed"] = len(fail_reasons) == 0
result["fail_reasons"] = fail_reasons
return result
@@ -4355,7 +4425,8 @@ def run_phase_z2_mvp1(
note=(
"Selenium 실측 — clientHeight / scrollHeight / excess_y / frame_slot_metrics. "
"Step 8 의 계획값과 비교 시 어느 cell 이 overflow 했는지 박힘. "
- "image / table 검사 부재 — Step 14 ⚠ partial."
+ "image_aspect_mismatch 검사 추가 (IMP-15 실행-1, issue #45) — image_events[] + fail_reasons. "
+ "table 검사 부재 (실행-2 잔류) — Step 14 ⚠ partial (table only)."
),
)
diff --git a/tests/phase_z2/test_phase_z2_step14_image_check.py b/tests/phase_z2/test_phase_z2_step14_image_check.py
new file mode 100644
index 0000000..9377e67
--- /dev/null
+++ b/tests/phase_z2/test_phase_z2_step14_image_check.py
@@ -0,0 +1,196 @@
+"""IMP-15 실행-1 (Gitea issue #45) — Step 14 image_aspect_mismatch detection.
+
+Tests Selenium-driven `
` aspect measurement added to ``run_overflow_check``:
+
+* Fixture A — 200×100 image rendered at 200×100 → ``abs(delta) < tol``, no fail
+ reason, ``passed=True``.
+* Fixture B — 200×100 image forced to render 200×200 → ``abs(delta) > 0.30``,
+ fail reason includes ``image aspect mismatch in zone--primary:``,
+ ``passed=False``.
+* Fixture C — ``
`` with no ``.zone`` ancestor (attached directly under
+ ``.slide``) → event reports ``zone_position == "unknown"``.
+
+Chromedriver resolution mirrors the pipeline's order
+(``PROJECT_ROOT/chromedriver{,.exe}`` → PATH fallback). When no driver is
+resolvable the suite skips by default; under ``PHASE_Z_REQUIRE_SELENIUM=1`` the
+tests are marked ``xfail(strict=True)`` so CI cannot silently lose coverage.
+"""
+from __future__ import annotations
+
+import os
+import shutil
+from pathlib import Path
+
+import pytest
+
+from src.phase_z2_pipeline import (
+ IMAGE_ASPECT_DELTA_TOL,
+ PROJECT_ROOT,
+ run_overflow_check,
+)
+
+PIL_Image = pytest.importorskip("PIL.Image", reason="Pillow required for fixture PNGs")
+
+
+# ─── chromedriver skip / xfail guard ─────────────────────────────────
+
+def _selenium_manager_resolvable() -> bool:
+ """Probe ``webdriver.Chrome(options=...)`` — pipeline's third tier.
+
+ ``src/phase_z2_pipeline.py`` (run_overflow_check) tries
+ ``PROJECT_ROOT/chromedriver{,.exe}`` first, then falls back to
+ ``webdriver.Chrome(options=options)`` which delegates to Selenium Manager
+ for driver auto-resolution. The test resolver must mirror that fallback
+ or PHASE_Z_REQUIRE_SELENIUM=1 produces spurious strict-XPASS failures on
+ machines where Selenium Manager can satisfy the pipeline at runtime.
+ """
+ try:
+ from selenium import webdriver
+ from selenium.webdriver.chrome.options import Options as _Opts
+ except Exception:
+ return False
+ opts = _Opts()
+ opts.add_argument("--headless=new")
+ opts.add_argument("--no-sandbox")
+ opts.add_argument("--disable-dev-shm-usage")
+ try:
+ drv = webdriver.Chrome(options=opts)
+ except Exception:
+ return False
+ try:
+ drv.quit()
+ except Exception:
+ pass
+ return True
+
+
+def _chromedriver_resolvable() -> bool:
+ """Mirror pipeline order: PROJECT_ROOT/chromedriver{,.exe} → PATH → Selenium Manager."""
+ for candidate in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
+ if candidate.is_file():
+ return True
+ if shutil.which("chromedriver") or shutil.which("chromedriver.exe"):
+ return True
+ return _selenium_manager_resolvable()
+
+
+_REQUIRE_SELENIUM = os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1"
+_DRIVER_AVAILABLE = _chromedriver_resolvable()
+
+if not _DRIVER_AVAILABLE:
+ if _REQUIRE_SELENIUM:
+ pytestmark = pytest.mark.xfail(
+ strict=True,
+ reason="PHASE_Z_REQUIRE_SELENIUM=1 but chromedriver is unresolvable",
+ )
+ else:
+ pytestmark = pytest.mark.skip(
+ reason=(
+ "chromedriver unresolvable (PROJECT_ROOT/chromedriver{,.exe} + PATH + Selenium Manager); "
+ "set PHASE_Z_REQUIRE_SELENIUM=1 to make this a hard failure"
+ ),
+ )
+
+
+# ─── HTML / PNG fixture helpers ──────────────────────────────────────
+
+_SLIDE_CSS = """
+html, body { margin: 0; padding: 0; }
+.slide { width: 1280px; height: 720px; position: relative; box-sizing: border-box; }
+.zone { display: block; }
+"""
+
+
+def _write_png(path: Path, width: int, height: int, colour=(120, 160, 200)) -> Path:
+ img = PIL_Image.new("RGB", (width, height), colour)
+ img.save(path, format="PNG")
+ return path
+
+
+def _write_slide_html(tmp_path: Path, body_inner: str, name: str = "slide.html") -> Path:
+ html = (
+ "