Spaces:
Runtime error
Runtime error
| # 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''' | |
| <a:highlight xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"> | |
| <a:srgbClr val="{color}"/> | |
| </a:highlight> | |
| ''' | |
| 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 |