""" Dynamic page layout engine for the universal data report generator. Provides pre-defined layout templates and layout calculation utilities. """ from pptx.util import Emu, Pt from pptx.dml.color import RGBColor from dataclasses import dataclass from typing import Optional SLIDE_WIDTH = 16256000 SLIDE_HEIGHT = 9144000 MARGIN_LEFT = Emu(762000) MARGIN_RIGHT = Emu(762000) MARGIN_TOP = Emu(254000) CONTENT_TOP_BASE = Emu(1600200) FOOTER_TOP = Emu(8824000) FOOTER_HEIGHT = Emu(320000) CONTENT_WIDTH = SLIDE_WIDTH - MARGIN_LEFT - MARGIN_RIGHT @dataclass class LayoutZone: x: int y: int width: int height: int zone_type: str def calculate_content_area(content_top_emu: int = None) -> LayoutZone: top = content_top_emu or int(CONTENT_TOP_BASE) height = FOOTER_TOP - top - Emu(100000) return LayoutZone( x=int(MARGIN_LEFT), y=top, width=int(CONTENT_WIDTH), height=int(height), zone_type='content_area', ) def get_kpi_grid(content_top_emu: int = None, cols: int = 3, rows: int = 2, card_width_emu: int = 4699000, card_height_emu: int = 3048000, gap_x_emu: int = 444500, gap_y_emu: int = 381000) -> list[LayoutZone]: start_y = max(int(CONTENT_TOP_BASE), content_top_emu or int(CONTENT_TOP_BASE)) zones = [] for row in range(rows): for col in range(cols): x = int(MARGIN_LEFT) + col * (card_width_emu + gap_x_emu) y = start_y + row * (card_height_emu + gap_y_emu) zones.append(LayoutZone(x=x, y=y, width=card_width_emu, height=card_height_emu, zone_type='kpi_card')) return zones def get_chart_left_zone(content_top_emu: int = None, chart_ratio: float = 0.6) -> LayoutZone: content = calculate_content_area(content_top_emu) chart_w = int(content.width * chart_ratio) - Emu(200000) return LayoutZone( x=content.x, y=content.y, width=chart_w, height=content.height, zone_type='chart_left', ) def get_insight_right_zone(content_top_emu: int = None, chart_ratio: float = 0.6) -> LayoutZone: content = calculate_content_area(content_top_emu) chart_w = int(content.width * chart_ratio) text_left = content.x + chart_w + Emu(200000) text_w = content.x + content.width - text_left return LayoutZone( x=text_left, y=content.y, width=text_w, height=content.height, zone_type='insight_right', ) def get_full_width_zone(content_top_emu: int = None) -> LayoutZone: return calculate_content_area(content_top_emu) def get_two_column_zones(content_top_emu: int = None, gap_emu: int = 381000) -> tuple[LayoutZone, LayoutZone]: content = calculate_content_area(content_top_emu) half_w = (content.width - gap_emu) // 2 left = LayoutZone(x=content.x, y=content.y, width=half_w, height=content.height, zone_type='column_left') right = LayoutZone(x=content.x + half_w + gap_emu, y=content.y, width=half_w, height=content.height, zone_type='column_right') return left, right def get_two_row_zones(content_top_emu: int = None, gap_emu: int = 381000, top_ratio: float = 0.55) -> tuple[LayoutZone, LayoutZone]: content = calculate_content_area(content_top_emu) top_h = int(content.height * top_ratio) top = LayoutZone(x=content.x, y=content.y, width=content.width, height=top_h, zone_type='row_top') bottom = LayoutZone( x=content.x, y=content.y + top_h + gap_emu, width=content.width, height=content.height - top_h - gap_emu, zone_type='row_bottom', ) return top, bottom def get_card_grid(n: int, content_top_emu: int = None, max_cols: int = 3) -> list[LayoutZone]: content = calculate_content_area(content_top_emu) cols = min(max_cols, n) rows = (n + cols - 1) // cols card_w = (content.width - (cols - 1) * Emu(254000)) // cols card_h = (content.height - (rows - 1) * Emu(254000)) // rows zones = [] for i in range(n): col = i % cols row = i // cols x = content.x + col * (card_w + Emu(254000)) y = content.y + row * (card_h + Emu(254000)) zones.append(LayoutZone(x=x, y=y, width=card_w, height=card_h, zone_type=f'card_{i}')) return zones def get_alert_card_zones(n: int, content_top_emu: int = None) -> list[LayoutZone]: content = calculate_content_area(content_top_emu) card_h = Emu(2286000) gap = Emu(254000) return get_card_grid(n, content_top_emu, max_cols=3) def get_issue_card_zones(n: int, content_top_emu: int = None) -> list[LayoutZone]: content = calculate_content_area(content_top_emu) card_h = Emu(2032000) gap = Emu(254000) start_y = content.y zones = [] for i in range(min(n, 3)): y = start_y + i * (card_h + gap) zones.append(LayoutZone(x=content.x, y=y, width=content.width, height=card_h, zone_type=f'issue_{i}')) return zones def get_table_zone(content_top_emu: int = None, ratio: float = 0.5) -> LayoutZone: content = calculate_content_area(content_top_emu) return LayoutZone( x=content.x, y=content.y + int(content.height * ratio) + Emu(200000), width=content.width, height=int(content.height * (1 - ratio)), zone_type='table_bottom', ) def detect_layout_slots(slide) -> dict: slots = { 'has_header': False, 'has_footer': False, 'has_page_title': False, 'content_top': int(CONTENT_TOP_BASE), 'content_width': int(CONTENT_WIDTH), 'content_height': FOOTER_TOP - int(CONTENT_TOP_BASE) - Emu(100000), } for shape in slide.shapes: if shape.has_text_frame: text = shape.text_frame.text if 'page_title' in text or '报告' in text: slots['has_page_title'] = True if '数据来源' in text: slots['has_footer'] = True slots['footer_top'] = int(shape.top) if shape.top + shape.height < Emu(1300000): slots['has_header'] = True return slots def ensure_safe_position(shape, slide_width: int, slide_height: int) -> bool: margin = Emu(254000) adjusted = False if shape.left < 0: shape.left = margin adjusted = True if shape.top < 0: shape.top = margin adjusted = True if shape.left + shape.width > slide_width: shape.left = slide_width - shape.width - margin adjusted = True if shape.top + shape.height > slide_height: shape.top = slide_height - shape.height - margin adjusted = True return adjusted def calculate_fill_ratio(slide, content_top_emu: int = None) -> float: content = calculate_content_area(content_top_emu) total_area = content.width * content.height if total_area <= 0: return 0.0 filled_area = 0 for shape in slide.shapes: sx = int(shape.left) sy = int(shape.top) sw = int(shape.width) sh = int(shape.height) if sy < content.y: continue if sy > content.y + content.height: continue overlap_x = max(0, min(sx + sw, content.x + content.width) - max(sx, content.x)) overlap_y = max(0, min(sy + sh, content.y + content.height) - max(sy, content.y)) filled_area += overlap_x * overlap_y return min(1.0, filled_area / total_area) if __name__ == '__main__': ca = calculate_content_area() print(f"Content area: {ca.width}x{ca.height}") kpis = get_kpi_grid() for i, z in enumerate(kpis): print(f"KPI {i}: x={z.x}, y={z.y}") print(f"Fill ratio test: hypothetical")