Spaces:
Running
Running
""" | |
precise layout generation using css box model | |
""" | |
import json | |
from pathlib import Path | |
from typing import Dict, Any, List, Tuple | |
from src.state.poster_state import PosterState | |
from utils.langgraph_utils import LangGraphAgent, extract_json, load_prompt | |
from utils.src.logging_utils import log_agent_info, log_agent_success, log_agent_error, log_agent_warning | |
from src.layout.text_height_measurement import measure_text_height | |
from src.config.poster_config import load_config | |
class LayoutAgent: | |
"""creates optimized layouts using css box model""" | |
def __init__(self): | |
self.name = "layout_agent" | |
self.config = load_config() | |
self.poster_margin = self.config["layout"]["poster_margin"] | |
self.column_spacing = self.config["layout"]["column_spacing"] | |
self.title_height_fraction = self.config["layout"]["title_height_fraction"] | |
self.title_font_family = self.config["typography"]["fonts"]["title"] | |
self.authors_font_family = self.config["typography"]["fonts"]["authors"] | |
self.section_title_font_family = self.config["typography"]["fonts"]["section_title"] | |
self.body_text_font_family = self.config["typography"]["fonts"]["body_text"] | |
# layout constants | |
self.layout_constants = self.config["layout_constants"] | |
self.column_balancing = self.config["column_balancing"] | |
# debug configuration | |
self.show_debug_borders = self.config["rendering"]["debug_borders"] ## enable to see section boundaries for debugging | |
def __call__(self, state: PosterState, mode: str = "initial") -> PosterState: | |
if mode == "initial": | |
return self._generate_initial_layout(state) | |
else: | |
return self._generate_final_layout(state) | |
def _generate_initial_layout(self, state: PosterState) -> PosterState: | |
"""generate initial layout without optimization - direct curator mapping""" | |
log_agent_info(self.name, "generating initial layout from story board") | |
try: | |
story_board = state.get("story_board") | |
if not story_board: | |
raise ValueError("missing story_board from curator") | |
# organize sections from story board for layout creation | |
sections = story_board["spatial_content_plan"]["sections"] | |
optimized_layout = self._organize_sections_by_column(sections) | |
# create layout directly from curator output - no optimization | |
layout_data = self._create_precise_layout( | |
story_board=story_board, | |
optimized_layout=optimized_layout, | |
state=state | |
) | |
# generate column analysis for balancer | |
column_analysis = self._generate_column_analysis(layout_data, state) | |
state["initial_layout_data"] = layout_data | |
state["column_analysis"] = column_analysis | |
state["current_agent"] = self.name | |
self._save_initial_layout(state) | |
log_agent_success(self.name, "initial layout generated") | |
return state | |
except Exception as e: | |
log_agent_error(self.name, f"initial layout error: {e}") | |
state["errors"].append(f"{self.name}: {e}") | |
return state | |
def _generate_final_layout(self, state: PosterState) -> PosterState: | |
"""generate final layout from optimized story board""" | |
log_agent_info(self.name, "generating final layout from optimized story board") | |
try: | |
optimized_story_board = state.get("optimized_story_board") | |
if not optimized_story_board: | |
raise ValueError("missing optimized_story_board from balancer") | |
# organize sections from optimized story board | |
sections = optimized_story_board["spatial_content_plan"]["sections"] | |
organized_layout = self._organize_sections_by_column(sections) | |
# create final layout from optimized story board | |
layout_data = self._create_precise_layout( | |
story_board=optimized_story_board, | |
optimized_layout=organized_layout, | |
state=state | |
) | |
# generate final column analysis to verify optimization success | |
final_column_analysis = self._generate_column_analysis(layout_data, state) | |
# validate final layout | |
validation = self._validate_precise_layout(layout_data, state["poster_width"], state["poster_height"]) | |
state["design_layout"] = layout_data | |
state["final_column_analysis"] = final_column_analysis | |
state["optimized_column_assignment"] = organized_layout["optimized_layout"]["column_assignments"] | |
state["current_agent"] = self.name | |
self._save_final_layout(state) | |
log_agent_success(self.name, "final layout complete") | |
return state | |
except Exception as e: | |
log_agent_error(self.name, f"final layout error: {e}") | |
state["errors"].append(f"{self.name}: {e}") | |
return state | |
def _optimize_column_distribution(self, story_board: Dict, poster_width: int, poster_height: int, config, state) -> Dict: | |
"""rule-based column distribution for optimal space utilization""" | |
log_agent_info(self.name, "optimizing column distribution") | |
# calculate available space | |
effective_height = poster_height - 2 * self.poster_margin # total height minus margins | |
title_region_height = effective_height * self.title_height_fraction # 18% of effective height | |
available_height = effective_height - title_region_height # remaining height for sections | |
column_width = (poster_width - 2 * self.poster_margin - 2 * self.column_spacing) / 3 | |
# handle new spatial content plan format | |
if "spatial_content_plan" in story_board: | |
sections = story_board["spatial_content_plan"]["sections"] | |
column_distribution = story_board.get("column_distribution", {}) | |
else: | |
# fallback to old format | |
sections = story_board.get("story_board", {}).get("sections", []) | |
column_distribution = {} | |
# create precise spatial layout using css-like calculations | |
optimized_layout = self._create_spatial_layout( | |
sections, column_distribution, available_height, column_width, state | |
) | |
log_agent_success(self.name, f"created rule-based optimized layout") | |
return { | |
"optimized_layout": { | |
"column_assignments": optimized_layout, | |
"strategy": "rule_based_intelligent", | |
"space_utilization_target": 0.90, | |
"column_dimensions": { | |
"width": column_width, | |
"height": available_height | |
} | |
} | |
} | |
def _apply_adjustments(self, adjustments: Dict): | |
"""apply critic-requested adjustments to layout parameters""" | |
if adjustments.get("increase_spacing"): | |
log_agent_info(self.name, "increased spacing: adjusting layout constants") | |
if adjustments.get("reduce_sizes"): | |
log_agent_info(self.name, "reduced spacing: adjusting layout constants") | |
if adjustments.get("poster_margin"): | |
self.poster_margin = adjustments["poster_margin"] | |
if adjustments.get("column_spacing"): | |
self.column_spacing = adjustments["column_spacing"] | |
def _save_initial_layout(self, state: PosterState): | |
"""save initial layout data""" | |
output_dir = Path(state["output_dir"]) / "content" | |
output_dir.mkdir(parents=True, exist_ok=True) | |
with open(output_dir / "initial_layout_data.json", "w", encoding='utf-8') as f: | |
json.dump(state.get("initial_layout_data", []), f, indent=2) | |
with open(output_dir / "column_analysis.json", "w", encoding='utf-8') as f: | |
json.dump(state.get("column_analysis", {}), f, indent=2) | |
def _save_final_layout(self, state: PosterState): | |
"""save final layout data""" | |
output_dir = Path(state["output_dir"]) / "content" | |
output_dir.mkdir(parents=True, exist_ok=True) | |
with open(output_dir / "final_design_layout.json", "w", encoding='utf-8') as f: | |
json.dump(state.get("design_layout", []), f, indent=2) | |
with open(output_dir / "optimized_layout.json", "w", encoding='utf-8') as f: | |
json.dump(state.get("optimized_column_assignment", {}), f, indent=2) | |
# save final column analysis to show optimization success | |
if state.get("final_column_analysis"): | |
with open(output_dir / "final_column_analysis.json", "w", encoding='utf-8') as f: | |
json.dump(state.get("final_column_analysis", {}), f, indent=2) | |
def _generate_column_analysis(self, layout_data: List[Dict], state: PosterState) -> Dict: | |
"""generate detailed column utilization analysis using exact column calculation method""" | |
poster_width = state["poster_width"] | |
poster_height = state["poster_height"] | |
effective_height = poster_height - 2 * self.poster_margin | |
title_region_height = effective_height * self.title_height_fraction | |
available_height = effective_height - title_region_height | |
# calculate precise column x coordinates using global constants | |
column_width = (poster_width - 2 * self.poster_margin - 2 * self.column_spacing) / 3 | |
left_column_x = self.poster_margin | |
middle_column_x = self.poster_margin + column_width + self.column_spacing | |
right_column_x = self.poster_margin + 2 * (column_width + self.column_spacing) | |
columns = {"left": [], "middle": [], "right": []} | |
# group elements by column using calculated column boundaries | |
for element in layout_data: | |
if element.get("type") == "section_container": | |
element_x = element.get("x", 0) | |
# use midpoint boundaries to categorize elements | |
if element_x < (left_column_x + middle_column_x) / 2: | |
columns["left"].append(element) | |
elif element_x < (middle_column_x + right_column_x) / 2: | |
columns["middle"].append(element) | |
else: | |
columns["right"].append(element) | |
# calculate utilization for each column | |
column_analysis = { | |
"available_height": available_height, | |
"columns": {} | |
} | |
for col_name, elements in columns.items(): | |
if elements: | |
max_bottom = max(elem["y"] + elem["height"] for elem in elements) | |
min_top = min(elem["y"] for elem in elements) | |
used_height = max_bottom - min_top | |
else: | |
used_height = 0 | |
utilization_rate = used_height / available_height if available_height > 0 else 0 | |
status = "overflow" if utilization_rate > 1.0 else "underutilized" if utilization_rate < 0.7 else "balanced" | |
column_analysis["columns"][col_name] = { | |
"utilization_rate": utilization_rate, | |
"total_height": used_height, | |
"status": status, | |
"available_space": max(0, available_height - used_height), | |
"excess_height": max(0, used_height - available_height) | |
} | |
return column_analysis | |
def _organize_sections_by_column(self, sections: List[Dict]) -> Dict: | |
"""organize sections by column assignment for layout creation""" | |
columns = {"left": [], "middle": [], "right": []} | |
for section in sections: | |
column = section.get("column_assignment", "left") | |
if column in columns: | |
columns[column].append(section) | |
column_assignments = [ | |
{"column_id": 0, "sections": columns["left"]}, | |
{"column_id": 1, "sections": columns["middle"]}, | |
{"column_id": 2, "sections": columns["right"]} | |
] | |
return { | |
"optimized_layout": { | |
"column_assignments": column_assignments | |
} | |
} | |
def _create_precise_layout(self, story_board: Dict, optimized_layout: Dict, state: PosterState) -> List[Dict]: | |
"""create precise layout with exact positioning using measurements""" | |
layout_elements = [] | |
# poster dimensions | |
poster_width = state["poster_width"] | |
poster_height = state["poster_height"] | |
# calculate layout dimensions | |
effective_height = poster_height - 2 * self.poster_margin | |
title_region_height = effective_height * self.title_height_fraction # 18% fixed region | |
available_height = effective_height - title_region_height # remaining for sections | |
column_width = (poster_width - 2 * self.poster_margin - 2 * self.column_spacing) / 3 | |
# add title element (still uses actual measured height, not fixed region height) | |
title_element = self._create_title_element(state, poster_width, title_region_height) | |
if title_element: | |
layout_elements.append(title_element) | |
# add logo elements | |
logo_elements = self._create_logo_elements(state, poster_width) | |
layout_elements.extend(logo_elements) | |
# process each column | |
column_assignments = optimized_layout.get("optimized_layout", {}).get("column_assignments", []) | |
for col_idx, column in enumerate(column_assignments): | |
column_x = self.poster_margin + col_idx * (column_width + self.column_spacing) | |
column_y = self.poster_margin + title_region_height # fixed at poster_margin + 18% | |
current_y = column_y | |
# process each section in this column | |
for section in column.get("sections", []): | |
section_start_y = current_y | |
section_elements = self._create_section_elements( | |
section, column_x, current_y, column_width, state, available_height | |
) | |
# calculate section height from actual element positions | |
section_height = 0 | |
if section_elements: | |
# find the bottommost element | |
max_bottom = 0 | |
for element in section_elements: | |
element_bottom = element["y"] + element["height"] | |
max_bottom = max(max_bottom, element_bottom) | |
section_height = max_bottom - section_start_y | |
# create section container for layout structure | |
section_container = { | |
"type": "section_container", | |
"x": column_x, | |
"y": section_start_y, | |
"width": column_width, | |
"height": section_height, | |
"section_id": section.get("section_id", "unknown"), | |
"importance_level": section.get("importance_level", 2), # importance level for background styling | |
"priority": 0.1 | |
} | |
# add debug border only if enabled | |
if self.show_debug_borders: | |
section_container["debug_border"] = True | |
layout_elements.append(section_container) | |
layout_elements.extend(section_elements) | |
current_y += section_height + 1.0 # 1" section spacing for stability | |
log_agent_info(self.name, f"placed section '{section.get('section_id')}' at y={section_start_y:.2f}, height={section_height:.2f}") | |
return layout_elements | |
def _create_title_element(self, state: PosterState, poster_width: float, title_height: float) -> Dict: | |
"""create title element with exact positioning""" | |
# calculate 2/3 width (2 columns + 1 margin width) | |
column_width = (poster_width - 2 * self.poster_margin - 2 * self.column_spacing) / 3 | |
title_width = 2 * column_width + self.column_spacing # 2 columns + 1 spacing | |
# extract title and authors from narrative content | |
narrative = state.get("narrative_content", {}) | |
meta = narrative.get("meta", {}) | |
poster_title = meta.get("poster_title", state.get('poster_name', 'Title')) | |
authors = meta.get("authors", state.get('authors', 'Authors')) | |
return { | |
"type": "title", | |
"x": self.poster_margin, | |
"y": self.poster_margin, | |
"width": title_width, | |
"height": title_height - 1.0, | |
"content": f"{poster_title}\n{authors}", | |
"font_family": self.title_font_family, | |
"font_size": 100, | |
"author_font_size": 72, | |
"priority": 1.0 | |
} | |
def _create_logo_elements(self, state: PosterState, poster_width: float) -> List[Dict]: | |
"""create logo elements with exact positioning""" | |
elements = [] | |
# get aspect ratio of logos | |
from PIL import Image | |
conf_logo_aspect_ratio = self.layout_constants["default_logo_aspect_ratio"] | |
aff_logo_aspect_ratio = self.layout_constants["default_logo_aspect_ratio"] | |
if state.get("logo_path") and Path(state["logo_path"]).exists(): | |
with Image.open(state["logo_path"]) as img: | |
conf_logo_aspect_ratio = img.size[0] / img.size[1] | |
if state.get("aff_logo_path") and Path(state["aff_logo_path"]).exists(): | |
with Image.open(state["aff_logo_path"]) as img: | |
aff_logo_aspect_ratio = img.size[0] / img.size[1] | |
# calculate logo heights based on fit in 1/3 of poster width | |
column_width = (poster_width - 2 * self.poster_margin - 2 * self.column_spacing) / 3 | |
logo_height = (column_width - 1) / (conf_logo_aspect_ratio + aff_logo_aspect_ratio) | |
# widths based on aspect ratios | |
conf_logo_width = logo_height * conf_logo_aspect_ratio | |
aff_logo_width = logo_height * aff_logo_aspect_ratio | |
conf_logo_x = poster_width - self.poster_margin - conf_logo_width | |
aff_logo_x = conf_logo_x - 1.0 - aff_logo_width | |
if state.get("aff_logo_path"): | |
elements.append({ | |
"type": "aff_logo", | |
"x": aff_logo_x, | |
"y": self.poster_margin, | |
"width": aff_logo_width, | |
"height": logo_height, | |
"priority": 0.9 | |
}) | |
if state.get("logo_path"): | |
elements.append({ | |
"type": "conf_logo", | |
"x": conf_logo_x, | |
"y": self.poster_margin, | |
"width": conf_logo_width, | |
"height": logo_height, | |
"priority": 0.9 | |
}) | |
return elements | |
def _create_section_elements(self, section: Dict, column_x: float, start_y: float, | |
column_width: float, state: PosterState, available_height: float = None) -> List[Dict]: | |
"""create all elements for a section with precise positioning""" | |
elements = [] | |
current_y = start_y | |
# enhanced section title with design styling | |
section_title = section.get("section_title", "") | |
if section_title: | |
title_elements = self._create_section_title_design( | |
section, column_x, current_y, column_width, state | |
) | |
elements.extend(title_elements) | |
# calculate total height used by title and accent elements | |
title_total_height = max(elem["y"] + elem["height"] - current_y for elem in title_elements) | |
current_y += title_total_height + self.config["layout"]["title_to_content_spacing"] | |
# visual assets first (after title, before text) | |
visual_assets = section.get("visual_assets", []) | |
for visual_asset in visual_assets: | |
visual_id = visual_asset.get("visual_id", "") | |
# apply same padding as text elements | |
visual_padding = self.config["layout"]["text_padding"]["left_right"] # left/right padding | |
visual_width = column_width - (2 * visual_padding) | |
final_visual_width, final_visual_height, scale_factor = self._calculate_visual_height(visual_id, visual_width, state, available_height) | |
# center the visual within the section (important for scaled visuals) | |
section_content_width = column_width - (2 * visual_padding) | |
if final_visual_width < section_content_width: | |
# center horizontally within the section | |
visual_x = column_x + visual_padding + (section_content_width - final_visual_width) / 2 | |
else: | |
# use left alignment if visual fills the section | |
visual_x = column_x + visual_padding | |
elements.append({ | |
"type": "visual", | |
"x": visual_x, | |
"y": current_y, | |
"width": final_visual_width, | |
"height": final_visual_height, | |
"visual_id": visual_id, | |
"scale_factor": scale_factor, # for renderer to apply proper scaling | |
"priority": 0.6, | |
"id": f"{section.get('section_id')}_{visual_id}", | |
"font_family": self.body_text_font_family, | |
"font_color": "#000000", | |
"font_size": 44, | |
"line_spacing": 1.0 | |
}) | |
# use the already-scaled height for positioning (no double scaling) | |
current_y += final_visual_height + self.config["layout"]["visual_spacing"]["below_visual"] | |
# text content (after visuals) | |
text_content = section.get("text_content", []) | |
if text_content: | |
combined_text = "\n".join(text_content) | |
text_padding = self.config["layout"]["text_padding"]["left_right"] # consistent with layout positioning | |
text_measurement = measure_text_height( | |
text_content=combined_text, | |
width_inches=column_width - (2 * text_padding), | |
font_name=self.body_text_font_family, | |
font_size=44, | |
line_spacing=1.0 | |
) | |
text_height = text_measurement["optimal_height"] + 0.1 | |
# apply text padding to match measurement calculation | |
elements.append({ | |
"type": "text", | |
"x": column_x + text_padding, | |
"y": current_y, | |
"width": column_width - (2 * text_padding), | |
"height": text_height, | |
"content": combined_text, | |
"font_family": self.body_text_font_family, | |
"font_size": 44, | |
"font_color": "#000000", | |
"priority": 0.5, | |
"id": f"{section.get('section_id')}_text", | |
"line_spacing": 1.0 | |
}) | |
current_y += text_height + 0.3 | |
return elements | |
def _create_section_title_design(self, section: Dict, column_x: float, start_y: float, column_width: float, state: PosterState) -> List[Dict]: | |
"""create section title with colorblock styling""" | |
elements = [] | |
section_title = section.get("section_title", "") | |
section_id = section.get("section_id", "") | |
# get section title design from state | |
title_design = state.get("section_title_design", {}).get("section_title_design", {}) | |
# find specific section application | |
section_app = None | |
for app in title_design.get("section_applications", []): | |
if app.get("section_id") == section_id: | |
section_app = app | |
break | |
# extract styling information | |
title_styling = section_app.get("title_styling", {}) | |
accent_styling = section_app.get("accent_styling", {}) | |
# create title textbox positioning | |
title_padding = self.layout_constants["title_padding"] | |
title_width = column_width - (2 * title_padding) | |
base_title_x = column_x + title_padding | |
# use font size from styling_interfaces | |
styling_interfaces = state.get("styling_interfaces", {}) | |
section_title_font_size = styling_interfaces.get("font_sizes", {}).get("section_title", 64) | |
# calculate rectangle dimensions (same as before) | |
rect_height = section_title_font_size / 72 # convert pt to inches precisely | |
rect_width = rect_height * 0.618 # golden ratio width | |
# apply user-requested coordinate modifications | |
rect_y_offset = 10 / 72 # 10pt converted to inches | |
title_x_offset = rect_height # offset by rectangle height | |
# create rectangle background element with y offset | |
rectangle_element = { | |
"type": "title_accent_block", | |
"x": base_title_x, | |
"y": start_y + rect_y_offset, # user modification: y + 10pt | |
"width": rect_width, | |
"height": rect_height, | |
"color": accent_styling.get("color", "#335f91"), | |
"priority": 0.7 | |
} | |
elements.append(rectangle_element) | |
# adjust title content (add 4 spaces prefix for rectangle_left template) | |
display_title = " " + section_title | |
# create title element with x offset using precise font-based height | |
precise_title_height = section_title_font_size / 72 # pt to inches | |
title_element = { | |
"type": "section_title", | |
"x": base_title_x + title_x_offset, # x + rectangle height | |
"y": start_y, | |
"width": title_width, | |
"height": precise_title_height, | |
"section_title": display_title, | |
"font_family": title_styling.get("font_family", self.section_title_font_family), | |
"font_size": section_title_font_size, | |
"font_weight": title_styling.get("font_weight", "bold"), | |
"font_color": title_styling.get("color", "#000000"), | |
"alignment": title_styling.get("alignment", "left"), | |
"priority": 0.8 | |
} | |
elements.append(title_element) | |
return elements | |
def _validate_precise_layout(self, layout_data: List[Dict], poster_width: float, | |
poster_height: float) -> Dict[str, Any]: | |
"""validate layout for overlaps and overflow""" | |
issues = [] | |
valid = True | |
# check for overflow | |
for element in layout_data: | |
right_edge = element["x"] + element["width"] | |
bottom_edge = element["y"] + element["height"] | |
if right_edge > poster_width: | |
issues.append(f"Element {element.get('id', 'unknown')} overflows right edge") | |
valid = False | |
if bottom_edge > poster_height: | |
issues.append(f"Element {element.get('id', 'unknown')} overflows bottom edge") | |
valid = False | |
# check for overlaps (simplified check) | |
for i, elem1 in enumerate(layout_data): | |
for j, elem2 in enumerate(layout_data[i+1:], i+1): | |
if self._elements_overlap(elem1, elem2): | |
issues.append(f"Elements {elem1.get('id', 'unknown')} and {elem2.get('id', 'unknown')} overlap") | |
valid = False | |
# calculate space utilization | |
total_used_area = sum(elem["width"] * elem["height"] for elem in layout_data) | |
total_poster_area = poster_width * poster_height | |
space_utilization = total_used_area / total_poster_area if total_poster_area > 0 else 0 | |
return { | |
"valid": valid, | |
"issues": issues, | |
"space_utilization": space_utilization, | |
"total_elements": len(layout_data) | |
} | |
def _elements_overlap(self, elem1: Dict, elem2: Dict) -> bool: | |
"""check if two elements overlap""" | |
return not ( | |
elem1["x"] + elem1["width"] <= elem2["x"] or | |
elem2["x"] + elem2["width"] <= elem1["x"] or | |
elem1["y"] + elem1["height"] <= elem2["y"] or | |
elem2["y"] + elem2["height"] <= elem1["y"] | |
) | |
def _create_spatial_layout(self, sections: List[Dict], column_distribution: Dict, | |
available_height: float, column_width: float, state: PosterState) -> List[Dict]: | |
"""create precise spatial layout using css-like calculations""" | |
log_agent_info(self.name, "creating spatial layout with css-like precision") | |
# organize sections by spatial assignment | |
columns = { | |
"left": {"sections": [], "total_height": 0.0}, | |
"middle": {"sections": [], "total_height": 0.0}, | |
"right": {"sections": [], "total_height": 0.0} | |
} | |
for section in sections: | |
column = section.get("column_assignment", "left") | |
if column in columns: | |
columns[column]["sections"].append(section) | |
log_agent_info(self.name, f"organized sections: left={len(columns['left']['sections'])}, middle={len(columns['middle']['sections'])}, right={len(columns['right']['sections'])}") | |
# calculate precise heights for each section | |
for column_name, column_data in columns.items(): | |
for section in column_data["sections"]: | |
section_height = self._calculate_precise_section_height(section, column_width, state, available_height) | |
section["calculated_height"] = section_height | |
column_data["total_height"] += section_height | |
# return layout in expected format | |
return [{ | |
"column_id": 0, | |
"sections": [s for s in sections if s.get("column_assignment") == "left"], | |
"estimated_height": columns["left"]["total_height"] | |
}, { | |
"column_id": 1, | |
"sections": [s for s in sections if s.get("column_assignment") == "middle"], | |
"estimated_height": columns["middle"]["total_height"] | |
}, { | |
"column_id": 2, | |
"sections": [s for s in sections if s.get("column_assignment") == "right"], | |
"estimated_height": columns["right"]["total_height"] | |
}] | |
def _calculate_precise_section_height(self, section: Dict, column_width: float, state: PosterState, available_height: float = None) -> float: | |
"""calculate precise section height using css box model""" | |
total_height = 0.0 | |
# section title height (if exists) | |
title = section.get("section_title", "") | |
if title: | |
title_padding = self.layout_constants["title_padding"] # consistent with layout positioning | |
title_measurement = measure_text_height( | |
text_content=title, | |
width_inches=column_width - (2 * title_padding), # account for padding | |
font_name="Helvetica Neue", | |
font_size=64, | |
line_spacing=1.0 | |
) | |
title_height = title_measurement["optimal_height"] + 0.3 # title margin | |
total_height += title_height | |
# text content height with fixed line spacing | |
text_content = section.get("text_content", []) | |
if text_content: | |
# join all bullet points with proper paragraph separation | |
full_text = "\n\n".join(text_content) # double newline between paragraphs | |
text_padding = self.config["layout"]["text_padding"]["left_right"] # consistent with layout positioning | |
text_measurement = measure_text_height( | |
text_content=full_text, | |
width_inches=column_width - (2 * text_padding), # account for padding | |
font_name=self.body_text_font_family, | |
font_size=44, | |
line_spacing=1.0 | |
) | |
text_height = text_measurement["optimal_height"] + 0.2 # text margin | |
total_height += text_height | |
# visual assets height (fixed aspect ratio) | |
visual_assets = section.get("visual_assets", []) | |
for visual in visual_assets: | |
visual_id = visual.get("visual_id", "") | |
if visual_id: | |
visual_padding = self.layout_constants["visual_padding"] # consistent with layout positioning | |
visual_width = column_width - (2 * visual_padding) | |
final_visual_width, final_visual_height, scale_factor = self._calculate_visual_height(visual_id, visual_width, state, available_height) | |
# use the already-scaled height for section sizing (no double scaling) | |
total_height += final_visual_height + 0.3 # visual margin | |
# section padding and margins | |
section_padding = self.layout_constants["section_padding"] | |
total_height += section_padding | |
return total_height | |
def _calculate_visual_height(self, visual_id: str, visual_width: float, state, available_height: float = None) -> tuple: | |
"""calculate proper visual width and height based on aspect ratio with auto-shrinking for large visuals | |
returns: (final_width, final_height, scale_factor) | |
""" | |
# visual width already accounts for padding (passed from caller) | |
# get aspect ratio from state data | |
images = state.get("images", {}) | |
tables = state.get("tables", {}) | |
# better default aspect ratios based on visual type | |
if visual_id.startswith("table_"): | |
aspect_ratio = 1.5 # tables are often wider than tall | |
elif visual_id.startswith("figure_"): | |
aspect_ratio = 1.2 # figures vary but often slightly wider | |
else: | |
aspect_ratio = self.layout_constants["default_logo_aspect_ratio"] # default square | |
# handle both formats: "figure_1"/"table_1" and "1"/"2" etc. | |
lookup_id = visual_id | |
# if visual_id has prefix, extract the number | |
if visual_id.startswith("figure_"): | |
lookup_id = visual_id.replace("figure_", "") | |
elif visual_id.startswith("table_"): | |
lookup_id = visual_id.replace("table_", "") | |
# check in appropriate collection first based on visual_id prefix | |
if visual_id.startswith("table_") and lookup_id in tables: | |
found_aspect = tables[lookup_id].get("aspect", aspect_ratio) | |
aspect_ratio = found_aspect | |
log_agent_info(self.name, f"found visual {visual_id} -> {lookup_id} in tables, aspect={aspect_ratio:.2f}") | |
elif visual_id.startswith("figure_") and lookup_id in images: | |
found_aspect = images[lookup_id].get("aspect", aspect_ratio) | |
aspect_ratio = found_aspect | |
log_agent_info(self.name, f"found visual {visual_id} -> {lookup_id} in images, aspect={aspect_ratio:.2f}") | |
elif lookup_id in images: | |
found_aspect = images[lookup_id].get("aspect", aspect_ratio) | |
aspect_ratio = found_aspect | |
log_agent_info(self.name, f"found visual {visual_id} -> {lookup_id} in images, aspect={aspect_ratio:.2f}") | |
elif lookup_id in tables: | |
found_aspect = tables[lookup_id].get("aspect", aspect_ratio) | |
aspect_ratio = found_aspect | |
log_agent_info(self.name, f"found visual {visual_id} -> {lookup_id} in tables, aspect={aspect_ratio:.2f}") | |
else: | |
log_agent_warning(self.name, f"visual {visual_id} (lookup: {lookup_id}) not found in state data, using fallback aspect={aspect_ratio:.2f}") | |
# debug: log available visual data | |
log_agent_info(self.name, f"available images: {list(images.keys())}") | |
log_agent_info(self.name, f"available tables: {list(tables.keys())}") | |
# calculate original height from aspect ratio | |
original_height = visual_width / aspect_ratio | |
# check if shrinking is needed (height > 40% of column height) | |
scale_factor = 1.0 | |
if available_height and original_height > (available_height * 0.4): | |
scale_factor = 0.8 # shrink to 80% of original size | |
log_agent_info(self.name, f"visual {visual_id} too large ({original_height:.2f}\" > 40% of {available_height:.2f}\"), shrinking to 80%") | |
# apply scaling to both width and height to maintain aspect ratio | |
final_width = visual_width * scale_factor | |
final_height = original_height * scale_factor | |
log_agent_info(self.name, f"visual {visual_id}: orig_w={visual_width:.2f}\", orig_h={original_height:.2f}\", scale={scale_factor:.1f}, final_w={final_width:.2f}\", final_h={final_height:.2f}\"") | |
# return final width, height and scale factor for rendering | |
return final_width, final_height, scale_factor | |
def layout_agent_node(state: PosterState) -> Dict[str, Any]: | |
result = LayoutAgent()(state) | |
return { | |
**state, | |
"design_layout": result["design_layout"], | |
"column_assignment": result.get("column_assignment"), | |
"tokens": result["tokens"], | |
"current_agent": result["current_agent"], | |
"errors": result["errors"] | |
} |