"""Phase L: 슬라이드 렌더링 측정 에이전트. Selenium headless Chrome으로 HTML을 실제 렌더링하고 각 zone/block의 px 높이를 정확히 측정한다. LLM 추정이 아닌 브라우저 엔진 측정. 결정론적. """ from __future__ import annotations import logging from typing import Any from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.chrome.service import Service from src.config import settings logger = logging.getLogger(__name__) # JavaScript: 각 zone과 블록의 실제 높이를 측정 _MEASURE_SCRIPT = """ var slide = document.querySelector('.slide'); if (!slide) return {error: 'slide not found'}; var result = { slide: { scrollHeight: slide.scrollHeight, clientHeight: slide.clientHeight, overflowed: slide.scrollHeight > slide.clientHeight, excess_px: Math.max(0, slide.scrollHeight - slide.clientHeight) }, zones: {}, containers: {} }; // Zone 측정 (area-* 클래스) var areaDivs = slide.querySelectorAll('[class*="area-"]'); for (var i = 0; i < areaDivs.length; i++) { var zone = areaDivs[i]; var areaMatch = zone.className.match(/area-(\\w+)/); if (!areaMatch) continue; var areaName = areaMatch[1]; var blocks = []; var blockDivs = zone.querySelectorAll('[class*="block-"]'); for (var j = 0; j < blockDivs.length; j++) { var block = blockDivs[j]; var blockMatch = block.className.match(/block-([\\w-]+)/); var blockName = blockMatch ? blockMatch[1] : block.className; blocks.push({ block_type: blockName, scrollHeight: Math.round(block.scrollHeight), clientHeight: Math.round(block.clientHeight), offsetHeight: Math.round(block.offsetHeight), overflowed: block.scrollHeight > block.clientHeight + 2, excess_px: Math.max(0, Math.round(block.scrollHeight - block.clientHeight)) }); } result.zones[areaName] = { scrollHeight: Math.round(zone.scrollHeight), clientHeight: Math.round(zone.clientHeight), overflowed: zone.scrollHeight > zone.clientHeight + 2, excess_px: Math.max(0, Math.round(zone.scrollHeight - zone.clientHeight)), block_count: blocks.length, blocks: blocks }; } // Phase O: 컨테이너 측정 (container-* 클래스) var containerDivs = slide.querySelectorAll('[class*="container-"]'); for (var k = 0; k < containerDivs.length; k++) { var container = containerDivs[k]; var containerMatch = container.className.match(/container-(.+)/); if (!containerMatch) continue; var containerName = containerMatch[1]; var cBlocks = []; var cBlockDivs = container.querySelectorAll('[class*="block-"]'); for (var m = 0; m < cBlockDivs.length; m++) { var cBlock = cBlockDivs[m]; var cBlockMatch = cBlock.className.match(/block-([\\w-]+)/); var cBlockName = cBlockMatch ? cBlockMatch[1] : cBlock.className; cBlocks.push({ block_type: cBlockName, scrollHeight: Math.round(cBlock.scrollHeight), clientHeight: Math.round(cBlock.clientHeight), overflowed: cBlock.scrollHeight > cBlock.clientHeight + 2, excess_px: Math.max(0, Math.round(cBlock.scrollHeight - cBlock.clientHeight)) }); } result.containers[containerName] = { scrollHeight: Math.round(container.scrollHeight), clientHeight: Math.round(container.clientHeight), allocatedHeight: parseInt(container.style.height) || 0, overflowed: container.scrollHeight > container.clientHeight + 2, excess_px: Math.max(0, Math.round(container.scrollHeight - container.clientHeight)), block_count: cBlocks.length, blocks: cBlocks }; } return result; """ def measure_rendered_heights(html: str) -> dict[str, Any]: """렌더링된 HTML의 각 zone/block 실제 px 높이를 측정한다. Selenium headless Chrome 사용. 결정론적. viewport 크기는 config에서 읽음 (하드코딩 아님). Args: html: 렌더링할 완성 HTML 문자열 Returns: { "slide": {"scrollHeight": 750, "clientHeight": 720, "overflowed": true, ...}, "zones": { "body": {"scrollHeight": 520, "clientHeight": 490, "overflowed": true, "blocks": [...]}, "sidebar": {"scrollHeight": 400, "clientHeight": 490, "overflowed": false, ...}, ... } } """ options = Options() options.add_argument("--headless=new") options.add_argument("--disable-gpu") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") options.add_argument( f"--window-size={settings.slide_width},{settings.slide_height + 200}" ) driver = None try: driver = webdriver.Chrome(options=options) # HTML을 data URI로 로드 import urllib.parse encoded = urllib.parse.quote(html) driver.get(f"data:text/html;charset=utf-8,{encoded}") # 폰트 로딩 대기 (Pretendard CDN) try: driver.execute_script("return document.fonts.ready") except Exception: pass # 폰트 API 미지원 시 무시 # 측정 실행 result = driver.execute_script(_MEASURE_SCRIPT) if result and "error" not in result: _log_measurement(result) return result logger.warning(f"[측정] 실패: {result}") return {"slide": {}, "zones": {}} except Exception as e: logger.warning(f"[측정] Selenium 오류: {e}") return {"slide": {}, "zones": {}} finally: if driver: try: driver.quit() except Exception: pass def format_measurement_for_kei( measurement: dict[str, Any], allocation: dict[int, int] | None = None, ) -> str: """측정 결과를 Kei 검수에 전달할 텍스트로 포맷한다. Args: measurement: measure_rendered_heights() 결과 allocation: allocate_height_budget() 결과 (있으면 할당 대비 비교) Returns: Kei에게 전달할 측정 결과 텍스트 """ lines = ["## 실제 렌더링 측정 결과 (Selenium)"] slide = measurement.get("slide", {}) if slide: status = "OK" if not slide.get("overflowed") else f"+{slide.get('excess_px', 0)}px 초과" lines.append( f"- 슬라이드 전체: {slide.get('scrollHeight', '?')}px / " f"{slide.get('clientHeight', '?')}px — {status}" ) for zone_name, zone_data in measurement.get("zones", {}).items(): status = "OK" if not zone_data.get("overflowed") else f"+{zone_data.get('excess_px', 0)}px 초과" lines.append( f"- {zone_name} zone: 실제 {zone_data.get('scrollHeight', '?')}px / " f"가용 {zone_data.get('clientHeight', '?')}px — {status}" ) for block in zone_data.get("blocks", []): block_status = "OK" if not block.get("overflowed") else f"+{block.get('excess_px', 0)}px 잘림" height = block.get("scrollHeight", "?") # zone 내 비중 계산 zone_height = zone_data.get("clientHeight", 1) ratio_pct = round(height / zone_height * 100) if isinstance(height, (int, float)) and zone_height > 0 else "?" lines.append( f" - {block.get('block_type', '?')}: " f"{height}px ({ratio_pct}%) — {block_status}" ) return "\n".join(lines) def capture_slide_screenshot(html: str) -> str | None: """Phase N-4: 렌더링된 슬라이드의 스크린샷을 base64 PNG로 캡처한다. Selenium 4.x WebElement.screenshot_as_base64 사용. 반환: 순수 base64 문자열 (data URI prefix 없음). 실패 시 None. """ options = Options() options.add_argument("--headless=new") options.add_argument("--disable-gpu") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") options.add_argument("--force-device-scale-factor=1") options.add_argument( f"--window-size={settings.slide_width},{settings.slide_height + 200}" ) driver = None try: driver = webdriver.Chrome(options=options) import urllib.parse encoded = urllib.parse.quote(html) driver.get(f"data:text/html;charset=utf-8,{encoded}") # 폰트 로딩 대기 try: driver.execute_script("return document.fonts.ready") except Exception: pass from selenium.webdriver.common.by import By slide = driver.find_element(By.CSS_SELECTOR, ".slide") screenshot_b64 = slide.screenshot_as_base64 logger.info(f"[스크린샷] 캡처 완료: {len(screenshot_b64)}자 base64") return screenshot_b64 except Exception as e: logger.warning(f"[스크린샷] Selenium 캡처 실패: {e}") return None finally: if driver: try: driver.quit() except Exception: pass def _log_measurement(result: dict[str, Any]) -> None: """측정 결과를 로그에 출력한다.""" slide = result.get("slide", {}) overflow_status = "OK" if not slide.get("overflowed") else f"초과 +{slide.get('excess_px')}px" logger.info(f"[측정] 슬라이드: {slide.get('scrollHeight')}px / {slide.get('clientHeight')}px — {overflow_status}") for zone_name, zone_data in result.get("zones", {}).items(): zone_status = "OK" if not zone_data.get("overflowed") else f"초과 +{zone_data.get('excess_px')}px" logger.info( f"[측정] {zone_name}: {zone_data.get('scrollHeight')}px / " f"{zone_data.get('clientHeight')}px — {zone_status} " f"({zone_data.get('block_count', 0)}개 블록)" )