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"] | |
| } |