# tools/font_utils.py import re import json import string import collections import requests from typing import Tuple, Any from pptx.dml.color import RGBColor from pptx.oxml import parse_xml from pptx.oxml.xmlchemy import OxmlElement from PIL import ImageFont from pygments.token import Token # Pygments color map for syntax highlighting PYGMENTS_COLOR_MAP = { Token.Keyword: (255, 128, 0), # Orange Token.Keyword.Constant: (255, 128, 0), Token.Keyword.Declaration: (255, 128, 0), Token.Keyword.Namespace: (255, 128, 0), Token.Keyword.Pseudo: (255, 128, 0), Token.Keyword.Reserved: (255, 128, 0), Token.Keyword.Type: (255, 128, 0), Token.Name.Attribute: (166, 226, 46), # Green Token.Name.Builtin: (166, 226, 46), Token.Name.Builtin.Pseudo: (166, 226, 46), Token.Name.Class: (166, 226, 46), Token.Name.Constant: (166, 226, 46), Token.Name.Decorator: (166, 226, 46), Token.Name.Entity: (166, 226, 46), Token.Name.Exception: (166, 226, 46), Token.Name.Function: (166, 226, 46), Token.Name.Property: (166, 226, 46), Token.Name.Label: (166, 226, 46), Token.Name.Namespace: (166, 226, 46), Token.Name.Other: (166, 226, 46), Token.Name.Tag: (166, 226, 46), Token.Name.Variable: (255, 255, 255), # White Token.Name.Variable.Class: (255, 255, 255), Token.Name.Variable.Global: (255, 255, 255), Token.Name.Variable.Instance: (255, 255, 255), Token.Literal.Number: (174, 129, 255), # Purple Token.Literal.String: (230, 219, 116), # Yellow-ish Token.Literal.String.Doc: (230, 219, 116), Token.Literal.String.Interpol: (230, 219, 116), Token.Literal.String.Escape: (230, 219, 116), Token.Literal.String.Regex: (230, 219, 116), Token.Literal.String.Symbol: (230, 219, 116), Token.Literal.String.Single: (230, 219, 116), Token.Literal.String.Double: (230, 219, 116), Token.Operator: (255, 255, 255), # White Token.Operator.Word: (255, 128, 0), # Orange Token.Comment: (117, 113, 94), # Grey Token.Comment.Multiline: (117, 113, 94), Token.Comment.Single: (117, 113, 94), Token.Comment.Preproc: (117, 113, 94), Token.Generic.Deleted: (249, 38, 114), # Red Token.Generic.Error: (249, 38, 114), Token.Generic.Heading: (255, 128, 0), Token.Generic.Inserted: (166, 226, 46), Token.Generic.Output: (255, 255, 255), Token.Generic.Prompt: (117, 113, 94), Token.Generic.Strong: (255, 128, 0), Token.Generic.Subheading: (255, 128, 0), Token.Generic.Traceback: (249, 38, 114), Token.Punctuation: (255, 255, 255), Token.Text: (255, 255, 255), Token.Error: (249, 38, 114), } MONOSPACE_FONT_PATHS = ["AnonymousPro-Regular.ttf", "cour.ttf"] def get_monospaced_font(size): """Attempts to load a monospaced font from the provided paths.""" for font_path in MONOSPACE_FONT_PATHS: try: return ImageFont.truetype(font_path, size) except IOError: continue print(f"Warning: No specific monospaced font found. Falling back to PIL's default font.") try: # Fallback to a system font if 'cour.ttf' is not found either, or PIL's default return ImageFont.truetype("arial.ttf", size) # Try a common system font except IOError: return ImageFont.load_default() def get_token_color(token_type: Any) -> Tuple[int, int, int]: """ Retrieves the color (RGB tuple) for a given Pygments token type based on the PYGMENTS_COLOR_MAP. It traverses the token's parent types if a direct match is not found. """ current_type = token_type while current_type is not Token: color = PYGMENTS_COLOR_MAP.get(current_type) if color: return color current_type = current_type.parent # Fallback to default text color if no specific color is found return PYGMENTS_COLOR_MAP.get(Token.Text, (255, 255, 255)) HIGHLIGHT_COLORS = { 'yellow': 'FFFF00', 'green': '00FF00', 'blue': '00FFFF', } def get_highlight_color(color_name='yellow'): return HIGHLIGHT_COLORS.get(color_name.lower(), 'FFFF00') def set_highlight_reliable(run, color="FFFF00"): try: run.font.color.rgb = RGBColor(0, 0, 0) rPr = run._r.get_or_add_rPr() hl = OxmlElement("a:highlight") srgbClr = OxmlElement("a:srgbClr") setattr(srgbClr, "val", color) hl.append(srgbClr) rPr.append(hl) return True except Exception as e: print(f"Failed to apply highlight using OxmlElement method: {e}") return False def set_highlight_xml_direct(run, color="FFFF00"): """Alternative method for setting highlight using direct XML manipulation""" try: run.font.color.rgb = RGBColor(0, 0, 0) highlight_xml = f''' ''' highlight_element = parse_xml(highlight_xml) rPr = run._r.get_or_add_rPr() rPr.append(highlight_element) return True except Exception as e: print(f"Failed to apply highlight using direct XML method: {e}") return False def extract_keywords_simple(text, max_keywords=3, topic=None): text_clean = re.sub(r'```.*?```', '', text, flags=re.DOTALL) text_clean = text_clean.translate(str.maketrans('', '', string.punctuation)).lower() words = [w for w in text_clean.split() if len(w) > 3 and not w.isdigit()] # Remove topic words topic_words = set() if topic: topic_words = {w.lower() for w in re.findall(r'\w+', topic)} freq = collections.Counter([w for w in words if w not in topic_words]) keywords = [w for w, _ in freq.most_common(max_keywords)] return keywords def get_keywords_from_api(slide_text, anthropic_api_key, use_anthropic_api, topic=None, max_keywords=None): """ Extracts the 1–3 most important technical/domain-specific keywords from slide_text. Ensures topic words are excluded from extraction. Ignores max_keywords parameter — always returns between 1 and 3. """ # We enforce our own cap: min 1, max 3 min_keywords, max_limit = 1, 3 if not anthropic_api_key or not use_anthropic_api: keywords = extract_keywords_simple(slide_text, max_limit, topic) return keywords[:max_limit] if keywords else [] try: prompt = f""" Extract ONLY the most important 1–3 technical or domain-specific keywords from the following text. Rules: - Do NOT repeat the same word. - Do NOT extract filler words, names, or generic/common terms. - Do NOT include the topic name "{topic}" if given. - Only include technically relevant terms that capture the core concept. - Extract fewer than 3 if only 1–2 truly important technical terms are present. - Output JSON ONLY in this exact format: {{"keywords": ["term1", "term2", "term3"]}} Text: {slide_text} """ response = requests.post( "https://api.anthropic.com/v1/messages", headers={ "Content-Type": "application/json", "x-api-key": anthropic_api_key, "anthropic-version": "2023-06-01" }, json={ "model": "claude-3-haiku-20240307", "max_tokens": 200, "messages": [{"role": "user", "content": prompt}] } ) if response.status_code == 200: result_text = response.json()["content"][0]["text"].strip() json_match = re.search(r'\{.*\}', result_text, re.DOTALL) if json_match: keywords = json.loads(json_match.group()).get("keywords", []) # Build set of topic words topic_words = set() if topic: topic_words = {w.lower() for w in re.findall(r'\w+', topic)} # Validate & exclude topic words validated = [ k for k in keywords if k and isinstance(k, str) and re.search(r'\b' + re.escape(k) + r'\b', slide_text, re.IGNORECASE) and k.lower() not in topic_words ] limited = validated[:max_limit] if validated else [] return limited except Exception: pass # Fallback: simple frequency-based extraction if API fails keywords = extract_keywords_simple(slide_text, max_limit, topic) return keywords[:max_limit] if keywords else [] def highlight_keywords_in_text_frame(text_frame, keywords, highlight_color="FFFF00"): if not keywords: return 0 highlights = 0 highlighted_words = set() print(f"Attempting to highlight keywords: {keywords[:3]}") for paragraph in text_frame.paragraphs: # Process each run in the paragraph original_runs = list(paragraph.runs) # Clear the paragraph to rebuild it paragraph.clear() for original_run in original_runs: run_text = original_run.text if not run_text.strip(): # Add empty run and continue new_run = paragraph.add_run() new_run.text = "" continue found_keywords = [] # Find all keyword matches in this run for keyword in keywords[:5]: if keyword.lower() in highlighted_words: continue for match in re.finditer(r'\b' + re.escape(keyword) + r'\b', run_text, re.IGNORECASE): start, end = match.start(), match.end() found_keywords.append({ 'start': start, 'end': end, 'keyword': keyword, 'text': match.group() # Preserve original case }) highlighted_words.add(keyword.lower()) # Sort by position found_keywords.sort(key=lambda x: x['start']) # Rebuild the run with proper formatting current_pos = 0 for keyword_info in found_keywords: start, end = keyword_info['start'], keyword_info['end'] # Add text before keyword (preserve original formatting) if start > current_pos: before_run = paragraph.add_run() before_run.text = run_text[current_pos:start] # Copy original formatting before_run.font.bold = original_run.font.bold before_run.font.italic = original_run.font.italic if original_run.font.color and original_run.font.color.rgb: before_run.font.color.rgb = original_run.font.color.rgb if original_run.font.size: before_run.font.size = original_run.font.size if original_run.font.name: before_run.font.name = original_run.font.name # Add highlighted keyword (preserve original formatting, just highlight) keyword_run = paragraph.add_run() keyword_run.text = keyword_info['text'] # Copy formatting keyword_run.font.bold = original_run.font.bold keyword_run.font.italic = original_run.font.italic if original_run.font.color and original_run.font.color.rgb: keyword_run.font.color.rgb = original_run.font.color.rgb if original_run.font.size: keyword_run.font.size = original_run.font.size if original_run.font.name: keyword_run.font.name = original_run.font.name # Apply highlight (no forced bold/black) set_highlight_reliable(keyword_run, highlight_color) or \ set_highlight_xml_direct(keyword_run, highlight_color) highlights += 1 current_pos = end # Add remaining text after last keyword (preserve original formatting) if current_pos < len(run_text): after_run = paragraph.add_run() after_run.text = run_text[current_pos:] # Copy original formatting after_run.font.bold = original_run.font.bold after_run.font.italic = original_run.font.italic if original_run.font.color and original_run.font.color.rgb: after_run.font.color.rgb = original_run.font.color.rgb if original_run.font.size: after_run.font.size = original_run.font.size if original_run.font.name: after_run.font.name = original_run.font.name print(f"Total highlights applied: {highlights}") return highlights