page_layouts.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. """
  2. Dynamic page layout engine for the universal data report generator.
  3. Provides pre-defined layout templates and layout calculation utilities.
  4. Supports dynamic slide dimensions via LayoutContext for custom templates.
  5. """
  6. from pptx.util import Emu, Pt
  7. from pptx.dml.color import RGBColor
  8. from dataclasses import dataclass, field
  9. from typing import Optional
  10. # ==============================================================================
  11. # DEFAULT CONSTANTS (backward compatible)
  12. # ==============================================================================
  13. SLIDE_WIDTH = 16256000
  14. SLIDE_HEIGHT = 9144000
  15. MARGIN_LEFT = Emu(762000)
  16. MARGIN_RIGHT = Emu(762000)
  17. MARGIN_TOP = Emu(254000)
  18. CONTENT_TOP_BASE = Emu(1600200)
  19. FOOTER_TOP = Emu(8824000)
  20. FOOTER_HEIGHT = Emu(320000)
  21. CONTENT_WIDTH = SLIDE_WIDTH - MARGIN_LEFT - MARGIN_RIGHT
  22. # ==============================================================================
  23. # LAYOUT CONTEXT
  24. # ==============================================================================
  25. @dataclass
  26. class LayoutContext:
  27. """Encapsulates slide dimensions and content geometry for a specific template."""
  28. slide_width: int = SLIDE_WIDTH
  29. slide_height: int = SLIDE_HEIGHT
  30. content_top: int = field(default_factory=lambda: int(CONTENT_TOP_BASE))
  31. footer_top: int = FOOTER_TOP
  32. margin_left: int = field(default_factory=lambda: int(MARGIN_LEFT))
  33. margin_right: int = field(default_factory=lambda: int(MARGIN_RIGHT))
  34. margin_top: int = field(default_factory=lambda: int(MARGIN_TOP))
  35. @property
  36. def content_width(self) -> int:
  37. return self.slide_width - self.margin_left - self.margin_right
  38. @classmethod
  39. def from_template_profile(cls, profile) -> 'LayoutContext':
  40. """Build LayoutContext from a TemplateProfile (template_parser)."""
  41. # Import here to avoid circular dependency at module load
  42. from template_parser import TemplateProfile
  43. if not isinstance(profile, TemplateProfile):
  44. raise TypeError("profile must be a TemplateProfile instance")
  45. # Use content top from content master if available
  46. content_top = profile.get_content_top("content")
  47. margins = profile.safe_margins
  48. return cls(
  49. slide_width=profile.slide_width,
  50. slide_height=profile.slide_height,
  51. content_top=content_top,
  52. footer_top=profile.slide_height - int(Emu(320000)), # default footer area
  53. margin_left=margins.get("left", int(MARGIN_LEFT)),
  54. margin_right=margins.get("right", int(MARGIN_RIGHT)),
  55. margin_top=margins.get("top", int(MARGIN_TOP)),
  56. )
  57. # ==============================================================================
  58. # LAYOUT ZONES
  59. # ==============================================================================
  60. @dataclass
  61. class LayoutZone:
  62. x: int
  63. y: int
  64. width: int
  65. height: int
  66. zone_type: str
  67. def _resolve_ctx(ctx: Optional[LayoutContext] = None) -> LayoutContext:
  68. return ctx if ctx is not None else LayoutContext()
  69. def calculate_content_area(content_top_emu: int = None,
  70. ctx: Optional[LayoutContext] = None) -> LayoutZone:
  71. ctx = _resolve_ctx(ctx)
  72. top = content_top_emu if content_top_emu is not None else ctx.content_top
  73. height = ctx.footer_top - top - int(Emu(100000))
  74. return LayoutZone(
  75. x=ctx.margin_left,
  76. y=top,
  77. width=ctx.content_width,
  78. height=max(int(height), int(Emu(500000))),
  79. zone_type='content_area',
  80. )
  81. def get_kpi_grid(content_top_emu: int = None, cols: int = 3, rows: int = 2,
  82. card_width_emu: int = 4699000, card_height_emu: int = 3048000,
  83. gap_x_emu: int = 444500, gap_y_emu: int = 381000,
  84. ctx: Optional[LayoutContext] = None) -> list[LayoutZone]:
  85. ctx = _resolve_ctx(ctx)
  86. start_y = max(ctx.content_top, content_top_emu or ctx.content_top)
  87. zones = []
  88. for row in range(rows):
  89. for col in range(cols):
  90. x = ctx.margin_left + col * (card_width_emu + gap_x_emu)
  91. y = start_y + row * (card_height_emu + gap_y_emu)
  92. zones.append(LayoutZone(x=x, y=y, width=card_width_emu, height=card_height_emu, zone_type='kpi_card'))
  93. return zones
  94. def get_chart_left_zone(content_top_emu: int = None, chart_ratio: float = 0.6,
  95. ctx: Optional[LayoutContext] = None) -> LayoutZone:
  96. content = calculate_content_area(content_top_emu, ctx)
  97. chart_w = int(content.width * chart_ratio) - int(Emu(200000))
  98. return LayoutZone(
  99. x=content.x,
  100. y=content.y,
  101. width=max(chart_w, int(Emu(1000000))),
  102. height=content.height,
  103. zone_type='chart_left',
  104. )
  105. def get_insight_right_zone(content_top_emu: int = None, chart_ratio: float = 0.6,
  106. ctx: Optional[LayoutContext] = None) -> LayoutZone:
  107. content = calculate_content_area(content_top_emu, ctx)
  108. chart_w = int(content.width * chart_ratio)
  109. text_left = content.x + chart_w + int(Emu(200000))
  110. text_w = content.x + content.width - text_left
  111. return LayoutZone(
  112. x=text_left,
  113. y=content.y,
  114. width=max(text_w, int(Emu(800000))),
  115. height=content.height,
  116. zone_type='insight_right',
  117. )
  118. def get_full_width_zone(content_top_emu: int = None,
  119. ctx: Optional[LayoutContext] = None) -> LayoutZone:
  120. return calculate_content_area(content_top_emu, ctx)
  121. def get_two_column_zones(content_top_emu: int = None, gap_emu: int = 381000,
  122. ctx: Optional[LayoutContext] = None) -> tuple[LayoutZone, LayoutZone]:
  123. content = calculate_content_area(content_top_emu, ctx)
  124. half_w = (content.width - gap_emu) // 2
  125. left = LayoutZone(x=content.x, y=content.y, width=half_w, height=content.height, zone_type='column_left')
  126. right = LayoutZone(x=content.x + half_w + gap_emu, y=content.y, width=half_w, height=content.height, zone_type='column_right')
  127. return left, right
  128. def get_two_row_zones(content_top_emu: int = None, gap_emu: int = 381000,
  129. top_ratio: float = 0.55,
  130. ctx: Optional[LayoutContext] = None) -> tuple[LayoutZone, LayoutZone]:
  131. content = calculate_content_area(content_top_emu, ctx)
  132. top_h = int(content.height * top_ratio)
  133. top = LayoutZone(x=content.x, y=content.y, width=content.width, height=top_h, zone_type='row_top')
  134. bottom = LayoutZone(
  135. x=content.x,
  136. y=content.y + top_h + gap_emu,
  137. width=content.width,
  138. height=content.height - top_h - gap_emu,
  139. zone_type='row_bottom',
  140. )
  141. return top, bottom
  142. def get_card_grid(n: int, content_top_emu: int = None, max_cols: int = 3,
  143. ctx: Optional[LayoutContext] = None) -> list[LayoutZone]:
  144. content = calculate_content_area(content_top_emu, ctx)
  145. cols = min(max_cols, n)
  146. rows = (n + cols - 1) // cols
  147. card_w = (content.width - (cols - 1) * int(Emu(254000))) // cols
  148. card_h = (content.height - (rows - 1) * int(Emu(254000))) // rows
  149. zones = []
  150. for i in range(n):
  151. col = i % cols
  152. row = i // cols
  153. x = content.x + col * (card_w + int(Emu(254000)))
  154. y = content.y + row * (card_h + int(Emu(254000)))
  155. zones.append(LayoutZone(x=x, y=y, width=card_w, height=card_h, zone_type=f'card_{i}'))
  156. return zones
  157. def get_alert_card_zones(n: int, content_top_emu: int = None,
  158. ctx: Optional[LayoutContext] = None) -> list[LayoutZone]:
  159. return get_card_grid(n, content_top_emu, max_cols=3, ctx=ctx)
  160. def get_issue_card_zones(n: int, content_top_emu: int = None,
  161. ctx: Optional[LayoutContext] = None) -> list[LayoutZone]:
  162. content = calculate_content_area(content_top_emu, ctx)
  163. card_h = int(Emu(2032000))
  164. gap = int(Emu(254000))
  165. start_y = content.y
  166. zones = []
  167. for i in range(min(n, 3)):
  168. y = start_y + i * (card_h + gap)
  169. zones.append(LayoutZone(x=content.x, y=y, width=content.width, height=card_h, zone_type=f'issue_{i}'))
  170. return zones
  171. def get_table_zone(content_top_emu: int = None, ratio: float = 0.5,
  172. ctx: Optional[LayoutContext] = None) -> LayoutZone:
  173. content = calculate_content_area(content_top_emu, ctx)
  174. return LayoutZone(
  175. x=content.x,
  176. y=content.y + int(content.height * ratio) + int(Emu(200000)),
  177. width=content.width,
  178. height=int(content.height * (1 - ratio)),
  179. zone_type='table_bottom',
  180. )
  181. def detect_layout_slots(slide, ctx: Optional[LayoutContext] = None) -> dict:
  182. ctx = _resolve_ctx(ctx)
  183. slots = {
  184. 'has_header': False,
  185. 'has_footer': False,
  186. 'has_page_title': False,
  187. 'content_top': ctx.content_top,
  188. 'content_width': ctx.content_width,
  189. 'content_height': ctx.footer_top - ctx.content_top - int(Emu(100000)),
  190. }
  191. for shape in slide.shapes:
  192. if shape.has_text_frame:
  193. text = shape.text_frame.text
  194. if 'page_title' in text or '报告' in text:
  195. slots['has_page_title'] = True
  196. if '数据来源' in text:
  197. slots['has_footer'] = True
  198. slots['footer_top'] = int(shape.top)
  199. if shape.top + shape.height < Emu(1300000):
  200. slots['has_header'] = True
  201. return slots
  202. def ensure_safe_position(shape, slide_width: int, slide_height: int) -> bool:
  203. margin = Emu(254000)
  204. adjusted = False
  205. if shape.left < 0:
  206. shape.left = margin
  207. adjusted = True
  208. if shape.top < 0:
  209. shape.top = margin
  210. adjusted = True
  211. if shape.left + shape.width > slide_width:
  212. shape.left = slide_width - shape.width - margin
  213. adjusted = True
  214. if shape.top + shape.height > slide_height:
  215. shape.top = slide_height - shape.height - margin
  216. adjusted = True
  217. return adjusted
  218. def calculate_fill_ratio(slide, content_top_emu: int = None,
  219. ctx: Optional[LayoutContext] = None) -> float:
  220. content = calculate_content_area(content_top_emu, ctx)
  221. total_area = content.width * content.height
  222. if total_area <= 0:
  223. return 0.0
  224. filled_area = 0
  225. for shape in slide.shapes:
  226. sx = int(shape.left)
  227. sy = int(shape.top)
  228. sw = int(shape.width)
  229. sh = int(shape.height)
  230. if sy < content.y:
  231. continue
  232. if sy > content.y + content.height:
  233. continue
  234. overlap_x = max(0, min(sx + sw, content.x + content.width) - max(sx, content.x))
  235. overlap_y = max(0, min(sy + sh, content.y + content.height) - max(sy, content.y))
  236. filled_area += overlap_x * overlap_y
  237. return min(1.0, filled_area / total_area)
  238. if __name__ == '__main__':
  239. ca = calculate_content_area()
  240. print(f"Content area: {ca.width}x{ca.height}")
  241. kpis = get_kpi_grid()
  242. for i, z in enumerate(kpis):
  243. print(f"KPI {i}: x={z.x}, y={z.y}")
  244. print(f"Fill ratio test: hypothetical")