| |
| 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 = { |
| Token.Keyword: (255, 128, 0), |
| 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), |
| 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), |
| 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), |
| Token.Literal.String: (230, 219, 116), |
| 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), |
| Token.Operator.Word: (255, 128, 0), |
|
|
| Token.Comment: (117, 113, 94), |
| Token.Comment.Multiline: (117, 113, 94), |
| Token.Comment.Single: (117, 113, 94), |
| Token.Comment.Preproc: (117, 113, 94), |
|
|
| Token.Generic.Deleted: (249, 38, 114), |
| 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: |
| |
| return ImageFont.truetype("arial.ttf", size) |
| 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 |
| |
| 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()] |
|
|
| |
| 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. |
| """ |
| |
| 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", []) |
|
|
| |
| topic_words = set() |
| if topic: |
| topic_words = {w.lower() for w in re.findall(r'\w+', topic)} |
|
|
| |
| 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 |
|
|
| |
| 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: |
| |
| original_runs = list(paragraph.runs) |
| |
| |
| paragraph.clear() |
| |
| for original_run in original_runs: |
| run_text = original_run.text |
| if not run_text.strip(): |
| |
| new_run = paragraph.add_run() |
| new_run.text = "" |
| continue |
| |
| found_keywords = [] |
| |
| |
| 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() |
| }) |
| highlighted_words.add(keyword.lower()) |
| |
| |
| found_keywords.sort(key=lambda x: x['start']) |
| |
| |
| current_pos = 0 |
| for keyword_info in found_keywords: |
| start, end = keyword_info['start'], keyword_info['end'] |
| |
| |
| if start > current_pos: |
| before_run = paragraph.add_run() |
| before_run.text = run_text[current_pos:start] |
| |
| 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 |
| |
| |
| keyword_run = paragraph.add_run() |
| keyword_run.text = keyword_info['text'] |
| |
| 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 |
|
|
| |
| set_highlight_reliable(keyword_run, highlight_color) or \ |
| set_highlight_xml_direct(keyword_run, highlight_color) |
| |
| highlights += 1 |
| current_pos = end |
| |
| |
| if current_pos < len(run_text): |
| after_run = paragraph.add_run() |
| after_run.text = run_text[current_pos:] |
| |
| 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 |