Spaces:
Running
Running
| """ | |
| powerpoint rendering using python-pptx | |
| """ | |
| import re | |
| import qrcode | |
| from pathlib import Path | |
| from typing import Dict, Any, Optional | |
| import json | |
| from pptx import Presentation | |
| from pptx.util import Inches, Pt | |
| from pptx.enum.text import PP_ALIGN, MSO_AUTO_SIZE, MSO_VERTICAL_ANCHOR | |
| from pptx.enum.shapes import MSO_SHAPE | |
| from pptx.dml.color import RGBColor | |
| from PIL import Image | |
| from src.state.poster_state import PosterState | |
| from utils.src.logging_utils import log_agent_info, log_agent_success, log_agent_error | |
| from src.config.poster_config import load_config | |
| class Renderer: | |
| """powerpoint rendering with styling support""" | |
| def __init__(self): | |
| self.name = "renderer" | |
| self.styling_interfaces = None | |
| # load configuration | |
| self.config = load_config() | |
| self.layout_constants = self.config["layout_constants"] | |
| self.powerpoint_config = self.config["powerpoint"] | |
| self.indentation_config = self.config["indentation"] | |
| self.typography_config = self.config["typography"] | |
| def __call__(self, state: PosterState) -> PosterState: | |
| log_agent_info(self.name, "Starting Rendering Process") | |
| try: | |
| self.styling_interfaces = self._load_styling_interfaces(state) | |
| output_path = Path(state["output_dir"]) / f"{state['poster_name']}.pptx" | |
| self._render_presentation(state, output_path) | |
| # # convert to png if possible | |
| # png_path = self._convert_to_png(output_path) | |
| # log_agent_success(self.name, f"rendered poster: {output_path}") | |
| # if png_path: | |
| # log_agent_success(self.name, f"generated preview: {png_path}") | |
| except Exception as e: | |
| log_agent_error(self.name, f"rendering failed: {e}") | |
| state["errors"].append(f"{self.name}: {e}") | |
| return state | |
| def _load_styling_interfaces(self, state: PosterState) -> Dict[str, Any]: | |
| """load styling interfaces from font agent output file""" | |
| styling_path = Path(state["output_dir"]) / "content" / "styling_interfaces.json" | |
| if styling_path.exists(): | |
| with open(styling_path, 'r', encoding='utf-8') as f: | |
| interfaces = json.load(f) | |
| interfaces["line_spacing"] = 1.0 | |
| return interfaces | |
| else: | |
| # fallback to defaults with 1.0 line spacing | |
| return { | |
| "bullet_point_marker": "•", | |
| "bold_start_tag": "**", | |
| "bold_end_tag": "**", | |
| "italic_start_tag": "*", | |
| "italic_end_tag": "*", | |
| "color_start_tag": "<color:", | |
| "color_end_tag": "</color>", | |
| "line_spacing": 1.0, | |
| "paragraph_spacing": 0.1 | |
| } | |
| def _render_presentation(self, state: PosterState, output_path: Path): | |
| """render complete presentation""" | |
| prs = Presentation() | |
| prs.slide_width = Inches(state["poster_width"]) | |
| prs.slide_height = Inches(state["poster_height"]) | |
| slide = prs.slides.add_slide(prs.slide_layouts[6]) | |
| # TODO: generate QR code if needed | |
| qr_code_path = None | |
| if state.get("url"): | |
| qr_code_path = self._generate_qr_code(state["url"], state["output_dir"]) | |
| # use styled_layout if available, fallback to design_layout | |
| layout_data = state.get("styled_layout", state.get("design_layout", [])) | |
| if not layout_data: | |
| raise ValueError("no styled_layout or design_layout found") | |
| # sort elements by priority to ensure proper rendering order | |
| sorted_elements = sorted(layout_data, key=lambda x: x.get("priority", 0.5)) | |
| for element in sorted_elements: | |
| self._render_element(slide, element, state, qr_code_path) | |
| prs.save(output_path) | |
| def _render_element(self, slide, element: Dict, state: PosterState, qr_code_path: Optional[str]): | |
| """render individual element based on type""" | |
| element_type = element.get("type") | |
| # handle QR code elements | |
| if element_type == "qr_code" and qr_code_path: | |
| self._render_qr_code(slide, element, qr_code_path) | |
| return | |
| # get appropriate renderer | |
| renderer_map = { | |
| "title": self._render_title, | |
| "section_title": self._render_section_title, | |
| "title_accent_block": self._render_title_accent_block, | |
| "title_accent_line": self._render_title_accent_line, | |
| "conf_logo": self._render_conf_logo, | |
| "aff_logo": self._render_aff_logo, | |
| "section_container": self._render_section_container, | |
| "text": self._render_text, | |
| "visual": self._render_visual, | |
| "mixed": self._render_mixed, | |
| } | |
| renderer = renderer_map.get(element_type) | |
| if renderer: | |
| renderer(slide, element, state) | |
| else: | |
| log_agent_error(self.name, f"unknown element type: {element_type}") | |
| def _render_title(self, slide, element: Dict, state: PosterState): | |
| """render poster title with authors""" | |
| x, y, w, h = (Inches(element[k]) for k in ["x", "y", "width", "height"]) | |
| log_agent_info(self.name, f"rendering title at ({x.inches:.1f}, {y.inches:.1f})") | |
| tb = slide.shapes.add_textbox(x, y, w, h) | |
| tf = tb.text_frame | |
| tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE # Make sure title fits in the fixed-height textbox | |
| tf.word_wrap = True | |
| content = element.get("content", "Title\nAuthors") | |
| lines = content.split("\n") | |
| # separate title and authors | |
| title_lines = lines[:-1] if len(lines) > 1 else lines | |
| authors_text = lines[-1] if len(lines) > 1 else "" | |
| # add title lines | |
| for i, title_line in enumerate(title_lines): | |
| if i == 0: | |
| p = tf.paragraphs[0] | |
| else: | |
| p = tf.add_paragraph() | |
| p.text = title_line.strip() | |
| p.font.name = element.get("font_family", "Helvetica Neue") | |
| title_font_size = self.styling_interfaces.get("font_sizes", {}).get("title", 100) | |
| p.font.size = Pt(element.get("font_size", title_font_size)) | |
| p.font.bold = True | |
| p.font.color.rgb = RGBColor(0, 0, 0) # black for readability | |
| p.alignment = PP_ALIGN.LEFT | |
| p.line_spacing = self.typography_config["line_spacing"] | |
| # add authors | |
| if authors_text: | |
| p_authors = tf.add_paragraph() | |
| p_authors.text = authors_text.strip() | |
| p_authors.font.name = "Arial" | |
| authors_font_size = self.styling_interfaces.get("font_sizes", {}).get("authors", 72) | |
| p_authors.font.size = Pt(element.get("author_font_size", authors_font_size)) | |
| p_authors.font.color.rgb = RGBColor(60, 60, 60) # dark gray | |
| p_authors.alignment = PP_ALIGN.LEFT | |
| p_authors.line_spacing = self.typography_config["line_spacing"] + 0.1 # slightly looser for authors | |
| def _render_section_title(self, slide, element: Dict, state: PosterState): | |
| """render section title with enhanced styling""" | |
| x, y, w, h = (Inches(element[k]) for k in ["x", "y", "width", "height"]) | |
| section_title = element.get("section_title", "").strip() | |
| if not section_title: | |
| return | |
| log_agent_info(self.name, f"rendering section title: '{section_title}'") | |
| # create textbox for section title | |
| textbox = slide.shapes.add_textbox(x, y, w, h) | |
| tf = textbox.text_frame | |
| tf.auto_size = MSO_AUTO_SIZE.NONE | |
| tf.word_wrap = False | |
| tf.clear() | |
| tf.vertical_anchor = MSO_VERTICAL_ANCHOR.TOP | |
| # use existing first paragraph to avoid extra newline | |
| if len(tf.paragraphs) > 0: | |
| p = tf.paragraphs[0] | |
| else: | |
| p = tf.add_paragraph() | |
| p.text = section_title | |
| p.font.name = element.get("font_family", "Helvetica Neue") | |
| section_title_font_size = self.styling_interfaces.get("font_sizes", {}).get("section_title", 48) | |
| p.font.size = Pt(element.get("font_size", section_title_font_size)) | |
| p.font.bold = element.get("font_weight", "bold") == "bold" | |
| # apply color styling | |
| font_color = element.get("font_color", "#000000") | |
| p.font.color.rgb = self._parse_color(font_color) | |
| # apply alignment based on design template | |
| alignment = element.get("alignment", "left").lower() | |
| if alignment == "center": | |
| p.alignment = PP_ALIGN.CENTER | |
| elif alignment == "right": | |
| p.alignment = PP_ALIGN.RIGHT | |
| else: | |
| p.alignment = PP_ALIGN.LEFT | |
| def _render_title_accent_block(self, slide, element: Dict, state: PosterState): | |
| """render color block accent for section titles""" | |
| x, y, w, h = (Inches(element[k]) for k in ["x", "y", "width", "height"]) | |
| # use 'color' field from layout agent | |
| fill_color = element.get("color", element.get("fill_color", "#1E3A8A")) | |
| log_agent_info(self.name, f"rendering title accent block: {fill_color} at ({x.inches:.2f}, {y.inches:.2f})") | |
| # create rectangle shape | |
| rect = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h) | |
| rect.fill.solid() | |
| rect.fill.fore_color.rgb = self._parse_color(fill_color) | |
| rect.line.fill.background() # no border | |
| def _render_title_accent_line(self, slide, element: Dict, state: PosterState): | |
| """render underline accent for section titles""" | |
| x, y, w, h = (Inches(element[k]) for k in ["x", "y", "width", "height"]) | |
| # use 'color' field from layout agent | |
| fill_color = element.get("color", element.get("fill_color", "#E8E8E8")) | |
| log_agent_info(self.name, f"rendering title accent line: {fill_color} at ({x.inches:.2f}, {y.inches:.2f})") | |
| # create thin rectangle for line | |
| line = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h) | |
| line.fill.solid() | |
| line.fill.fore_color.rgb = self._parse_color(fill_color) | |
| line.line.fill.background() # no border | |
| def _render_section_container(self, slide, element: Dict, state: PosterState): | |
| """render section container with optional debug border and mono_light background for critical sections""" | |
| x, y, w, h = (Inches(element[k]) for k in ["x", "y", "width", "height"]) | |
| is_debug = element.get("debug_border", False) | |
| importance_level = element.get("importance_level", 2) | |
| # create base rectangle | |
| container = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h) | |
| # apply background fill based on importance level | |
| if importance_level == 1: | |
| # critical section gets mono_light background color | |
| color_scheme = state.get("color_scheme", {}) | |
| mono_light = color_scheme.get("mono_light", "#e6eaef") | |
| container.fill.solid() | |
| container.fill.fore_color.rgb = self._parse_color(mono_light) | |
| log_agent_info(self.name, f"applied mono_light background ({mono_light}) to critical section") | |
| else: | |
| # non-critical sections remain transparent | |
| container.fill.background() | |
| # apply border based on debug mode | |
| if is_debug: | |
| # prominent debug border | |
| container.line.color.rgb = RGBColor(255, 0, 0) # red border | |
| container.line.width = Pt(2) | |
| log_agent_info(self.name, f"added debug section border") | |
| else: | |
| container.line.fill.background() | |
| def _render_text(self, slide, element: Dict, state: PosterState): | |
| """render text elements with enhanced formatting""" | |
| x, y, w, h = (Inches(element[k]) for k in ["x", "y", "width", "height"]) | |
| content = element.get("content", "").strip() | |
| if not content: | |
| return | |
| log_agent_info(self.name, f"rendering text element: {element.get('id', 'unknown')}") | |
| # add text with margins | |
| margin = self.layout_constants["text_margin_renderer"] # reduced margin for better space utilization | |
| self._add_enhanced_text( | |
| slide, content, | |
| x + Inches(margin), y, | |
| w - Inches(2 * margin), h, | |
| element | |
| ) | |
| def _render_visual(self, slide, element: Dict, state: PosterState): | |
| """render visual elements with proper aspect ratio and scaling""" | |
| x, y, w, h = (Inches(element[k]) for k in ["x", "y", "width", "height"]) | |
| visual_id = element.get("visual_id") | |
| scale_factor = element.get("scale_factor", 1.0) # default to no scaling | |
| if visual_id: | |
| # layout agent already calculated padding, use exact positioning | |
| self._add_visual_with_aspect_ratio( | |
| slide, visual_id, | |
| x, y, w, h, | |
| state, scale_factor | |
| ) | |
| def _render_mixed(self, slide, element: Dict, state: PosterState): | |
| """render mixed elements (text and visual)""" | |
| # for now, treat as text element | |
| self._render_text(slide, element, state) | |
| def _render_conf_logo(self, slide, element: Dict, state: PosterState): | |
| """render conference logo""" | |
| logo_path = state.get("logo_path") | |
| if logo_path and Path(logo_path).exists(): | |
| self._render_logo_with_aspect_ratio(slide, element, logo_path) | |
| def _render_aff_logo(self, slide, element: Dict, state: PosterState): | |
| """render affiliation logo""" | |
| aff_logo_path = state.get("aff_logo_path") | |
| if aff_logo_path and Path(aff_logo_path).exists(): | |
| self._render_logo_with_aspect_ratio(slide, element, aff_logo_path) | |
| def _add_enhanced_text(self, slide, text: str, left, top, width, height, element: Dict): | |
| """add text with enhanced formatting support""" | |
| if not text.strip(): | |
| return | |
| textbox = slide.shapes.add_textbox(left, top, width, height) | |
| tf = textbox.text_frame | |
| tf.auto_size = MSO_AUTO_SIZE.NONE | |
| tf.word_wrap = True | |
| tf.clear() | |
| # enforce height constraints to prevent text overflow beyond textbox bounds | |
| tf.vertical_anchor = MSO_VERTICAL_ANCHOR.TOP | |
| # add small margins to ensure text stays within bounds | |
| tf.margin_top = Inches(0.05) | |
| tf.margin_bottom = Inches(0.05) | |
| # get font properties from element | |
| font_family = element.get("font_family", "Arial") | |
| font_size = element.get("font_size", 40) | |
| font_color = element.get("font_color", "#000000") | |
| line_spacing = element.get("line_spacing", self.styling_interfaces["line_spacing"]) | |
| self._format_enhanced_text(tf, text, font_family, font_size, font_color, line_spacing) | |
| # debug info for formatting | |
| total_runs = sum(len(p.runs) for p in tf.paragraphs) | |
| log_agent_info(self.name, f"created {len(tf.paragraphs)} paragraphs with {total_runs} formatted runs") | |
| def _format_enhanced_text(self, text_frame, text: str, font_family: str, font_size: int, font_color: str, line_spacing: float): | |
| """format text with enhanced bullet point and bold support using 1.0 line spacing""" | |
| text_frame.clear() | |
| body_text_font_size = self.styling_interfaces.get("font_sizes", {}).get("body_text", 40) | |
| effective_font_size = font_size if font_size != 40 else body_text_font_size | |
| base_font_size = Pt(max(effective_font_size, 36)) # minimum 36pt | |
| base_color = self._parse_color(font_color) | |
| # split by single newlines only (treat as simple line breaks) | |
| lines = text.split('\n') | |
| for line_idx, line in enumerate(lines): | |
| original_line = line # keep original line for indentation detection | |
| line = line.strip() | |
| if not line: | |
| continue | |
| # create paragraph for each line | |
| if line_idx == 0 and len(text_frame.paragraphs) > 0: | |
| p = text_frame.paragraphs[0] | |
| else: | |
| p = text_frame.add_paragraph() | |
| # handle indentation by checking if line starts with ◦ (sub-bullet) | |
| if line.strip().startswith(self.indentation_config["secondary_bullet_char"]): # secondary bullet character | |
| # set paragraph level for indentation | |
| p.level = self.indentation_config["secondary_level"] | |
| else: | |
| p.level = self.indentation_config["primary_level"] | |
| # add formatted text content (don't clear p.text) | |
| self._add_formatted_runs(p, line, font_family, base_font_size, base_color) | |
| # set paragraph properties - force 1.0 line spacing | |
| p.alignment = PP_ALIGN.LEFT | |
| p.line_spacing = self.typography_config["line_spacing"] # fixed 1.0 line spacing | |
| def _add_formatted_runs(self, paragraph, text: str, font_family: str, | |
| base_font_size, base_color): | |
| """add text with all formatting as separate runs - following pptx best practices""" | |
| self._parse_and_add_runs(paragraph, text, font_family, base_font_size, base_color) | |
| def _parse_and_add_runs(self, paragraph, text: str, font_family: str, | |
| base_font_size, base_color): | |
| """parse text and create separate runs for each format type""" | |
| # tokenize the text into formatting segments | |
| segments = self._tokenize_formatting(text) | |
| # create runs for each segment | |
| for segment in segments: | |
| run = paragraph.add_run() | |
| run.text = segment['text'] | |
| run.font.name = font_family | |
| run.font.size = base_font_size | |
| # apply formatting based on segment type | |
| if segment['color']: | |
| run.font.color.rgb = self._parse_color(segment['color']) | |
| else: | |
| run.font.color.rgb = base_color | |
| if segment['bold']: | |
| run.font.bold = True | |
| if segment['italic']: | |
| run.font.italic = True | |
| def _tokenize_formatting(self, text: str) -> list: | |
| """tokenize text into formatting segments with precise position tracking""" | |
| segments = [] | |
| i = 0 | |
| while i < len(text): | |
| # check for color markup: <color:#RRGGBB>text</color> | |
| color_match = re.match(r'<color:(#[0-9A-Fa-f]{6})>', text[i:]) | |
| if color_match: | |
| color_hex = color_match.group(1) | |
| opening_tag_end = i + color_match.end() | |
| # find closing </color> tag using absolute position | |
| closing_tag_pattern = r'</color>' | |
| color_content_start = opening_tag_end | |
| closing_match = re.search(closing_tag_pattern, text[color_content_start:]) | |
| if closing_match: | |
| # calculate absolute positions | |
| color_content_end = color_content_start + closing_match.start() | |
| closing_tag_end = color_content_start + closing_match.end() | |
| # extract content between color tags | |
| colored_text = text[color_content_start:color_content_end] | |
| # process colored text with automatic bold | |
| if colored_text.strip(): # only process non-empty content | |
| segments.append({ | |
| 'text': colored_text, | |
| 'bold': True, # all colored text is bold | |
| 'italic': False, | |
| 'color': color_hex | |
| }) | |
| # move past the entire color block | |
| i = closing_tag_end | |
| continue | |
| else: | |
| # malformed color tag, treat as regular text | |
| segments.append({ | |
| 'text': text[i], | |
| 'bold': False, | |
| 'italic': False, | |
| 'color': None | |
| }) | |
| i += 1 | |
| continue | |
| # check for bold: **text** | |
| bold_match = re.match(r'\*\*(.*?)\*\*', text[i:]) | |
| if bold_match: | |
| bold_text = bold_match.group(1) | |
| segments.append({ | |
| 'text': bold_text, | |
| 'bold': True, | |
| 'italic': False, | |
| 'color': None | |
| }) | |
| i += bold_match.end() | |
| continue | |
| # check for italic: *text* | |
| italic_match = re.match(r'\*(.*?)\*', text[i:]) | |
| if italic_match: | |
| italic_text = italic_match.group(1) | |
| segments.append({ | |
| 'text': italic_text, | |
| 'bold': False, | |
| 'italic': True, | |
| 'color': None | |
| }) | |
| i += italic_match.end() | |
| continue | |
| # regular text - find next formatting marker | |
| next_format = re.search(r'(\*\*|\*|<color:)', text[i:]) | |
| if next_format: | |
| regular_text = text[i:i + next_format.start()] | |
| else: | |
| regular_text = text[i:] | |
| if regular_text: | |
| segments.append({ | |
| 'text': regular_text, | |
| 'bold': False, | |
| 'italic': False, | |
| 'color': None | |
| }) | |
| if next_format: | |
| i += next_format.start() | |
| else: | |
| break | |
| return segments | |
| def _parse_bold_italic(self, text: str, color: str) -> list: | |
| """simplified bold/italic parser - only used for nested formatting""" | |
| segments = [] | |
| i = 0 | |
| while i < len(text): | |
| # check for bold | |
| bold_match = re.match(r'\*\*(.*?)\*\*', text[i:]) | |
| if bold_match: | |
| bold_text = bold_match.group(1) | |
| segments.append({ | |
| 'text': bold_text, | |
| 'bold': True, | |
| 'italic': False, | |
| 'color': color | |
| }) | |
| i += bold_match.end() | |
| continue | |
| # check for italic | |
| italic_match = re.match(r'\*(.*?)\*', text[i:]) | |
| if italic_match: | |
| italic_text = italic_match.group(1) | |
| segments.append({ | |
| 'text': italic_text, | |
| 'bold': bool(color), # force bold if color is present | |
| 'italic': True, | |
| 'color': color | |
| }) | |
| i += italic_match.end() | |
| continue | |
| # regular text | |
| next_format = re.search(r'(\*\*|\*)', text[i:]) | |
| if next_format: | |
| regular_text = text[i:i + next_format.start()] | |
| else: | |
| regular_text = text[i:] | |
| if regular_text: | |
| segments.append({ | |
| 'text': regular_text, | |
| 'bold': bool(color), # force bold if color is present | |
| 'italic': False, | |
| 'color': color | |
| }) | |
| if next_format: | |
| i += next_format.start() | |
| else: | |
| break | |
| return segments | |
| def _add_visual_with_aspect_ratio(self, slide, visual_id: str, left, top, width, height, state, scale_factor: float = 1.0): | |
| """add visual with proper aspect ratio preservation and optional scaling""" | |
| visual_path = self._get_visual_path(visual_id, state) | |
| if visual_path and Path(visual_path).exists(): | |
| try: | |
| # calculate proper size maintaining aspect ratio | |
| with Image.open(visual_path) as img: | |
| orig_width, orig_height = img.size | |
| aspect_ratio = orig_width / orig_height | |
| # get allocated space from layout | |
| available_width = width.inches if hasattr(width, 'inches') else float(width) | |
| available_height = height.inches if hasattr(height, 'inches') else float(height) | |
| # always use exact dimensions and positioning from JSON | |
| final_width = Inches(available_width) | |
| final_height = Inches(available_height) | |
| centered_left = left | |
| centered_top = top | |
| slide.shapes.add_picture(visual_path, centered_left, centered_top, width=final_width, height=final_height) | |
| if scale_factor < 1.0: | |
| log_agent_info(self.name, f"visual {visual_id} uses layout-calculated dimensions (scale_factor={scale_factor:.1f} already applied)") | |
| except Exception as e: | |
| log_agent_error(self.name, f"failed to add visual {visual_id}: {e}") | |
| def _render_logo_with_aspect_ratio(self, slide, element: Dict, image_path: str): | |
| """render logo with proper aspect ratio preservation""" | |
| x, y, w, h = (Inches(element[k]) for k in ["x", "y", "width", "height"]) | |
| try: | |
| # calculate dimensions while preserving aspect ratio | |
| with Image.open(image_path) as img: | |
| orig_width, orig_height = img.size | |
| aspect_ratio = orig_width / orig_height | |
| available_width = w.inches if hasattr(w, 'inches') else float(w) | |
| available_height = h.inches if hasattr(h, 'inches') else float(h) | |
| # fit image within available space | |
| if available_width / aspect_ratio <= available_height: | |
| final_width = Inches(available_width) | |
| final_height = Inches(available_width / aspect_ratio) | |
| else: | |
| final_height = Inches(available_height) | |
| final_width = Inches(available_height * aspect_ratio) | |
| # center the image | |
| centered_left = x + (w - final_width) / 2 | |
| centered_top = y + (h - final_height) / 2 | |
| slide.shapes.add_picture(image_path, centered_left, centered_top, | |
| width=final_width, height=final_height) | |
| except Exception as e: | |
| log_agent_error(self.name, f"failed to render logo: {e}") | |
| def _get_visual_path(self, visual_id: str, state: PosterState) -> Optional[str]: | |
| """get path to visual asset""" | |
| images = state.get("images", {}) | |
| tables = state.get("tables", {}) | |
| vid = (visual_id or "").split('_')[-1] | |
| if visual_id.startswith("figure"): | |
| return images.get(vid, {}).get("path") | |
| if visual_id.startswith("table"): | |
| return tables.get(vid, {}).get("path") | |
| return None | |
| def _parse_color(self, color_str: str) -> RGBColor: | |
| """parse color string to RGBColor""" | |
| hex_color = color_str.lstrip('#') | |
| r, g, b = (int(hex_color[i:i+2], 16) for i in (0, 2, 4)) | |
| return RGBColor(r, g, b) | |
| def _generate_qr_code(self, url: str, output_dir: str) -> str: | |
| """generate QR code for URL""" | |
| qr = qrcode.QRCode( | |
| version=1, | |
| error_correction=qrcode.constants.ERROR_CORRECT_L, | |
| box_size=10, | |
| border=2, | |
| ) | |
| qr.add_data(url) | |
| qr.make(fit=True) | |
| img = qr.make_image(fill_color="black", back_color="white") | |
| qr_path = Path(output_dir) / "qr_code.png" | |
| img.save(qr_path) | |
| return str(qr_path) | |
| def _render_qr_code(self, slide, element: Dict, qr_code_path: str): | |
| """render QR code element""" | |
| x, y, w, h = (Inches(element[k]) for k in ["x", "y", "width", "height"]) | |
| slide.shapes.add_picture(qr_code_path, x, y, w, h) | |
| def _convert_to_png(self, pptx_path: Path) -> Optional[str]: | |
| """convert PPTX to PNG using LibreOffice""" | |
| try: | |
| import subprocess | |
| output_dir = pptx_path.parent | |
| import platform | |
| system = platform.system().lower() | |
| if system == "windows": | |
| libreoffice_paths = [ | |
| r"C:\Program Files\LibreOffice\program\soffice.exe", | |
| r"C:\Program Files (x86)\LibreOffice\program\soffice.exe", | |
| r"C:\Users\%USERNAME%\AppData\Local\Programs\LibreOffice\program\soffice.exe", | |
| "soffice.exe", | |
| "libreoffice.exe" | |
| ] | |
| elif system == "linux": | |
| libreoffice_paths = [ | |
| "/usr/bin/libreoffice", | |
| "/usr/local/bin/libreoffice", | |
| "/snap/bin/libreoffice", | |
| "/usr/bin/soffice", | |
| "libreoffice", | |
| "soffice" | |
| ] | |
| elif system == "darwin": # macOS | |
| libreoffice_paths = [ | |
| "/Applications/LibreOffice.app/Contents/MacOS/soffice", | |
| "/usr/local/bin/libreoffice", | |
| "libreoffice", | |
| "soffice" | |
| ] | |
| else: | |
| libreoffice_paths = [ | |
| "libreoffice", | |
| "soffice" | |
| ] | |
| for lo_path in libreoffice_paths: | |
| try: | |
| cmd = [ | |
| lo_path, "--headless", "--convert-to", "png", | |
| "--outdir", str(output_dir), str(pptx_path) | |
| ] | |
| result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) | |
| if result.returncode == 0: | |
| png_name = pptx_path.stem + ".png" | |
| png_path = output_dir / png_name | |
| if png_path.exists(): | |
| return str(png_path) | |
| except (subprocess.SubprocessError, FileNotFoundError): | |
| continue | |
| log_agent_error(self.name, "LibreOffice not found - install for PNG conversion") | |
| except Exception as e: | |
| log_agent_error(self.name, f"PNG conversion failed: {e}") | |
| return None | |
| def renderer_node(state: PosterState) -> Dict[str, Any]: | |
| result = Renderer()(state) | |
| return { | |
| **state, | |
| "tokens": result["tokens"], | |
| "current_agent": result["current_agent"], | |
| "errors": result["errors"] | |
| } |