internationalscholarsprogram/handbook-engine
0
1"""Renderers — mirrors PHP renderers.php.2 3Contains functions for rendering:4- Table of Contents (TOC)5- Global section blocks (overview, steps, bullets, tables, doc_v1, etc.)6- University section blocks (overview, benefits, programs)7- Remote image fetching as data URIs8"""9 10from __future__ import annotations11 12import base6413import logging14import re15from typing import Any16 17import httpx18 19from app.services.utils import (20 emphasize_keywords,21 format_money_figures,22 get_any,23 h,24 hb_slug,25 is_assoc,26 is_truthy,27)28 29logger = logging.getLogger(__name__)30 31 32# =========================================33# Image fetching (with in-memory cache + async batch support)34# =========================================35 36_image_cache: dict[str, str] = {}37 38 39def _detect_image_mime(data: bytes, content_type: str) -> str:40 """Detect image MIME type from headers or magic bytes."""41 if "image/" in content_type:42 return content_type.split(";")[0].strip()43 if data[:8].startswith(b"\x89PNG"):44 return "image/png"45 if data[:3] == b"\xff\xd8\xff":46 return "image/jpeg"47 if data[:4] == b"GIF8":48 return "image/gif"49 if data[:4] == b"RIFF" and data[8:12] == b"WEBP":50 return "image/webp"51 return ""52 53 54def fetch_image_data_uri(url: str) -> str:55 """Fetch a remote image and return as data:... URI. Mirrors PHP fetchImageDataUri."""56 url = url.strip()57 if not url:58 return ""59 60 # Check cache first (populated by prefetch_images)61 if url in _image_cache:62 return _image_cache[url]63 64 try:65 with httpx.Client(verify=False, timeout=12, follow_redirects=True) as client:66 resp = client.get(url)67 if resp.status_code < 200 or resp.status_code >= 300 or not resp.content:68 logger.warning("Image fetch failed for %s status=%d", url, resp.status_code)69 _image_cache[url] = ""70 return ""71 data = resp.content72 except Exception as exc:73 logger.warning("Image fetch error for %s: %s", url, exc)74 _image_cache[url] = ""75 return ""76 77 mime = _detect_image_mime(data, resp.headers.get("content-type", ""))78 if not mime.startswith("image/"):79 logger.warning("Invalid image mime %s for %s", mime, url)80 _image_cache[url] = ""81 return ""82 83 b64 = base64.b64encode(data).decode("ascii")84 result = f"data:{mime};base64,{b64}"85 _image_cache[url] = result86 return result87 88 89async def prefetch_images(urls: list[str]) -> dict[str, str]:90 """Fetch all images in parallel using async HTTP and populate the cache.91 92 This is the key optimization: instead of fetching ~30 campus images93 serially (30-60s), we fetch them all concurrently (~3-5s).94 """95 import asyncio96 97 unique_urls = list({u.strip() for u in urls if u.strip() and u.strip() not in _image_cache})98 if not unique_urls:99 return {u: _image_cache.get(u.strip(), "") for u in urls}100 101 async def _fetch_one(client: httpx.AsyncClient, url: str) -> tuple[str, str]:102 try:103 resp = await client.get(url)104 if resp.status_code < 200 or resp.status_code >= 300 or not resp.content:105 logger.warning("Prefetch image failed for %s status=%d", url, resp.status_code)106 return url, ""107 mime = _detect_image_mime(resp.content, resp.headers.get("content-type", ""))108 if not mime.startswith("image/"):109 logger.warning("Prefetch invalid mime %s for %s", mime, url)110 return url, ""111 b64 = base64.b64encode(resp.content).decode("ascii")112 return url, f"data:{mime};base64,{b64}"113 except Exception as exc:114 logger.warning("Prefetch image error for %s: %s", url, exc)115 return url, ""116 117 logger.info("Prefetching %d campus images in parallel...", len(unique_urls))118 async with httpx.AsyncClient(verify=False, timeout=15, follow_redirects=True) as client:119 results = await asyncio.gather(*[_fetch_one(client, u) for u in unique_urls])120 121 fetched = 0122 for url, data_uri in results:123 _image_cache[url] = data_uri124 if data_uri:125 fetched += 1126 127 logger.info("Prefetched %d/%d images successfully", fetched, len(unique_urls))128 return {u: _image_cache.get(u.strip(), "") for u in urls}129 130 131# =========================================132# Funding extraction133# =========================================134 135def _extract_university_funding(136 j: dict,137 school_meta: dict | None = None,138) -> tuple[str, list[str]]:139 """Extract funding heading + items from benefits section JSON.140 141 Priority:142 1. section_json.funding.options143 2. section_json.funding_available144 3. fallback from pth_ref_schools.school_category145 """146 if not isinstance(j, dict):147 j = {}148 149 heading = "Funding Available"150 items: list[str] = []151 152 # 1. Preferred normalized shape153 funding = j.get("funding", {})154 if isinstance(funding, dict):155 subheading = str(funding.get("subheading", "")).strip()156 if subheading:157 heading = subheading158 159 options = funding.get("options", [])160 if isinstance(options, list):161 for opt in options:162 if not isinstance(opt, dict):163 continue164 name = str(opt.get("name", "")).strip()165 amount = str(opt.get("amount", "")).strip()166 167 if name and amount:168 items.append(f"{name} - {amount}")169 elif name:170 items.append(name)171 elif amount:172 items.append(amount)173 174 # 2. Legacy fallback shape175 if not items:176 funding_available = j.get("funding_available", [])177 if isinstance(funding_available, list):178 for item in funding_available:179 text = str(item).strip()180 if text:181 items.append(text)182 183 # 3. School-category fallback184 if not items and isinstance(school_meta, dict):185 school_category = str(school_meta.get("school_category", "")).strip().lower()186 status = str(school_meta.get("status", "")).strip().lower()187 188 if status == "in":189 if school_category == "non_cosigner":190 items = [191 "ISP Study Loan - $10,000",192 "Partner 1 (Unsecured Loan) - Up to $50,000 per academic year",193 "Partner 3 (Credit Option) - Up to $15,000",194 ]195 elif school_category == "cosigner":196 items = [197 "ISP Study Loan - $10,000",198 "Partner 2 (A Cosigned Loan) - Full Coverage Support",199 "Partner 3 (Credit Option) - Up to $15,000",200 ]201 202 return (heading, items)203 204 205# =========================================206# TOC sorting and rendering207# =========================================208 209def sort_toc(items: list[dict]) -> list[dict]:210 """Mirrors PHP sortHandbookToc — sort by sort_order/sort, stable fallback."""211 for idx, e in enumerate(items):212 e.setdefault("_i", idx)213 214 def key_fn(e: dict):215 so = e.get("sort_order", e.get("sort"))216 if so is not None:217 try:218 so_num = float(so)219 return (0, so_num, e.get("_i", 0))220 except (ValueError, TypeError):221 pass222 return (1, 0.0, e.get("_i", 0))223 224 items.sort(key=key_fn)225 for e in items:226 e.pop("_i", None)227 return items228 229 230def render_toc(items: list[dict], debug: bool = False, show_pages: bool = True) -> str:231 """Render Table of Contents HTML (DOMPDF-safe).232 233 Mirrors PHP renderToc().234 """235 sorted_items = sort_toc(items)236 237 out = '<!-- HANDBOOK_TOC_V2 -->'238 out += '<div class="toc">'239 out += '<div class="toc-heading">Table of Contents</div>'240 out += (241 '<table class="toc-table" width="100%" cellspacing="0" cellpadding="0"'242 ' style="border-collapse:collapse; table-layout:fixed; width:100%;">'243 '<colgroup><col /><col width="50" /><col width="48" /></colgroup>'244 )245 246 for e in sorted_items:247 if not isinstance(e, dict):248 continue249 title = str(e.get("title", "")).strip()250 target = str(e.get("target", e.get("anchor", ""))).strip()251 if not title:252 continue253 254 level = max(0, min(3, int(e.get("level", 0))))255 bold = bool(e.get("bold", False))256 upper = bool(e.get("upper", False))257 if level == 0:258 bold = True259 upper = True260 261 row_class = "toc-row--major" if level == 0 else "toc-row--sub"262 if level >= 2:263 row_class += " toc-row--deep"264 265 text = title.upper() if upper else title266 title_inner = h(text)267 if target:268 title_inner = f'<a href="{h(target)}">{title_inner}</a>'269 if bold:270 title_inner = f"<strong>{title_inner}</strong>"271 272 page = str(e.get("page", "")).strip()273 if show_pages and page:274 page_cell = f"<strong>{h(page)}</strong>"275 else:276 page_cell = " "277 278 indent = ""279 if level == 1:280 indent = "padding-left:16px;"281 elif level >= 2:282 indent = "padding-left:30px;"283 284 title_style = (285 "vertical-align:bottom; padding:1px 4px 1px 0; font-size:10px; "286 "line-height:1.15; color:#111;"287 + (" font-weight:700;" if bold else " font-weight:400;")288 + (" text-transform:uppercase; letter-spacing:0.1px;" if upper else "")289 + (f" {indent}" if indent else "")290 )291 292 out += f'<tr class="{h(row_class)}">'293 out += f'<td class="toc-title" style="{title_style}">{title_inner}</td>'294 out += '<td class="toc-dots" style="vertical-align:bottom; border-bottom:1px dotted #777; height:0.85em; padding:0;"> </td>'295 out += (296 f'<td class="toc-pagenum" style="vertical-align:bottom; text-align:right; '297 f'padding-left:4px; font-size:10px; font-weight:700; line-height:1.15; '298 f'white-space:nowrap; width:48px; color:#111;">{page_cell}</td>'299 )300 out += "</tr>"301 302 out += "</table></div>"303 return out304 305 306def render_toc_hardcoded(307 items: list[dict],308 debug: bool = False,309 page_start: int = 3,310 page_offset: int = 0,311) -> str:312 """Mirrors PHP renderTocHardcoded — sort, assign sequential pages, render."""313 sorted_items = sort_toc(items)314 315 seq = max(1, page_start)316 for item in sorted_items:317 p = str(item.get("page", "")).strip()318 if p and p.lstrip("-").isdigit():319 display = int(p) + page_offset320 item["page"] = str(display)321 if display >= seq:322 seq = display + 1323 else:324 item["page"] = str(seq)325 seq += 1326 327 out = "<!-- HANDBOOK_TOC_HARDCODED -->\n"328 out += '<div class="toc">'329 out += '<p class="toc-heading">Table of Contents</p>'330 out += (331 '<table class="toc-table" style="table-layout:fixed;width:100%;">'332 '<colgroup><col /><col width="50" /><col width="48" /></colgroup>'333 )334 335 for e in sorted_items:336 if not isinstance(e, dict):337 continue338 title = str(e.get("title", "")).strip()339 target = str(e.get("target", e.get("anchor", ""))).strip()340 if not title:341 continue342 343 level = max(0, min(3, int(e.get("level", 0))))344 bold = bool(e.get("bold", False))345 upper = bool(e.get("upper", False))346 if level == 0:347 bold = True348 upper = True349 350 row_class = "toc-row--major" if level == 0 else "toc-row--sub"351 if level >= 2:352 row_class += " toc-row--deep"353 354 text = title.upper() if upper else title355 title_inner = h(text)356 if target:357 title_inner = f'<a href="{h(target)}">{title_inner}</a>'358 if bold:359 title_inner = f"<strong>{title_inner}</strong>"360 361 page = str(e.get("page", "")).strip()362 page_html = f"<strong>{h(page)}</strong>" if page else " "363 364 indent = ""365 if level == 1:366 indent = "padding-left:16px;"367 elif level >= 2:368 indent = "padding-left:30px;"369 370 title_style = (371 "vertical-align:bottom;padding:1px 4px 1px 0;font-size:10px;"372 "line-height:1.15;color:#111;"373 + ("font-weight:700;" if bold else "font-weight:400;")374 + ("text-transform:uppercase;letter-spacing:0.1px;" if upper else "")375 + indent376 )377 378 out += f'<tr class="{h(row_class)}">'379 out += f'<td class="toc-title" style="{title_style}">{title_inner}</td>'380 out += '<td class="toc-dots" style="vertical-align:bottom;padding:0;"><span class="toc-dots-inner"> </span></td>'381 out += (382 f'<td class="toc-pagenum" style="vertical-align:bottom;text-align:right;'383 f'padding-left:4px;font-size:10px;font-weight:700;line-height:1.15;'384 f'white-space:nowrap;width:48px;color:#111111;">{page_html}</td>'385 )386 out += "</tr>"387 388 out += "</table></div>"389 return out390 391 392# =========================================393# table_v3 / table_v4 cell helpers394# =========================================395 396# Mapping of style names → inline CSS strings for table_v3/v4 cells397_V3_STYLE_MAP: dict[str, str] = {398 "band_teal": "text-align:center;font-weight:700;color:#fff;background:#199970;",399 "band_navy": "text-align:center;font-weight:700;color:#fff;background:#0263A3;",400 "bold_amounts": "font-weight:600;",401 "green_center_bold": "text-align:center;font-weight:700;color:#199970;",402 "center_bold_multiline": "text-align:center;font-weight:600;vertical-align:middle;",403 "footer_center_bold": "text-align:center;font-weight:700;background:#f5f5f5;",404 "covered_merged": "vertical-align:top;font-size:9pt;line-height:1.5;",405}406 407 408def _parse_v3_cell(cell: Any) -> tuple[str, str, str]:409 """Parse a table_v3/v4 cell dict into (attr_str, style_str, html_content)."""410 if not isinstance(cell, dict):411 text = format_money_figures(str(cell)) if cell else ""412 return ("", "", h(text))413 414 colspan = 1415 rowspan = 1416 text_val = str(cell.get("text", ""))417 cs = cell.get("colspan")418 rs = cell.get("rowspan")419 if cs is not None and str(cs).isdigit():420 colspan = int(cs)421 if rs is not None and str(rs).isdigit():422 rowspan = int(rs)423 424 attr = ""425 if colspan > 1:426 attr += f' colspan="{colspan}"'427 if rowspan > 1:428 attr += f' rowspan="{rowspan}"'429 430 style_name = str(cell.get("style", ""))431 inline_css = _V3_STYLE_MAP.get(style_name, "")432 style_str = f' style="{inline_css}"' if inline_css else ""433 434 # Rich parts within cell (merged cells with multiple text blocks)435 parts = cell.get("parts")436 if isinstance(parts, list) and parts:437 html_parts: list[str] = []438 for p in parts:439 if not isinstance(p, dict):440 continue441 pt = format_money_figures(str(p.get("text", "")))442 if not pt:443 continue444 if p.get("bold"):445 html_parts.append(f"<strong>{h(pt)}</strong>")446 else:447 html_parts.append(h(pt))448 content = "<br><br>".join(html_parts) if html_parts else h(format_money_figures(text_val))449 else:450 content = h(format_money_figures(text_val))451 452 return (attr, style_str, content)453 454 455# =========================================456# Global blocks renderer457# =========================================458 459def render_global_blocks(460 section_key: str,461 section_title: str,462 json_data: dict | list,463 debug: bool = False,464 *,465 universities: list[dict] | None = None,466) -> str:467 """Render a single global section's content.468 469 Mirrors PHP renderGlobalBlocks() — handles steps, bullets, tables,470 doc_v1, table_v2, summary_of_universities, etc.471 """472 html_out = ""473 key_norm = section_key.lower().strip()474 475 if not isinstance(json_data, dict):476 json_data = {}477 478 layout_norm = str(json_data.get("layout", "")).lower().strip()479 480 # ── Section title ──481 # Prefer the JSON-level title (display-ready) over the DB section_title482 json_title = str(json_data.get("title", "")).strip() if isinstance(json_data, dict) else ""483 title = json_title or section_title.strip()484 if title and key_norm != "table_of_contents":485 html_out += f'<h2 class="h2">{h(title)}</h2>'486 _title_norm = title.lower()487 488 # ── Steps ──489 steps = json_data.get("steps")490 if isinstance(steps, list):491 step_num = 0492 for s in steps:493 if not isinstance(s, dict):494 continue495 step_num += 1496 step_title = str(s.get("title", s.get("step_title", ""))).strip()497 body = format_money_figures(str(s.get("body", s.get("description", ""))).strip())498 499 html_out += '<div class="avoid-break" style="margin:0 0 4px;">'500 if step_title:501 html_out += f'<div class="h3">Step {step_num}: {h(step_title)}</div>'502 if body:503 html_out += f'<p class="p">{emphasize_keywords(body)}</p>'504 505 links = s.get("links", [])506 if isinstance(links, list) and links:507 html_out += '<ul class="ul">'508 for lnk in links:509 if not isinstance(lnk, dict):510 continue511 label = str(lnk.get("label", "Link")).strip()512 url = str(lnk.get("url", "")).strip()513 if not url:514 continue515 html_out += f'<li><a href="{h(url)}" target="_blank" rel="noopener noreferrer">{h(label)}</a></li>'516 html_out += "</ul>"517 518 qr = str(s.get("qr_url", s.get("qr_image", ""))).strip()519 if qr:520 html_out += f'<img src="{h(qr)}" alt="QR" style="width:60px; height:60px; margin:4px 0;" />'521 522 html_out += "</div>"523 return html_out524 525 # ── Bullets ──526 has_bullets = isinstance(json_data.get("bullets"), list)527 has_items = isinstance(json_data.get("items"), list)528 if has_bullets or (layout_norm == "bullets_with_note" and has_items):529 lst = json_data.get("items") if has_items else json_data.get("bullets")530 html_out += '<ul class="ul">'531 for b in lst:532 b_str = format_money_figures(str(b).strip())533 if not b_str:534 continue535 html_out += f"<li>{emphasize_keywords(b_str)}</li>"536 html_out += "</ul>"537 538 note = format_money_figures(str(json_data.get("note", json_data.get("footnote", ""))).strip())539 if note:540 html_out += f'<div class="note">{h(note)}</div>'541 return html_out542 543 # ── Basic table ──544 cols = json_data.get("columns")545 rows = json_data.get("rows")546 if isinstance(cols, list) and isinstance(rows, list):547 html_out += '<table class="tbl">'548 if cols:549 html_out += "<thead><tr>"550 for c in cols:551 html_out += f"<th>{h(str(c))}</th>"552 html_out += "</tr></thead>"553 html_out += "<tbody>"554 555 for r in rows:556 if not isinstance(r, (list, dict)):557 continue558 html_out += "<tr>"559 if isinstance(r, dict):560 for col_label in cols:561 key_guess = re.sub(r"[^a-z0-9]+", "_", str(col_label).lower())562 cell = r.get(key_guess, "")563 html_out += f"<td>{h(format_money_figures(str(cell)))}</td>"564 else:565 for cell in r:566 html_out += f"<td>{h(format_money_figures(str(cell)))}</td>"567 html_out += "</tr>"568 569 html_out += "</tbody></table>"570 return html_out571 572 # ── table_v2 ──573 if layout_norm == "table_v2":574 base_cols = json_data.get("base_columns", [])575 groups = json_data.get("header_groups", [])576 rows = json_data.get("rows", [])577 if not isinstance(base_cols, list):578 base_cols = []579 if not isinstance(groups, list):580 groups = []581 if not isinstance(rows, list):582 rows = []583 584 all_cols: list[dict] = []585 for c in base_cols:586 if isinstance(c, dict):587 all_cols.append({"key": str(c.get("key", "")), "label": str(c.get("label", ""))})588 for g in groups:589 if not isinstance(g, dict):590 continue591 g_cols = g.get("columns", [])592 if not isinstance(g_cols, list):593 g_cols = []594 for c in g_cols:595 if isinstance(c, dict):596 all_cols.append({"key": str(c.get("key", "")), "label": str(c.get("label", ""))})597 598 html_out += '<table class="tbl tbl-comparison"><thead>'599 has_group_row = bool(groups)600 if has_group_row:601 html_out += "<tr>"602 for c in base_cols:603 if isinstance(c, dict):604 html_out += f'<th rowspan="2">{h(str(c.get("label", "")))}</th>'605 for g in groups:606 if not isinstance(g, dict):607 continue608 g_cols = g.get("columns", [])609 if not isinstance(g_cols, list):610 g_cols = []611 span = max(1, len(g_cols))612 html_out += f'<th colspan="{span}">{h(str(g.get("label", "")))}</th>'613 html_out += "</tr><tr>"614 for g in groups:615 if not isinstance(g, dict):616 continue617 g_cols = g.get("columns", [])618 if not isinstance(g_cols, list):619 g_cols = []620 for c in g_cols:621 if isinstance(c, dict):622 html_out += f'<th>{h(str(c.get("label", "")))}</th>'623 html_out += "</tr>"624 else:625 html_out += "<tr>"626 for c in all_cols:627 html_out += f'<th>{h(c.get("label", ""))}</th>'628 html_out += "</tr>"629 630 html_out += "</thead><tbody>"631 for r in rows:632 if not isinstance(r, dict):633 continue634 html_out += "<tr>"635 for c in all_cols:636 k = c.get("key", "")637 val = r.get(k, "")638 if isinstance(val, dict):639 val = val.get("text", "")640 html_out += f"<td>{h(format_money_figures(str(val)))}</td>"641 html_out += "</tr>"642 html_out += "</tbody></table>"643 return html_out644 645 # ── doc_v1 ──646 if layout_norm == "doc_v1" and isinstance(json_data.get("blocks"), list):647 for b in json_data["blocks"]:648 if not isinstance(b, dict):649 continue650 btype = str(b.get("type", ""))651 652 # Skip heading/subheading blocks that duplicate the section title653 if btype in ("heading", "subheading"):654 block_text = str(b.get("text", "")).strip().lower()655 if block_text == _title_norm:656 continue657 658 if btype == "paragraph":659 t = format_money_figures(str(b.get("text", "")))660 if t.strip():661 html_out += f'<p class="p">{emphasize_keywords(t)}</p>'662 663 elif btype == "subheading":664 t = format_money_figures(str(b.get("text", "")))665 if t.strip():666 html_out += f'<h3 class="h3 keep-with-next">{h(t)}</h3>'667 668 elif btype == "bullets":669 items = b.get("items", [])670 if not isinstance(items, list):671 items = []672 html_out += '<ul class="ul">'673 for it in items:674 it_str = format_money_figures(str(it).strip())675 if it_str:676 html_out += f"<li>{emphasize_keywords(it_str)}</li>"677 html_out += "</ul>"678 679 elif btype == "numbered_list":680 items = b.get("items", [])681 if not isinstance(items, list):682 items = []683 html_out += '<ol class="ol">'684 for it in items:685 it_str = format_money_figures(str(it).strip())686 if it_str:687 html_out += f"<li>{emphasize_keywords(it_str)}</li>"688 html_out += "</ol>"689 690 elif btype == "note":691 t = format_money_figures(str(b.get("text", "")))692 if t.strip():693 html_out += f'<div class="note">{h(t)}</div>'694 695 elif btype == "note_inline":696 parts = b.get("parts", [])697 if not isinstance(parts, list):698 parts = []699 txt = ""700 for p in parts:701 if not isinstance(p, dict):702 continue703 t = format_money_figures(str(p.get("text", "")))704 if not t:705 continue706 style = str(p.get("style", ""))707 if style == "red_bold":708 txt += f"<strong>{h(t)}</strong>"709 else:710 txt += h(t)711 if re.sub(r"<[^>]+>", "", txt).strip():712 html_out += f'<div class="note">{txt}</div>'713 714 elif btype == "table_v1":715 t_cols = b.get("columns", [])716 t_rows = b.get("rows", [])717 if not isinstance(t_cols, list):718 t_cols = []719 if not isinstance(t_rows, list):720 t_rows = []721 html_out += '<table class="tbl">'722 if t_cols:723 html_out += "<thead><tr>"724 for c in t_cols:725 html_out += f"<th>{h(str(c))}</th>"726 html_out += "</tr></thead>"727 html_out += "<tbody>"728 for r in t_rows:729 if not isinstance(r, list):730 continue731 html_out += "<tr>"732 for cell in r:733 html_out += f"<td>{h(format_money_figures(str(cell)))}</td>"734 html_out += "</tr>"735 html_out += "</tbody></table>"736 737 elif btype == "table":738 # Generic table (columns may be objects or strings, rows may be dicts or lists)739 t_cols = b.get("columns", [])740 t_rows = b.get("rows", [])741 if not isinstance(t_cols, list):742 t_cols = []743 if not isinstance(t_rows, list):744 t_rows = []745 col_labels = []746 col_keys = []747 for c in t_cols:748 if isinstance(c, dict):749 col_labels.append(str(c.get("label", c.get("key", ""))))750 col_keys.append(str(c.get("key", "")))751 else:752 col_labels.append(str(c))753 col_keys.append(re.sub(r"[^a-z0-9]+", "_", str(c).lower()))754 html_out += '<table class="tbl">'755 if col_labels:756 html_out += "<thead><tr>"757 for lbl in col_labels:758 html_out += f"<th>{h(lbl)}</th>"759 html_out += "</tr></thead>"760 html_out += "<tbody>"761 for r in t_rows:762 html_out += "<tr>"763 if isinstance(r, dict):764 for k in col_keys:765 cell = r.get(k, "")766 html_out += f"<td>{h(format_money_figures(str(cell)))}</td>"767 elif isinstance(r, list):768 for cell in r:769 html_out += f"<td>{h(format_money_figures(str(cell)))}</td>"770 html_out += "</tr>"771 html_out += "</tbody></table>"772 773 elif btype in ("table_v3", "table_v4"):774 t_rows = b.get("rows", [])775 h_rows = b.get("header_rows", [])776 col_widths = b.get("col_width_pct", [])777 if not isinstance(t_rows, list):778 t_rows = []779 if not isinstance(h_rows, list):780 h_rows = []781 if not isinstance(col_widths, list):782 col_widths = []783 784 html_out += '<table class="tbl">'785 786 # optional col widths787 if col_widths:788 html_out += "<colgroup>"789 for w in col_widths:790 html_out += f'<col style="width:{w}%">'791 html_out += "</colgroup>"792 793 # header rows794 if h_rows:795 html_out += "<thead>"796 for hr in h_rows:797 if not isinstance(hr, list):798 continue799 html_out += "<tr>"800 for cell in hr:801 c_attr, c_style, c_text = _parse_v3_cell(cell)802 html_out += f"<th{c_attr}{c_style}>{c_text}</th>"803 html_out += "</tr>"804 html_out += "</thead>"805 806 # body rows807 html_out += "<tbody>"808 for r in t_rows:809 if not isinstance(r, list):810 continue811 html_out += "<tr>"812 for cell in r:813 c_attr, c_style, c_text = _parse_v3_cell(cell)814 html_out += f"<td{c_attr}{c_style}>{c_text}</td>"815 html_out += "</tr>"816 html_out += "</tbody></table>"817 818 return html_out819 820 # ── Fallback ──821 if "text" in json_data:822 html_out += f'<p class="p">{h(format_money_figures(str(json_data["text"])))}</p>'823 824 if not html_out.strip():825 logger.warning(826 "Empty section render for key=%s title=%s",827 section_key, section_title,828 )829 830 return html_out831 832 833# =========================================834# University section renderer835# =========================================836 837def render_university_section(838 uni_name: str,839 sections: list[dict],840 allow_remote: bool,841 is_first_uni: bool,842 include_inactive_programs: bool = False,843 website_url: str = "",844 anchor_id: str | None = None,845 debug: bool = False,846 stats: dict | None = None,847 sort_order: int | None = None,848) -> str:849 """Render a single university section. Mirrors PHP renderUniversitySection."""850 classes = ["uni"]851 if not is_first_uni:852 classes.append("page-break")853 854 id_attr = f' id="{h(anchor_id)}"' if anchor_id else ""855 sort_attr = f' data-sort="{h(str(sort_order))}"' if sort_order is not None else ""856 857 out = f'<div class="{" ".join(classes)}"{id_attr}{sort_attr} data-section-key="university" data-section-title="{h(uni_name)}">'858 859 has_stats = isinstance(stats, dict)860 if has_stats:861 stats["universities"] = stats.get("universities", 0) + 1862 863 # Build map; merge duplicate "programs" sections864 sec_map: dict[str, dict] = {}865 for s in sections:866 if not isinstance(s, dict):867 continue868 k = str(s.get("section_key", ""))869 if not k:870 continue871 if k == "programs" and k in sec_map:872 existing = sec_map["programs"].get("section_json", {})873 incoming = s.get("section_json", {})874 if not isinstance(existing, dict):875 existing = {}876 if not isinstance(incoming, dict):877 incoming = {}878 a = existing.get("programs", [])879 b = incoming.get("programs", [])880 if not isinstance(a, list):881 a = []882 if not isinstance(b, list):883 b = []884 existing["programs"] = a + b885 sec_map["programs"]["section_json"] = existing886 continue887 sec_map[k] = s888 889 # Campus image890 img_section = sec_map.get("campus_image") or sec_map.get("image")891 campus_url = ""892 campus_cap = ""893 if img_section:894 j = img_section.get("section_json", {})895 if isinstance(j, dict):896 campus_url = str(j.get("image_url", "")).strip()897 campus_cap = str(j.get("caption", "")).strip()898 899 # Overview data + website900 overview_json: dict | None = None901 resolved_website = (website_url or "").strip()902 903 if "overview" in sec_map:904 overview_json = sec_map["overview"].get("section_json", {})905 if not isinstance(overview_json, dict):906 overview_json = {}907 site_from_overview = get_any(908 overview_json,909 ["university_website", "university_website_url", "website", "site", "url", "homepage", "web_url"],910 )911 if not resolved_website and site_from_overview:912 resolved_website = site_from_overview913 914 # 1. University title915 if resolved_website:916 if has_stats:917 stats["university_links"] = stats.get("university_links", 0) + 1918 out += (919 f'<div class="uni-name"><a class="uni-name-link" href="{h(resolved_website)}" '920 f'target="_blank" rel="noopener noreferrer">{h(uni_name)}</a></div>'921 )922 else:923 out += f'<div class="uni-name">{h(uni_name)}</div>'924 925 # 2-3. Two-column: Summary + Campus image926 image_embedded = False927 campus_cell = ""928 if allow_remote and campus_url:929 embedded = fetch_image_data_uri(campus_url)930 if embedded:931 image_embedded = True932 campus_cell = f'<img class="campus-top-img" src="{h(embedded)}" alt="Campus Image" />'933 if campus_cap:934 campus_cell += f'<div class="campus-top-cap">{h(campus_cap)}</div>'935 else:936 campus_cell = '<div class="campus-placeholder-cell">Campus image unavailable</div>'937 else:938 campus_cell = '<div class="campus-placeholder-cell">Campus image unavailable</div>'939 940 if has_stats:941 if image_embedded:942 stats["images_embedded"] = stats.get("images_embedded", 0) + 1943 else:944 stats["images_placeholder"] = stats.get("images_placeholder", 0) + 1945 946 summary_cell = ""947 if overview_json is not None:948 j = overview_json949 founded = get_any(j, ["founded", "Founded"])950 total = get_any(j, ["total_students", "Total Students"])951 undergrad = get_any(j, ["undergraduates", "Undergraduate Students", "undergraduate_students"])952 postgrad = get_any(j, ["postgraduate_students", "Postgraduate Students"])953 acc_rate = get_any(j, ["acceptance_rate", "Acceptance Rate"])954 location = get_any(j, ["location", "Location"])955 tuition = get_any(j, [956 "tuition_out_of_state_yearly",957 "Yearly Out of State Tuition Fees",958 "Yearly Out-of-State Tuition Fees",959 "Yearly Tuition Fees",960 "Yearly Out-of-State Tuition Fees:",961 ])962 963 summary_cell += '<div class="summary-title">Summary info</div>'964 summary_cell += '<ul class="summary-ul">'965 if founded:966 summary_cell += f'<li><span class="lbl">Founded:</span> {h(founded)}</li>'967 if total:968 summary_cell += f'<li><span class="lbl">Total Students:</span> {h(total)}</li>'969 if undergrad:970 summary_cell += f'<li><span class="lbl">Undergraduate Students:</span> {h(undergrad)}</li>'971 if postgrad:972 summary_cell += f'<li><span class="lbl">Postgraduate Students:</span> {h(postgrad)}</li>'973 if acc_rate or location:974 summary_cell += "<li>"975 if acc_rate:976 summary_cell += f'<span class="lbl">Acceptance Rate:</span> {h(acc_rate)} '977 if location:978 summary_cell += f'<span class="lbl">Location:</span> {h(location)}'979 summary_cell += "</li>"980 if tuition:981 summary_cell += f'<li><span class="lbl">Yearly Tuition/Out-of-State Tuition:</span> {h(tuition)}</li>'982 summary_cell += "</ul>"983 984 if resolved_website:985 if has_stats:986 stats["website_rows"] = stats.get("website_rows", 0) + 1987 summary_cell += (988 f'<div class="uni-website"><span class="lbl">Website:</span> '989 f'<a href="{h(resolved_website)}" target="_blank" rel="noopener noreferrer">'990 f'{h(resolved_website)}</a></div>'991 )992 993 out += (994 '<table class="school-top-table" cellspacing="0" cellpadding="0"><tr>'995 f'<td class="school-top-summary" style="vertical-align:top;">{summary_cell}</td>'996 f'<td class="school-top-campus" style="vertical-align:top;">{campus_cell}</td>'997 "</tr></table>"998 )999 1000 # 4. Benefits1001 if "benefits" in sec_map:1002 j = sec_map["benefits"].get("section_json", {})1003 if not isinstance(j, dict):1004 j = {}1005 benefits = j.get("benefits", [])1006 if not isinstance(benefits, list):1007 benefits = []1008 1009 out += '<div class="benefits-section">'1010 out += '<div class="benefits-bar">Benefits for ISP students at this school</div>'1011 if benefits:1012 out += '<ul class="benefits-ul">'1013 for b in benefits:1014 b_str = str(b).strip()1015 if not b_str:1016 continue1017 out += f'<li class="benefit-li"><span class="benefit-bullet">•</span> <span class="benefit-text">{h(b_str)}</span></li>'1018 out += "</ul>"1019 else:1020 out += '<div class="muted" style="margin:4px 0 6px;">No benefits listed.</div>'1021 out += "</div>"1022 1023 # 5. Programs1024 if "programs" in sec_map:1025 j = sec_map["programs"].get("section_json", {})1026 if not isinstance(j, dict):1027 j = {}1028 programs = j.get("programs", [])1029 if not isinstance(programs, list):1030 programs = []1031 1032 # Filter inactive1033 if not include_inactive_programs:1034 def _is_active(p: dict) -> bool:1035 flag = p.get("program_active", p.get("is_active", p.get("active", 1)))1036 return is_truthy(flag)1037 1038 programs = [p for p in programs if isinstance(p, dict) and _is_active(p)]1039 1040 out += (1041 '<div class="qualify">To qualify for The International Scholars Program at '1042 f"{h(uni_name)}, you must be willing to study any of the following programs:</div>"1043 )1044 1045 if programs:1046 out += '<table class="programs">'1047 out += (1048 '<th style="width:34%">Program</th>'1049 '<th style="width:33%">Designation</th>'1050 '<th style="width:33%">Entrance Examination</th></tr></thead><tbody>'1051 )1052 1053 for p in programs:1054 if not isinstance(p, dict):1055 continue1056 1057 program_name = str(p.get("program_name", "")).strip()1058 link = str(p.get("program_link", "")).strip()1059 if not link and isinstance(p.get("program_links"), dict):1060 link = str(p["program_links"].get("web_link", "")).strip()1061 1062 program_name_html = h(program_name)1063 if link:1064 program_name_html = f'<a href="{h(link)}" target="_blank" rel="noopener noreferrer">{program_name_html}</a>'1065 1066 1067 1068 entrance = str(p.get("entrance_exam", p.get("entrance_examination", "")))1069 designation = str(p.get("designation", ""))1070 out += (1071 f"<tr>"1072 f"<td>{program_name_html}</td>"1073 f"<td>{h(designation)}</td>"1074 f"<td>{h(entrance)}</td>"1075 f"</tr>"1076 )1077 1078 out += "</tbody></table>"1079 else:1080 out += '<div class="muted" style="margin:0 0 6px;">No programs listed.</div>'1081 1082 # Extra sections1083 skip_keys = {"campus_image", "image", "overview", "benefits", "programs"}1084 for s in sections:1085 if not isinstance(s, dict):1086 continue1087 k = str(s.get("section_key", ""))1088 if not k or k in skip_keys:1089 continue1090 title = str(s.get("section_title", ""))1091 j = s.get("section_json", {})1092 if not isinstance(j, dict):1093 j = {}1094 out += render_global_blocks(k, title, j, debug)1095 1096 out += "</div>"1097 return out1098 