handbook_engine / app /services /renderers.py
internationalscholarsprogram's picture
fix: ISP handbook styling overhaul - margins, typography, emphasis, benefits, CSS cascade
ec94fc1
"""Renderers — mirrors PHP renderers.php.
Contains functions for rendering:
- Table of Contents (TOC)
- Global section blocks (overview, steps, bullets, tables, doc_v1, etc.)
- University section blocks (overview, benefits, programs)
- Remote image fetching as data URIs
"""
from __future__ import annotations
import base64
import logging
import re
from typing import Any
import httpx
from app.services.utils import (
emphasize_keywords,
format_money_figures,
get_any,
h,
hb_slug,
is_assoc,
is_truthy,
)
logger = logging.getLogger(__name__)
# =========================================
# Image fetching
# =========================================
def fetch_image_data_uri(url: str) -> str:
"""Fetch a remote image and return as data:... URI. Mirrors PHP fetchImageDataUri."""
url = url.strip()
if not url:
return ""
try:
with httpx.Client(verify=False, timeout=12, follow_redirects=True) as client:
resp = client.get(url)
if resp.status_code < 200 or resp.status_code >= 300 or not resp.content:
logger.warning("Image fetch failed for %s status=%d", url, resp.status_code)
return ""
data = resp.content
except Exception as exc:
logger.warning("Image fetch error for %s: %s", url, exc)
return ""
# Detect mime type from headers or magic bytes
content_type = resp.headers.get("content-type", "")
mime = ""
if "image/" in content_type:
mime = content_type.split(";")[0].strip()
else:
# Magic byte detection
if data[:8].startswith(b"\x89PNG"):
mime = "image/png"
elif data[:3] == b"\xff\xd8\xff":
mime = "image/jpeg"
elif data[:4] == b"GIF8":
mime = "image/gif"
elif data[:4] == b"RIFF" and data[8:12] == b"WEBP":
mime = "image/webp"
if not mime.startswith("image/"):
logger.warning("Invalid image mime %s for %s", mime, url)
return ""
b64 = base64.b64encode(data).decode("ascii")
return f"data:{mime};base64,{b64}"
# =========================================
# TOC sorting and rendering
# =========================================
def sort_toc(items: list[dict]) -> list[dict]:
"""Mirrors PHP sortHandbookToc — sort by sort_order/sort, stable fallback."""
for idx, e in enumerate(items):
e.setdefault("_i", idx)
def key_fn(e: dict):
so = e.get("sort_order", e.get("sort"))
if so is not None:
try:
so_num = float(so)
return (0, so_num, e.get("_i", 0))
except (ValueError, TypeError):
pass
return (1, 0.0, e.get("_i", 0))
items.sort(key=key_fn)
for e in items:
e.pop("_i", None)
return items
def render_toc(items: list[dict], debug: bool = False, show_pages: bool = True) -> str:
"""Render Table of Contents HTML (DOMPDF-safe).
Mirrors PHP renderToc().
"""
sorted_items = sort_toc(items)
out = '<!-- HANDBOOK_TOC_V2 -->'
out += '<div class="toc">'
out += '<div class="toc-heading">Table of Contents</div>'
out += (
'<table class="toc-table" width="100%" cellspacing="0" cellpadding="0"'
' style="border-collapse:collapse; table-layout:fixed; width:100%;">'
'<colgroup><col /><col width="50" /><col width="48" /></colgroup>'
)
for e in sorted_items:
if not isinstance(e, dict):
continue
title = str(e.get("title", "")).strip()
target = str(e.get("target", e.get("anchor", ""))).strip()
if not title:
continue
level = max(0, min(3, int(e.get("level", 0))))
bold = bool(e.get("bold", False))
upper = bool(e.get("upper", False))
if level == 0:
bold = True
upper = True
row_class = "toc-row--major" if level == 0 else "toc-row--sub"
if level >= 2:
row_class += " toc-row--deep"
text = title.upper() if upper else title
title_inner = h(text)
if target:
title_inner = f'<a href="{h(target)}">{title_inner}</a>'
if bold:
title_inner = f"<strong>{title_inner}</strong>"
page = str(e.get("page", "")).strip()
if show_pages and page:
page_cell = f"<strong>{h(page)}</strong>"
else:
page_cell = "&nbsp;"
indent = ""
if level == 1:
indent = "padding-left:16px;"
elif level >= 2:
indent = "padding-left:30px;"
title_style = (
"vertical-align:bottom; padding:1px 4px 1px 0; font-size:10px; "
"line-height:1.15; color:#111;"
+ (" font-weight:700;" if bold else " font-weight:400;")
+ (" text-transform:uppercase; letter-spacing:0.1px;" if upper else "")
+ (f" {indent}" if indent else "")
)
out += f'<tr class="{h(row_class)}">'
out += f'<td class="toc-title" style="{title_style}">{title_inner}</td>'
out += '<td class="toc-dots" style="vertical-align:bottom; border-bottom:1px dotted #777; height:0.85em; padding:0;">&nbsp;</td>'
out += (
f'<td class="toc-pagenum" style="vertical-align:bottom; text-align:right; '
f'padding-left:4px; font-size:10px; font-weight:700; line-height:1.15; '
f'white-space:nowrap; width:48px; color:#111;">{page_cell}</td>'
)
out += "</tr>"
out += "</table></div>"
return out
def render_toc_hardcoded(
items: list[dict],
debug: bool = False,
page_start: int = 3,
page_offset: int = 0,
) -> str:
"""Mirrors PHP renderTocHardcoded — sort, assign sequential pages, render."""
sorted_items = sort_toc(items)
seq = max(1, page_start)
for item in sorted_items:
p = str(item.get("page", "")).strip()
if p and p.lstrip("-").isdigit():
display = int(p) + page_offset
item["page"] = str(display)
if display >= seq:
seq = display + 1
else:
item["page"] = str(seq)
seq += 1
out = "<!-- HANDBOOK_TOC_HARDCODED -->\n"
out += '<div class="toc">'
out += '<p class="toc-heading">Table of Contents</p>'
out += (
'<table class="toc-table" style="table-layout:fixed;width:100%;">'
'<colgroup><col /><col width="50" /><col width="48" /></colgroup>'
)
for e in sorted_items:
if not isinstance(e, dict):
continue
title = str(e.get("title", "")).strip()
target = str(e.get("target", e.get("anchor", ""))).strip()
if not title:
continue
level = max(0, min(3, int(e.get("level", 0))))
bold = bool(e.get("bold", False))
upper = bool(e.get("upper", False))
if level == 0:
bold = True
upper = True
row_class = "toc-row--major" if level == 0 else "toc-row--sub"
if level >= 2:
row_class += " toc-row--deep"
text = title.upper() if upper else title
title_inner = h(text)
if target:
title_inner = f'<a href="{h(target)}">{title_inner}</a>'
if bold:
title_inner = f"<strong>{title_inner}</strong>"
page = str(e.get("page", "")).strip()
page_html = f"<strong>{h(page)}</strong>" if page else "&nbsp;"
indent = ""
if level == 1:
indent = "padding-left:16px;"
elif level >= 2:
indent = "padding-left:30px;"
title_style = (
"vertical-align:bottom;padding:1px 4px 1px 0;font-size:10px;"
"line-height:1.15;color:#111;"
+ ("font-weight:700;" if bold else "font-weight:400;")
+ ("text-transform:uppercase;letter-spacing:0.1px;" if upper else "")
+ indent
)
out += f'<tr class="{h(row_class)}">'
out += f'<td class="toc-title" style="{title_style}">{title_inner}</td>'
out += '<td class="toc-dots" style="vertical-align:bottom;padding:0;"><span class="toc-dots-inner">&nbsp;</span></td>'
out += (
f'<td class="toc-pagenum" style="vertical-align:bottom;text-align:right;'
f'padding-left:4px;font-size:10px;font-weight:700;line-height:1.15;'
f'white-space:nowrap;width:48px;color:#111111;">{page_html}</td>'
)
out += "</tr>"
out += "</table></div>"
return out
# =========================================
# Global blocks renderer
# =========================================
def render_global_blocks(
section_key: str,
section_title: str,
json_data: dict | list,
debug: bool = False,
*,
universities: list[dict] | None = None,
) -> str:
"""Render a single global section's content.
Mirrors PHP renderGlobalBlocks() — handles steps, bullets, tables,
doc_v1, table_v2, summary_of_universities, etc.
"""
html_out = ""
key_norm = section_key.lower().strip()
if not isinstance(json_data, dict):
json_data = {}
layout_norm = str(json_data.get("layout", "")).lower().strip()
# ── Summary of universities ──
if key_norm == "summary_of_universities":
unis = universities or []
title = section_title.strip()
if title:
html_out += f'<h2 class="h2">{h(title)}</h2>'
intro = str(json_data.get("intro", "")).strip()
if intro:
html_out += f'<p class="p">{h(format_money_figures(intro))}</p>'
elif layout_norm == "doc_v1" and isinstance(json_data.get("blocks"), list):
for b in json_data["blocks"]:
if not isinstance(b, dict):
continue
btype = str(b.get("type", ""))
if btype not in ("paragraph", "subheading", "note"):
continue
t = format_money_figures(str(b.get("text", "")))
if not t.strip():
continue
if btype == "subheading":
html_out += f'<h3 class="h3">{h(t)}</h3>'
elif btype == "note":
html_out += f'<div class="note">{h(t)}</div>'
else:
html_out += f'<p class="p">{emphasize_keywords(t)}</p>'
# Resolve list from universities or doc_v1 bullets
resolved: list[str] = []
if unis:
def uni_sort_key(u):
so = u.get("sort_order") if isinstance(u, dict) else None
if so is not None:
try:
return (0, float(so))
except (ValueError, TypeError):
pass
return (1, 0.0)
sorted_unis = sorted(unis, key=uni_sort_key)
for u in sorted_unis:
if not isinstance(u, dict):
continue
name = str(u.get("university_name", u.get("name", ""))).strip()
if name:
resolved.append(name)
if not resolved and layout_norm == "doc_v1" and isinstance(json_data.get("blocks"), list):
for b in json_data["blocks"]:
if not isinstance(b, dict) or str(b.get("type", "")) != "bullets":
continue
items = b.get("items", [])
if not isinstance(items, list):
continue
for it in items:
it_str = str(it).strip()
if it_str:
resolved.append(it_str)
# Dedupe
seen: set[str] = set()
deduped: list[str] = []
for nm in resolved:
k = nm.lower().strip()
if not k or k in seen:
continue
seen.add(k)
deduped.append(nm)
if deduped:
html_out += '<ol class="ol">'
for name in deduped:
anchor = "university_" + hb_slug(name)
html_out += f'<li><a href="#{h(anchor)}">{h(name)}</a></li>'
html_out += "</ol>"
note = str(json_data.get("note", "")).strip()
if note:
html_out += f'<div class="note">{h(format_money_figures(note))}</div>'
return html_out
# ── Section title ──
title = section_title.strip()
if title and key_norm != "table_of_contents":
html_out += f'<h2 class="h2">{h(title)}</h2>'
# ── Steps ──
steps = json_data.get("steps")
if isinstance(steps, list):
step_num = 0
for s in steps:
if not isinstance(s, dict):
continue
step_num += 1
step_title = str(s.get("title", s.get("step_title", ""))).strip()
body = format_money_figures(str(s.get("body", s.get("description", ""))).strip())
html_out += '<div class="avoid-break" style="margin:0 0 4px;">'
if step_title:
html_out += f'<div class="h3">Step {step_num}: {h(step_title)}</div>'
if body:
html_out += f'<p class="p">{emphasize_keywords(body)}</p>'
links = s.get("links", [])
if isinstance(links, list) and links:
html_out += '<ul class="ul">'
for lnk in links:
if not isinstance(lnk, dict):
continue
label = str(lnk.get("label", "Link")).strip()
url = str(lnk.get("url", "")).strip()
if not url:
continue
html_out += f'<li><a href="{h(url)}" target="_blank" rel="noopener noreferrer">{h(label)}</a></li>'
html_out += "</ul>"
qr = str(s.get("qr_url", s.get("qr_image", ""))).strip()
if qr:
html_out += f'<img src="{h(qr)}" alt="QR" style="width:60px; height:60px; margin:4px 0;" />'
html_out += "</div>"
return html_out
# ── Bullets ──
has_bullets = isinstance(json_data.get("bullets"), list)
has_items = isinstance(json_data.get("items"), list)
if has_bullets or (layout_norm == "bullets_with_note" and has_items):
lst = json_data.get("items") if has_items else json_data.get("bullets")
html_out += '<ul class="ul">'
for b in lst:
b_str = format_money_figures(str(b).strip())
if not b_str:
continue
html_out += f"<li>{emphasize_keywords(b_str)}</li>"
html_out += "</ul>"
note = format_money_figures(str(json_data.get("note", json_data.get("footnote", ""))).strip())
if note:
html_out += f'<div class="note">{h(note)}</div>'
return html_out
# ── Basic table ──
cols = json_data.get("columns")
rows = json_data.get("rows")
if isinstance(cols, list) and isinstance(rows, list):
html_out += '<table class="tbl">'
if cols:
html_out += "<thead><tr>"
for c in cols:
html_out += f"<th>{h(str(c))}</th>"
html_out += "</tr></thead>"
html_out += "<tbody>"
for r in rows:
if not isinstance(r, (list, dict)):
continue
html_out += "<tr>"
if isinstance(r, dict):
for col_label in cols:
key_guess = re.sub(r"[^a-z0-9]+", "_", str(col_label).lower())
cell = r.get(key_guess, "")
html_out += f"<td>{h(format_money_figures(str(cell)))}</td>"
else:
for cell in r:
html_out += f"<td>{h(format_money_figures(str(cell)))}</td>"
html_out += "</tr>"
html_out += "</tbody></table>"
return html_out
# ── table_v2 ──
if layout_norm == "table_v2":
base_cols = json_data.get("base_columns", [])
groups = json_data.get("header_groups", [])
rows = json_data.get("rows", [])
if not isinstance(base_cols, list):
base_cols = []
if not isinstance(groups, list):
groups = []
if not isinstance(rows, list):
rows = []
all_cols: list[dict] = []
for c in base_cols:
if isinstance(c, dict):
all_cols.append({"key": str(c.get("key", "")), "label": str(c.get("label", ""))})
for g in groups:
if not isinstance(g, dict):
continue
g_cols = g.get("columns", [])
if not isinstance(g_cols, list):
g_cols = []
for c in g_cols:
if isinstance(c, dict):
all_cols.append({"key": str(c.get("key", "")), "label": str(c.get("label", ""))})
html_out += '<table class="tbl tbl-comparison"><thead>'
has_group_row = bool(groups)
if has_group_row:
html_out += "<tr>"
for c in base_cols:
if isinstance(c, dict):
html_out += f'<th rowspan="2">{h(str(c.get("label", "")))}</th>'
for g in groups:
if not isinstance(g, dict):
continue
g_cols = g.get("columns", [])
if not isinstance(g_cols, list):
g_cols = []
span = max(1, len(g_cols))
html_out += f'<th colspan="{span}">{h(str(g.get("label", "")))}</th>'
html_out += "</tr><tr>"
for g in groups:
if not isinstance(g, dict):
continue
g_cols = g.get("columns", [])
if not isinstance(g_cols, list):
g_cols = []
for c in g_cols:
if isinstance(c, dict):
html_out += f'<th>{h(str(c.get("label", "")))}</th>'
html_out += "</tr>"
else:
html_out += "<tr>"
for c in all_cols:
html_out += f'<th>{h(c.get("label", ""))}</th>'
html_out += "</tr>"
html_out += "</thead><tbody>"
for r in rows:
if not isinstance(r, dict):
continue
html_out += "<tr>"
for c in all_cols:
k = c.get("key", "")
val = r.get(k, "")
if isinstance(val, dict):
val = val.get("text", "")
html_out += f"<td>{h(format_money_figures(str(val)))}</td>"
html_out += "</tr>"
html_out += "</tbody></table>"
return html_out
# ── doc_v1 ──
if layout_norm == "doc_v1" and isinstance(json_data.get("blocks"), list):
for b in json_data["blocks"]:
if not isinstance(b, dict):
continue
btype = str(b.get("type", ""))
if btype == "paragraph":
t = format_money_figures(str(b.get("text", "")))
if t.strip():
html_out += f'<p class="p">{emphasize_keywords(t)}</p>'
elif btype == "subheading":
t = format_money_figures(str(b.get("text", "")))
if t.strip():
html_out += f'<h3 class="h3 keep-with-next">{h(t)}</h3>'
elif btype == "bullets":
items = b.get("items", [])
if not isinstance(items, list):
items = []
html_out += '<ul class="ul">'
for it in items:
it_str = format_money_figures(str(it).strip())
if it_str:
html_out += f"<li>{emphasize_keywords(it_str)}</li>"
html_out += "</ul>"
elif btype == "numbered_list":
items = b.get("items", [])
if not isinstance(items, list):
items = []
html_out += '<ol class="ol">'
for it in items:
it_str = format_money_figures(str(it).strip())
if it_str:
html_out += f"<li>{emphasize_keywords(it_str)}</li>"
html_out += "</ol>"
elif btype == "note":
t = format_money_figures(str(b.get("text", "")))
if t.strip():
html_out += f'<div class="note">{h(t)}</div>'
elif btype == "note_inline":
parts = b.get("parts", [])
if not isinstance(parts, list):
parts = []
txt = ""
for p in parts:
if not isinstance(p, dict):
continue
t = format_money_figures(str(p.get("text", "")))
if not t:
continue
style = str(p.get("style", ""))
if style == "red_bold":
txt += f"<strong>{h(t)}</strong>"
else:
txt += h(t)
if re.sub(r"<[^>]+>", "", txt).strip():
html_out += f'<div class="note">{txt}</div>'
elif btype == "table_v1":
t_cols = b.get("columns", [])
t_rows = b.get("rows", [])
if not isinstance(t_cols, list):
t_cols = []
if not isinstance(t_rows, list):
t_rows = []
html_out += '<table class="tbl">'
if t_cols:
html_out += "<thead><tr>"
for c in t_cols:
html_out += f"<th>{h(str(c))}</th>"
html_out += "</tr></thead>"
html_out += "<tbody>"
for r in t_rows:
if not isinstance(r, list):
continue
html_out += "<tr>"
for cell in r:
html_out += f"<td>{h(format_money_figures(str(cell)))}</td>"
html_out += "</tr>"
html_out += "</tbody></table>"
elif btype in ("table_v3", "table_v4"):
t_rows = b.get("rows", [])
if not isinstance(t_rows, list):
t_rows = []
html_out += '<table class="tbl"><tbody>'
for r in t_rows:
if not isinstance(r, list):
continue
html_out += "<tr>"
for cell in r:
colspan = 1
rowspan = 1
text_val = ""
if isinstance(cell, dict):
text_val = str(cell.get("text", ""))
cs = cell.get("colspan")
rs = cell.get("rowspan")
if cs is not None and str(cs).isdigit():
colspan = int(cs)
if rs is not None and str(rs).isdigit():
rowspan = int(rs)
else:
text_val = str(cell)
attr = ""
if colspan > 1:
attr += f' colspan="{colspan}"'
if rowspan > 1:
attr += f' rowspan="{rowspan}"'
html_out += f"<td{attr}>{h(format_money_figures(text_val))}</td>"
html_out += "</tr>"
html_out += "</tbody></table>"
return html_out
# ── Fallback ──
if "text" in json_data:
html_out += f'<p class="p">{h(format_money_figures(str(json_data["text"])))}</p>'
if not html_out.strip():
logger.warning(
"Empty section render for key=%s title=%s",
section_key, section_title,
)
return html_out
# =========================================
# University section renderer
# =========================================
def render_university_section(
uni_name: str,
sections: list[dict],
allow_remote: bool,
is_first_uni: bool,
include_inactive_programs: bool = False,
website_url: str = "",
anchor_id: str | None = None,
debug: bool = False,
stats: dict | None = None,
sort_order: int | None = None,
) -> str:
"""Render a single university section. Mirrors PHP renderUniversitySection."""
classes = ["uni"]
if not is_first_uni:
classes.append("page-break")
id_attr = f' id="{h(anchor_id)}"' if anchor_id else ""
sort_attr = f' data-sort="{h(str(sort_order))}"' if sort_order is not None else ""
out = f'<div class="{" ".join(classes)}"{id_attr}{sort_attr} data-section-key="university" data-section-title="{h(uni_name)}">'
has_stats = isinstance(stats, dict)
if has_stats:
stats["universities"] = stats.get("universities", 0) + 1
# Build map; merge duplicate "programs" sections
sec_map: dict[str, dict] = {}
for s in sections:
if not isinstance(s, dict):
continue
k = str(s.get("section_key", ""))
if not k:
continue
if k == "programs" and k in sec_map:
existing = sec_map["programs"].get("section_json", {})
incoming = s.get("section_json", {})
if not isinstance(existing, dict):
existing = {}
if not isinstance(incoming, dict):
incoming = {}
a = existing.get("programs", [])
b = incoming.get("programs", [])
if not isinstance(a, list):
a = []
if not isinstance(b, list):
b = []
existing["programs"] = a + b
sec_map["programs"]["section_json"] = existing
continue
sec_map[k] = s
# Campus image
img_section = sec_map.get("campus_image") or sec_map.get("image")
campus_url = ""
campus_cap = ""
if img_section:
j = img_section.get("section_json", {})
if isinstance(j, dict):
campus_url = str(j.get("image_url", "")).strip()
campus_cap = str(j.get("caption", "")).strip()
# Overview data + website
overview_json: dict | None = None
resolved_website = (website_url or "").strip()
if "overview" in sec_map:
overview_json = sec_map["overview"].get("section_json", {})
if not isinstance(overview_json, dict):
overview_json = {}
site_from_overview = get_any(
overview_json,
["university_website", "university_website_url", "website", "site", "url", "homepage", "web_url"],
)
if not resolved_website and site_from_overview:
resolved_website = site_from_overview
# 1. University title
if resolved_website:
if has_stats:
stats["university_links"] = stats.get("university_links", 0) + 1
out += (
f'<div class="uni-name"><a class="uni-name-link" href="{h(resolved_website)}" '
f'target="_blank" rel="noopener noreferrer">{h(uni_name)}</a></div>'
)
else:
out += f'<div class="uni-name">{h(uni_name)}</div>'
# 2-3. Two-column: Summary + Campus image
image_embedded = False
campus_cell = ""
if allow_remote and campus_url:
embedded = fetch_image_data_uri(campus_url)
if embedded:
image_embedded = True
campus_cell = f'<img class="campus-top-img" src="{h(embedded)}" alt="Campus Image" />'
if campus_cap:
campus_cell += f'<div class="campus-top-cap">{h(campus_cap)}</div>'
else:
campus_cell = '<div class="campus-placeholder-cell">Campus image unavailable</div>'
else:
campus_cell = '<div class="campus-placeholder-cell">Campus image unavailable</div>'
if has_stats:
if image_embedded:
stats["images_embedded"] = stats.get("images_embedded", 0) + 1
else:
stats["images_placeholder"] = stats.get("images_placeholder", 0) + 1
summary_cell = ""
if overview_json is not None:
j = overview_json
founded = get_any(j, ["founded", "Founded"])
total = get_any(j, ["total_students", "Total Students"])
undergrad = get_any(j, ["undergraduates", "Undergraduate Students", "undergraduate_students"])
postgrad = get_any(j, ["postgraduate_students", "Postgraduate Students"])
acc_rate = get_any(j, ["acceptance_rate", "Acceptance Rate"])
location = get_any(j, ["location", "Location"])
tuition = get_any(j, [
"tuition_out_of_state_yearly",
"Yearly Out of State Tuition Fees",
"Yearly Out-of-State Tuition Fees",
"Yearly Tuition Fees",
"Yearly Out-of-State Tuition Fees:",
])
summary_cell += '<div class="summary-title">Summary info</div>'
summary_cell += '<ul class="summary-ul">'
if founded:
summary_cell += f'<li><span class="lbl">Founded:</span> {h(founded)}</li>'
if total:
summary_cell += f'<li><span class="lbl">Total Students:</span> {h(total)}</li>'
if undergrad:
summary_cell += f'<li><span class="lbl">Undergraduate Students:</span> {h(undergrad)}</li>'
if postgrad:
summary_cell += f'<li><span class="lbl">Postgraduate Students:</span> {h(postgrad)}</li>'
if acc_rate or location:
summary_cell += "<li>"
if acc_rate:
summary_cell += f'<span class="lbl">Acceptance Rate:</span> {h(acc_rate)} '
if location:
summary_cell += f'<span class="lbl">Location:</span> {h(location)}'
summary_cell += "</li>"
if tuition:
summary_cell += f'<li><span class="lbl">Yearly Tuition/Out-of-State Tuition:</span> {h(tuition)}</li>'
summary_cell += "</ul>"
if resolved_website:
if has_stats:
stats["website_rows"] = stats.get("website_rows", 0) + 1
summary_cell += (
f'<div class="uni-website"><span class="lbl">Website:</span> '
f'<a href="{h(resolved_website)}" target="_blank" rel="noopener noreferrer">'
f'{h(resolved_website)}</a></div>'
)
out += (
'<table class="school-top-table" cellspacing="0" cellpadding="0"><tr>'
f'<td class="school-top-summary" style="vertical-align:top;">{summary_cell}</td>'
f'<td class="school-top-campus" style="vertical-align:top;">{campus_cell}</td>'
"</tr></table>"
)
# 4. Benefits
if "benefits" in sec_map:
j = sec_map["benefits"].get("section_json", {})
if not isinstance(j, dict):
j = {}
benefits = j.get("benefits", [])
if not isinstance(benefits, list):
benefits = []
out += '<div class="benefits-section">'
out += '<div class="benefits-bar">Benefits for ISP students at this school</div>'
if benefits:
out += '<ul class="benefits-ul">'
for b in benefits:
b_str = str(b).strip()
if not b_str:
continue
out += f'<li class="benefit-li"><span class="benefit-bullet">&bull;</span> <span class="benefit-text">{h(b_str)}</span></li>'
out += "</ul>"
else:
out += '<div class="muted" style="margin:4px 0 6px;">No benefits listed.</div>'
out += "</div>"
# 5. Programs
if "programs" in sec_map:
j = sec_map["programs"].get("section_json", {})
if not isinstance(j, dict):
j = {}
programs = j.get("programs", [])
if not isinstance(programs, list):
programs = []
# Filter inactive
if not include_inactive_programs:
def _is_active(p: dict) -> bool:
flag = p.get("program_active", p.get("is_active", p.get("active", 1)))
return is_truthy(flag)
programs = [p for p in programs if isinstance(p, dict) and _is_active(p)]
out += (
'<div class="qualify">To qualify for The International Scholars Program at '
f"{h(uni_name)}, you must be willing to study any of the following programs:</div>"
)
if programs:
out += '<table class="programs">'
out += (
'<thead><tr><th style="width:22%">Program</th>'
'<th style="width:14%">Designation</th>'
'<th style="width:12%">Entrance Examination</th>'
'<th style="width:34%">Examples of Career Pathways</th>'
'<th style="width:18%">Funding Category</th></tr></thead><tbody>'
)
for p in programs:
if not isinstance(p, dict):
continue
program_name = str(p.get("program_name", "")).strip()
link = str(p.get("program_link", "")).strip()
if not link and isinstance(p.get("program_links"), dict):
link = str(p["program_links"].get("web_link", "")).strip()
program_name_html = h(program_name)
if link:
program_name_html = f'<a href="{h(link)}" target="_blank" rel="noopener noreferrer">{program_name_html}</a>'
career = p.get("career_pathways", [])
career_html = ""
if isinstance(career, list):
career_items = [str(x).strip() for x in career if str(x).strip()]
if career_items:
career_html = '<ul class="career-list">'
for ci in career_items:
career_html += f"<li>{h(ci)}</li>"
career_html += "</ul>"
else:
raw = str(career).strip()
if raw:
lines = [l.strip() for l in re.split(r"[\r\n]+", raw) if l.strip()]
if len(lines) > 1:
career_html = '<ul class="career-list">'
for line in lines:
career_html += f"<li>{h(line)}</li>"
career_html += "</ul>"
else:
career_html = h(raw)
if not career_html:
career_html = "&nbsp;"
entrance = str(p.get("entrance_exam", p.get("entrance_examination", "")))
designation = str(p.get("designation", ""))
funding = str(p.get("funding_category", ""))
out += (
f"<tr>"
f"<td>{program_name_html}</td>"
f"<td>{h(designation)}</td>"
f"<td>{h(entrance)}</td>"
f"<td>{career_html}</td>"
f"<td>{h(funding)}</td>"
f"</tr>"
)
out += "</tbody></table>"
else:
out += '<div class="muted" style="margin:0 0 6px;">No programs listed.</div>'
# Extra sections
skip_keys = {"campus_image", "image", "overview", "benefits", "programs"}
for s in sections:
if not isinstance(s, dict):
continue
k = str(s.get("section_key", ""))
if not k or k in skip_keys:
continue
title = str(s.get("section_title", ""))
j = s.get("section_json", {})
if not isinstance(j, dict):
j = {}
out += render_global_blocks(k, title, j, debug)
out += "</div>"
return out