""" Dynamic page layout engine for the universal data report generator. Provides pre-defined layout templates and layout calculation utilities. Supports dynamic slide dimensions via LayoutContext for custom templates. """ from pptx.util import Emu, Pt from pptx.dml.color import RGBColor from dataclasses import dataclass, field from typing import Optional # ============================================================================== # DEFAULT CONSTANTS (backward compatible) # ============================================================================== 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 # ============================================================================== # LAYOUT CONTEXT # ============================================================================== @dataclass class LayoutContext: """Encapsulates slide dimensions and content geometry for a specific template.""" slide_width: int = SLIDE_WIDTH slide_height: int = SLIDE_HEIGHT content_top: int = field(default_factory=lambda: int(CONTENT_TOP_BASE)) footer_top: int = FOOTER_TOP margin_left: int = field(default_factory=lambda: int(MARGIN_LEFT)) margin_right: int = field(default_factory=lambda: int(MARGIN_RIGHT)) margin_top: int = field(default_factory=lambda: int(MARGIN_TOP)) @property def content_width(self) -> int: return self.slide_width - self.margin_left - self.margin_right @classmethod def from_template_profile(cls, profile) -> 'LayoutContext': """Build LayoutContext from a TemplateProfile (template_parser).""" # Import here to avoid circular dependency at module load from template_parser import TemplateProfile if not isinstance(profile, TemplateProfile): raise TypeError("profile must be a TemplateProfile instance") # Use content top from content master if available content_top = profile.get_content_top("content") margins = profile.safe_margins return cls( slide_width=profile.slide_width, slide_height=profile.slide_height, content_top=content_top, footer_top=profile.slide_height - int(Emu(320000)), # default footer area margin_left=margins.get("left", int(MARGIN_LEFT)), margin_right=margins.get("right", int(MARGIN_RIGHT)), margin_top=margins.get("top", int(MARGIN_TOP)), ) # ============================================================================== # LAYOUT ZONES # ============================================================================== @dataclass class LayoutZone: x: int y: int width: int height: int zone_type: str def _resolve_ctx(ctx: Optional[LayoutContext] = None) -> LayoutContext: return ctx if ctx is not None else LayoutContext() def calculate_content_area(content_top_emu: int = None, ctx: Optional[LayoutContext] = None) -> LayoutZone: ctx = _resolve_ctx(ctx) top = content_top_emu if content_top_emu is not None else ctx.content_top height = ctx.footer_top - top - int(Emu(100000)) return LayoutZone( x=ctx.margin_left, y=top, width=ctx.content_width, height=max(int(height), int(Emu(500000))), 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, ctx: Optional[LayoutContext] = None) -> list[LayoutZone]: ctx = _resolve_ctx(ctx) start_y = max(ctx.content_top, content_top_emu or ctx.content_top) zones = [] for row in range(rows): for col in range(cols): x = ctx.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, ctx: Optional[LayoutContext] = None) -> LayoutZone: content = calculate_content_area(content_top_emu, ctx) chart_w = int(content.width * chart_ratio) - int(Emu(200000)) return LayoutZone( x=content.x, y=content.y, width=max(chart_w, int(Emu(1000000))), height=content.height, zone_type='chart_left', ) def get_insight_right_zone(content_top_emu: int = None, chart_ratio: float = 0.6, ctx: Optional[LayoutContext] = None) -> LayoutZone: content = calculate_content_area(content_top_emu, ctx) chart_w = int(content.width * chart_ratio) text_left = content.x + chart_w + int(Emu(200000)) text_w = content.x + content.width - text_left return LayoutZone( x=text_left, y=content.y, width=max(text_w, int(Emu(800000))), height=content.height, zone_type='insight_right', ) def get_full_width_zone(content_top_emu: int = None, ctx: Optional[LayoutContext] = None) -> LayoutZone: return calculate_content_area(content_top_emu, ctx) def get_two_column_zones(content_top_emu: int = None, gap_emu: int = 381000, ctx: Optional[LayoutContext] = None) -> tuple[LayoutZone, LayoutZone]: content = calculate_content_area(content_top_emu, ctx) 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, ctx: Optional[LayoutContext] = None) -> tuple[LayoutZone, LayoutZone]: content = calculate_content_area(content_top_emu, ctx) 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, ctx: Optional[LayoutContext] = None) -> list[LayoutZone]: content = calculate_content_area(content_top_emu, ctx) cols = min(max_cols, n) rows = (n + cols - 1) // cols card_w = (content.width - (cols - 1) * int(Emu(254000))) // cols card_h = (content.height - (rows - 1) * int(Emu(254000))) // rows zones = [] for i in range(n): col = i % cols row = i // cols x = content.x + col * (card_w + int(Emu(254000))) y = content.y + row * (card_h + int(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, ctx: Optional[LayoutContext] = None) -> list[LayoutZone]: return get_card_grid(n, content_top_emu, max_cols=3, ctx=ctx) def get_issue_card_zones(n: int, content_top_emu: int = None, ctx: Optional[LayoutContext] = None) -> list[LayoutZone]: content = calculate_content_area(content_top_emu, ctx) card_h = int(Emu(2032000)) gap = int(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, ctx: Optional[LayoutContext] = None) -> LayoutZone: content = calculate_content_area(content_top_emu, ctx) return LayoutZone( x=content.x, y=content.y + int(content.height * ratio) + int(Emu(200000)), width=content.width, height=int(content.height * (1 - ratio)), zone_type='table_bottom', ) def detect_layout_slots(slide, ctx: Optional[LayoutContext] = None) -> dict: ctx = _resolve_ctx(ctx) slots = { 'has_header': False, 'has_footer': False, 'has_page_title': False, 'content_top': ctx.content_top, 'content_width': ctx.content_width, 'content_height': ctx.footer_top - ctx.content_top - int(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, ctx: Optional[LayoutContext] = None) -> float: content = calculate_content_area(content_top_emu, ctx) 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")