scn-consultation-api / src /tools /font_utils.py
SS-2005's picture
Initial HF Space deployment
59ec65a
Raw
History Blame Contribute Delete
13.2 kB
# 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