""" font styling and keyword highlighting """ import json import re from pathlib import Path from typing import Dict, Any, List 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.config.poster_config import load_config from jinja2 import Template class FontAgent: """handles text styling and keyword highlighting""" def __init__(self): self.name = "font_agent" self.keyword_extraction_prompt = load_prompt("config/prompts/extract_keywords.txt") def __call__(self, state: PosterState) -> PosterState: log_agent_info(self.name, "starting font styling") try: design_layout = state.get("design_layout", []) color_scheme = state.get("color_scheme", {}) story_board = state.get("story_board", {}) if not design_layout: raise ValueError("missing design_layout from layout agent") if not color_scheme: raise ValueError("missing color_scheme from color agent") if not story_board: raise ValueError("missing story_board from story board curator") # identify keywords to highlight keywords = self._identify_keywords(story_board, state) # apply styling to layout styled_layout = self._apply_styling(design_layout, color_scheme, keywords, state) state["styled_layout"] = styled_layout state["keywords"] = keywords state["current_agent"] = self.name self._save_styled_layout(state) # count total keywords across all sections total_keywords = sum(len(kw_list) for kw_list in keywords.get("section_keywords", {}).values()) log_agent_success(self.name, f"applied enhanced styling to {len(styled_layout)} elements") log_agent_success(self.name, f"identified {total_keywords} keywords for highlighting") except Exception as e: log_agent_error(self.name, f"failed: {e}") state["errors"].append(f"{self.name}: {e}") return state def _identify_keywords(self, story_board: Dict, state: PosterState) -> Dict[str, Any]: """identify keywords using story board content and enhanced narrative""" enhanced_narrative = state.get("enhanced_narrative", {}) # extract keywords using LLM with external prompt log_agent_info(self.name, "identifying keywords for highlighting") agent = LangGraphAgent("expert at identifying key terms for visual highlighting", state["text_model"]) template_data = { "enhanced_narrative": json.dumps(enhanced_narrative, indent=2), "curated_content": json.dumps(story_board, indent=2) } prompt = Template(self.keyword_extraction_prompt).render(**template_data) response = agent.step(prompt) result = extract_json(response.content) # add token usage state["tokens"].add_text(response.input_tokens, response.output_tokens) return result def _apply_styling(self, layout: List[Dict], colors: Dict, keywords: Dict, state: PosterState) -> List[Dict]: """apply styling with proper bullet point and bold formatting""" styled_layout = [] section_keywords = keywords.get("section_keywords", {}) # process all elements with enhanced styling for element in layout: styled_element = element.copy() # apply element-specific styling if element.get("type") == "title": self._apply_title_styling(styled_element, colors) elif element.get("type") in ["section_title", "title_accent_block", "title_accent_line"]: # these are handled by the section title designer pass elif element.get("type") == "section_container": self._apply_section_container_styling(styled_element, colors) elif element.get("type") in ["text", "visual", "mixed"]: self._apply_content_styling(styled_element, colors, section_keywords) elif element.get("type") in ["conf_logo", "aff_logo"]: # logos don't need text styling pass styled_layout.append(styled_element) # sort by priority for proper rendering order styled_layout.sort(key=lambda x: x.get("priority", 0.5)) return styled_layout def _apply_title_styling(self, element: Dict, colors: Dict): """apply styling to title elements""" element["font_family"] = "Helvetica Neue" element["font_color"] = colors.get("text_on_theme", "#FFFFFF") element["font_size"] = 100 element["author_font_size"] = 72 element["font_weight"] = "bold" def _apply_section_container_styling(self, element: Dict, colors: Dict): """apply styling to section container elements""" element["border_color"] = colors.get("mono_light", "#CCCCCC") element["border_width"] = 1 element["fill_color"] = "#FFFFFF" # white background def _apply_content_styling(self, element: Dict, colors: Dict, section_keywords: Dict): """apply styling to content elements with keyword highlighting""" # determine parent section for keyword lookup parent_section = self._extract_parent_section(element) keywords_for_section = section_keywords.get(parent_section, {}) # ensure proper bullet point formatting first (before keyword highlighting to preserve formatting) if element.get("content"): element["content"] = self._format_bullet_points(element["content"]) # apply keyword highlighting to content (after bullet formatting) if keywords_for_section and element.get("content"): content = element["content"] original_content = content content = self._apply_keyword_highlighting(content, keywords_for_section, colors) element["content"] = content # debug logging if content != original_content: total_keywords = sum(len(kw_list) for kw_list in keywords_for_section.values() if isinstance(kw_list, list)) log_agent_info(self.name, f"Applied highlighting to {parent_section}: found {total_keywords} keywords") elif keywords_for_section: total_keywords = sum(len(kw_list) for kw_list in keywords_for_section.values() if isinstance(kw_list, list)) log_agent_warning(self.name, f"Keywords found for {parent_section} ({total_keywords} total) but no highlighting applied") # apply base text styling element["font_family"] = "Arial" element["font_color"] = colors.get("text", "#000000") element["font_size"] = 44 def _extract_parent_section(self, element: Dict) -> str: """extract parent section id from element""" element_id = element.get("id", "") # extract section id from element id if "_" in element_id and element_id.endswith("_text"): # remove the "_text" suffix to get the section ID return element_id[:-5] # remove last 5 characters ("_text") elif "_" in element_id: # fallback: remove last part after underscore parts = element_id.split("_") if len(parts) > 1: return "_".join(parts[:-1]) return "" def _apply_keyword_highlighting(self, content: str, keywords: Dict, colors: Dict) -> str: """apply semantic-based keyword highlighting with three distinct styles""" # use contrast color for highlighting highlight_color = colors.get("contrast", colors.get("theme", "#1E3A8A")) # define highlighting styles based on semantic categories style_functions = { "bold_contrast": lambda text: f"{text}", # contrast color (bold applied automatically in renderer) "bold": lambda text: f"**{text}**", # just bold "italic": lambda text: f"*{text}*" # italic } # apply each style category for style_type, style_func in style_functions.items(): keyword_list = keywords.get(style_type, []) for keyword in keyword_list: if not keyword.strip(): continue content = self._highlight_keyword_in_content(content, keyword, style_func) return content def _highlight_keyword_in_content(self, content: str, keyword: str, style_func) -> str: """highlight a specific keyword in content""" if f" str: """ensure proper bullet point formatting""" if not content: return content lines = content.split('\n') formatted_lines = [] for line in lines: line = line.strip() if not line: continue # ensure start with '•' or preserve existing '•' if line.startswith('• '): formatted_lines.append(line) elif line.startswith('- '): # dash -> bullet formatted_lines.append('• ' + line[2:]) elif line.startswith('* '): # asterisk -> bullet formatted_lines.append('• ' + line[2:]) elif not line.startswith('•'): # add bullet if missing (for content that should be bulleted) if any(line.lower().startswith(word) for word in ['the ', 'this ', 'our ', 'we ', 'new ', 'key ', 'main ']): formatted_lines.append('• ' + line) else: formatted_lines.append(line) else: formatted_lines.append(line) return '\n'.join(formatted_lines) def get_styling_interfaces(self) -> Dict[str, Any]: """return interfaces for renderer to properly handle styled content""" config = load_config() font_params = config["typography"] return { "bullet_point_marker": "•", "bold_start_tag": "**", "bold_end_tag": "**", "italic_start_tag": "*", "italic_end_tag": "*", "color_start_tag": "", "line_spacing": font_params["line_spacing"], # from config "paragraph_spacing": font_params["paragraph_spacing"], "font_sizes": { "title": font_params["sizes"]["title"], "authors": font_params["sizes"]["authors"], "section_title": font_params["sizes"]["section_title"], "body_text": font_params["sizes"]["body_text"] } } def _save_styled_layout(self, state: PosterState): """save styled layout and keywords""" output_dir = Path(state["output_dir"]) / "content" output_dir.mkdir(parents=True, exist_ok=True) # styled layout with open(output_dir / "styled_layout.json", "w", encoding='utf-8') as f: json.dump(state.get("styled_layout", []), f, indent=2) # keywords with open(output_dir / "keywords.json", "w", encoding='utf-8') as f: json.dump(state.get("keywords", {}), f, indent=2) # styling interfaces with open(output_dir / "styling_interfaces.json", "w", encoding='utf-8') as f: json.dump(self.get_styling_interfaces(), f, indent=2) def font_agent_node(state: PosterState) -> Dict[str, Any]: result = FontAgent()(state) return { **state, "styled_layout": result["styled_layout"], "keywords": result.get("keywords"), "tokens": result["tokens"], "current_agent": result["current_agent"], "errors": result["errors"] }