internationalscholarsprogram/handbook-engine
0
1"""Utility functions shared across renderers.2 3Mirrors PHP helpers: h(), formatMoneyFigures(), handbook_anchor(), etc.4"""5 6from __future__ import annotations7 8import html9import re10 11 12def h(s: str) -> str:13 """HTML-escape (mirrors PHP h())."""14 return html.escape(str(s), quote=True)15 16 17def is_assoc(a: list | dict) -> bool:18 """Check if an array is associative (dict-like) vs sequential list."""19 return isinstance(a, dict)20 21 22def hb_slug(s: str) -> str:23 """Slug helper for anchors."""24 tmp = s.lower().strip()25 tmp = re.sub(r"[^a-z0-9]+", "_", tmp, flags=re.IGNORECASE)26 tmp = re.sub(r"_+", "_", tmp)27 return tmp.strip("_")28 29 30def handbook_anchor(prefix: str, text: str, idx: int) -> str:31 """Normalise a string into a safe anchor id. Mirrors PHP handbook_anchor."""32 base = text.lower().strip()33 base = re.sub(r"[^a-z0-9]+", "-", base, flags=re.IGNORECASE)34 base = base.strip("-")35 if not base:36 base = f"{prefix}-{idx}"37 return f"{prefix}-{base}-{idx}"38 39 40def is_truthy(val) -> bool:41 """Mirrors PHP handbook_true."""42 if isinstance(val, bool):43 return val44 if isinstance(val, int):45 return val != 046 v = str(val).lower().strip()47 return v not in ("0", "false", "")48 49 50def format_money_figures(text: str) -> str:51 """Normalize all monetary figures to "USD X,XXX" format.52 53 - Converts existing $X,XXX → USD X,XXX54 - Normalizes bare large numbers (1,000+) → USD X,XXX55 - Formats with commas56 - Currency type is always USD (no $ symbol)57 """58 if not text:59 return text60 61 # Step 1: Convert "$X" → "USD X" directly (preserves ALL dollar amounts)62 def _dollar_to_usd(m: re.Match) -> str:63 num_str = m.group(1).replace(",", "")64 try:65 num = float(num_str)66 except ValueError:67 return m.group(0)68 if "." in m.group(1):69 dec_part = m.group(1).split(".")[-1]70 formatted = f"{num:,.{len(dec_part)}f}"71 elif num == int(num):72 formatted = f"{int(num):,}"73 else:74 formatted = f"{num:,.2f}"75 return "USD " + formatted76 77 text = re.sub(r'\$([\d,]+(?:\.\d+)?)', _dollar_to_usd, text)78 79 # Step 2: Normalize existing "USD X,XXX" for consistent comma formatting80 def _normalize_usd(m: re.Match) -> str:81 num_str = m.group(1).replace(",", "")82 try:83 num = float(num_str)84 except ValueError:85 return m.group(0)86 if "." in m.group(1):87 dec_part = m.group(1).split(".")[-1]88 formatted = f"{num:,.{len(dec_part)}f}"89 elif num == int(num):90 formatted = f"{int(num):,}"91 else:92 formatted = f"{num:,.2f}"93 return "USD " + formatted94 95 text = re.sub(r'\bUSD\s+([\d,]+(?:\.\d+)?)', _normalize_usd, text, flags=re.IGNORECASE)96 97 # Step 3: Add "USD " to bare large numbers (4+ digits or comma-formatted)98 # that aren't already preceded by "USD "99 def _format_bare_large(m: re.Match) -> str:100 num_str = m.group(1).replace(",", "")101 dec = m.group(2) if m.group(2) else ""102 try:103 num = float(num_str)104 except ValueError:105 return m.group(0)106 if dec:107 formatted = f"{num:,.{len(dec)}f}"108 else:109 formatted = f"{num:,.0f}"110 return "USD " + formatted111 112 text = re.sub(113 r"(?<!\d)(?<!USD )((?:\d{1,3}(?:,\d{3})+)|(?:\d{4,}))(?:\.(\d+))?(?![%\d/])",114 _format_bare_large,115 text,116 )117 118 return text119 120 121def ensure_program_options_pair(text: str) -> str:122 """Ensure REGULAR/PRIME program options appear together when either appears.123 124 If only one of the two appears in text, append "(REGULAR and PRIME)"125 to preserve source meaning while enforcing consistency.126 """127 if not text:128 return text129 130 has_regular = bool(re.search(r"\bREGULAR\b", text, flags=re.IGNORECASE))131 has_prime = bool(re.search(r"\bPRIME\b", text, flags=re.IGNORECASE))132 133 if has_regular ^ has_prime:134 if re.search(r"\(\s*REGULAR\s+and\s+PRIME\s*\)", text, flags=re.IGNORECASE):135 return text136 return text.rstrip() + " (REGULAR and PRIME)"137 138 return text139 140 141def sort_sections_stable(sections: list[dict]) -> list[dict]:142 """Stable sort: sort_order ASC, then id ASC, then insertion order."""143 for i, s in enumerate(sections):144 s.setdefault("_i", i)145 146 def sort_key(s: dict):147 so = s.get("sort_order")148 sid = s.get("id")149 so_key = (0, so) if so is not None else (1, 0)150 sid_key = (0, sid) if sid is not None else (1, 0)151 return (so_key, sid_key, s.get("_i", 0))152 153 sections.sort(key=sort_key)154 for s in sections:155 s.pop("_i", None)156 return sections157 158 159def get_any(d: dict, keys: list[str]) -> str:160 """Return the first non-empty string value found for one of the keys."""161 for k in keys:162 v = d.get(k)163 if v is None or isinstance(v, (dict, list)):164 continue165 t = str(v).strip()166 if t:167 return t168 return ""169 170 171def emphasize_keywords(text: str) -> str:172 """Add bold HTML emphasis to key handbook terms in already-escaped text.173 174 Bolds: REGULAR, PRIME, dollar amounts ($X,XXX), and other critical terms.175 Input must already be HTML-escaped. Returns HTML with <strong> tags.176 """177 if not text:178 return text179 180 escaped = h(text)181 182 # Bold REGULAR and PRIME (case-insensitive, whole word)183 escaped = re.sub(184 r'\b(REGULAR|PRIME)\b',185 r'<strong>\1</strong>',186 escaped,187 flags=re.IGNORECASE,188 )189 190 # Bold USD amounts like USD 1,000 or USD 500191 escaped = re.sub(192 r'\b(USD\s+[\d,]+(?:\.\d+)?)',193 r'<strong>\1</strong>',194 escaped,195 flags=re.IGNORECASE,196 )197 198 # Bold standalone USD199 escaped = re.sub(200 r'\b(USD)\b(?!\s*[\d,])',201 r'<strong>\1</strong>',202 escaped,203 flags=re.IGNORECASE,204 )205 206 # Bold dollar-sign amounts like $20, $1,000, $1,000.00207 escaped = re.sub(208 r'(\$[\d,]+(?:\.\d+)?)',209 r'<strong>\1</strong>',210 escaped,211 )212 213 # Bold specific GPA values 2.8, 3.4 and 4.0214 escaped = re.sub(215 r'\b(2\.8|3\.4|4\.0)\b',216 r'<strong>\1</strong>',217 escaped,218 )219 220 # Bold key qualification and geo terms.221 escaped = re.sub(222 r'\b(GPA\s*\(\s*Undergraduate\s+Requirement\s*\)|GPA|High\s+School\s+grades|Global|Uganda|Kenya)\b',223 r'<strong>\1</strong>',224 escaped,225 flags=re.IGNORECASE,226 )227 228 # Bold refund policy phrase.229 escaped = re.sub(230 r'\b(Refund\s+Policy)\b',231 r'<strong>\1</strong>',232 escaped,233 flags=re.IGNORECASE,234 )235 236 return escaped237 238 239def linkify_urls(text: str) -> str:240 """Convert URLs in text to clickable <a> tags with target="_blank".241 242 Detects http/https URLs and converts them to proper anchor tags.243 Input should be plain text or already HTML-escaped.244 Returns HTML with <a> tags.245 """246 if not text:247 return text248 249 # Detect and convert http/https URLs to clickable links250 # Pattern: http:// or https:// followed by domain and optional path251 url_pattern = r'(https?://[^\s<)]+)'252 253 def make_link(match):254 url = match.group(1)255 # Clean up trailing punctuation that's likely not part of URL256 url = url.rstrip('.,;:!?)\'\"')257 return f'<a href="{h(url)}" target="_blank" rel="noopener noreferrer">{h(url)}</a>'258 259 return re.sub(url_pattern, make_link, text)260 