page_layouts.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. """
  2. Dynamic page layout engine for the universal data report generator.
  3. Provides pre-defined layout templates and layout calculation utilities.
  4. """
  5. from pptx.util import Emu, Pt
  6. from pptx.dml.color import RGBColor
  7. from dataclasses import dataclass
  8. from typing import Optional
  9. SLIDE_WIDTH = 16256000
  10. SLIDE_HEIGHT = 9144000
  11. MARGIN_LEFT = Emu(762000)
  12. MARGIN_RIGHT = Emu(762000)
  13. MARGIN_TOP = Emu(254000)
  14. CONTENT_TOP_BASE = Emu(1600200)
  15. FOOTER_TOP = Emu(8824000)
  16. FOOTER_HEIGHT = Emu(320000)
  17. CONTENT_WIDTH = SLIDE_WIDTH - MARGIN_LEFT - MARGIN_RIGHT
  18. @dataclass
  19. class LayoutZone:
  20. x: int
  21. y: int
  22. width: int
  23. height: int
  24. zone_type: str
  25. def calculate_content_area(content_top_emu: int = None) -> LayoutZone:
  26. top = content_top_emu or int(CONTENT_TOP_BASE)
  27. height = FOOTER_TOP - top - Emu(100000)
  28. return LayoutZone(
  29. x=int(MARGIN_LEFT),
  30. y=top,
  31. width=int(CONTENT_WIDTH),
  32. height=int(height),
  33. zone_type='content_area',
  34. )
  35. def get_kpi_grid(content_top_emu: int = None, cols: int = 3, rows: int = 2,
  36. card_width_emu: int = 4699000, card_height_emu: int = 3048000,
  37. gap_x_emu: int = 444500, gap_y_emu: int = 381000) -> list[LayoutZone]:
  38. start_y = max(int(CONTENT_TOP_BASE), content_top_emu or int(CONTENT_TOP_BASE))
  39. zones = []
  40. for row in range(rows):
  41. for col in range(cols):
  42. x = int(MARGIN_LEFT) + col * (card_width_emu + gap_x_emu)
  43. y = start_y + row * (card_height_emu + gap_y_emu)
  44. zones.append(LayoutZone(x=x, y=y, width=card_width_emu, height=card_height_emu, zone_type='kpi_card'))
  45. return zones
  46. def get_chart_left_zone(content_top_emu: int = None, chart_ratio: float = 0.6) -> LayoutZone:
  47. content = calculate_content_area(content_top_emu)
  48. chart_w = int(content.width * chart_ratio) - Emu(200000)
  49. return LayoutZone(
  50. x=content.x,
  51. y=content.y,
  52. width=chart_w,
  53. height=content.height,
  54. zone_type='chart_left',
  55. )
  56. def get_insight_right_zone(content_top_emu: int = None, chart_ratio: float = 0.6) -> LayoutZone:
  57. content = calculate_content_area(content_top_emu)
  58. chart_w = int(content.width * chart_ratio)
  59. text_left = content.x + chart_w + Emu(200000)
  60. text_w = content.x + content.width - text_left
  61. return LayoutZone(
  62. x=text_left,
  63. y=content.y,
  64. width=text_w,
  65. height=content.height,
  66. zone_type='insight_right',
  67. )
  68. def get_full_width_zone(content_top_emu: int = None) -> LayoutZone:
  69. return calculate_content_area(content_top_emu)
  70. def get_two_column_zones(content_top_emu: int = None, gap_emu: int = 381000) -> tuple[LayoutZone, LayoutZone]:
  71. content = calculate_content_area(content_top_emu)
  72. half_w = (content.width - gap_emu) // 2
  73. left = LayoutZone(x=content.x, y=content.y, width=half_w, height=content.height, zone_type='column_left')
  74. right = LayoutZone(x=content.x + half_w + gap_emu, y=content.y, width=half_w, height=content.height, zone_type='column_right')
  75. return left, right
  76. def get_two_row_zones(content_top_emu: int = None, gap_emu: int = 381000,
  77. top_ratio: float = 0.55) -> tuple[LayoutZone, LayoutZone]:
  78. content = calculate_content_area(content_top_emu)
  79. top_h = int(content.height * top_ratio)
  80. top = LayoutZone(x=content.x, y=content.y, width=content.width, height=top_h, zone_type='row_top')
  81. bottom = LayoutZone(
  82. x=content.x,
  83. y=content.y + top_h + gap_emu,
  84. width=content.width,
  85. height=content.height - top_h - gap_emu,
  86. zone_type='row_bottom',
  87. )
  88. return top, bottom
  89. def get_card_grid(n: int, content_top_emu: int = None, max_cols: int = 3) -> list[LayoutZone]:
  90. content = calculate_content_area(content_top_emu)
  91. cols = min(max_cols, n)
  92. rows = (n + cols - 1) // cols
  93. card_w = (content.width - (cols - 1) * Emu(254000)) // cols
  94. card_h = (content.height - (rows - 1) * Emu(254000)) // rows
  95. zones = []
  96. for i in range(n):
  97. col = i % cols
  98. row = i // cols
  99. x = content.x + col * (card_w + Emu(254000))
  100. y = content.y + row * (card_h + Emu(254000))
  101. zones.append(LayoutZone(x=x, y=y, width=card_w, height=card_h, zone_type=f'card_{i}'))
  102. return zones
  103. def get_alert_card_zones(n: int, content_top_emu: int = None) -> list[LayoutZone]:
  104. content = calculate_content_area(content_top_emu)
  105. card_h = Emu(2286000)
  106. gap = Emu(254000)
  107. return get_card_grid(n, content_top_emu, max_cols=3)
  108. def get_issue_card_zones(n: int, content_top_emu: int = None) -> list[LayoutZone]:
  109. content = calculate_content_area(content_top_emu)
  110. card_h = Emu(2032000)
  111. gap = Emu(254000)
  112. start_y = content.y
  113. zones = []
  114. for i in range(min(n, 3)):
  115. y = start_y + i * (card_h + gap)
  116. zones.append(LayoutZone(x=content.x, y=y, width=content.width, height=card_h, zone_type=f'issue_{i}'))
  117. return zones
  118. def get_table_zone(content_top_emu: int = None, ratio: float = 0.5) -> LayoutZone:
  119. content = calculate_content_area(content_top_emu)
  120. return LayoutZone(
  121. x=content.x,
  122. y=content.y + int(content.height * ratio) + Emu(200000),
  123. width=content.width,
  124. height=int(content.height * (1 - ratio)),
  125. zone_type='table_bottom',
  126. )
  127. def detect_layout_slots(slide) -> dict:
  128. slots = {
  129. 'has_header': False,
  130. 'has_footer': False,
  131. 'has_page_title': False,
  132. 'content_top': int(CONTENT_TOP_BASE),
  133. 'content_width': int(CONTENT_WIDTH),
  134. 'content_height': FOOTER_TOP - int(CONTENT_TOP_BASE) - Emu(100000),
  135. }
  136. for shape in slide.shapes:
  137. if shape.has_text_frame:
  138. text = shape.text_frame.text
  139. if 'page_title' in text or '报告' in text:
  140. slots['has_page_title'] = True
  141. if '数据来源' in text:
  142. slots['has_footer'] = True
  143. slots['footer_top'] = int(shape.top)
  144. if shape.top + shape.height < Emu(1300000):
  145. slots['has_header'] = True
  146. return slots
  147. def ensure_safe_position(shape, slide_width: int, slide_height: int) -> bool:
  148. margin = Emu(254000)
  149. adjusted = False
  150. if shape.left < 0:
  151. shape.left = margin
  152. adjusted = True
  153. if shape.top < 0:
  154. shape.top = margin
  155. adjusted = True
  156. if shape.left + shape.width > slide_width:
  157. shape.left = slide_width - shape.width - margin
  158. adjusted = True
  159. if shape.top + shape.height > slide_height:
  160. shape.top = slide_height - shape.height - margin
  161. adjusted = True
  162. return adjusted
  163. def calculate_fill_ratio(slide, content_top_emu: int = None) -> float:
  164. content = calculate_content_area(content_top_emu)
  165. total_area = content.width * content.height
  166. if total_area <= 0:
  167. return 0.0
  168. filled_area = 0
  169. for shape in slide.shapes:
  170. sx = int(shape.left)
  171. sy = int(shape.top)
  172. sw = int(shape.width)
  173. sh = int(shape.height)
  174. if sy < content.y:
  175. continue
  176. if sy > content.y + content.height:
  177. continue
  178. overlap_x = max(0, min(sx + sw, content.x + content.width) - max(sx, content.x))
  179. overlap_y = max(0, min(sy + sh, content.y + content.height) - max(sy, content.y))
  180. filled_area += overlap_x * overlap_y
  181. return min(1.0, filled_area / total_area)
  182. if __name__ == '__main__':
  183. ca = calculate_content_area()
  184. print(f"Content area: {ca.width}x{ca.height}")
  185. kpis = get_kpi_grid()
  186. for i, z in enumerate(kpis):
  187. print(f"KPI {i}: x={z.x}, y={z.y}")
  188. print(f"Fill ratio test: hypothetical")