CoolFace
Apppublic

internationalscholarsprogram/handbook-engine

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
renderers.py1098 linesDownload Raw Back to services
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 = "&nbsp;"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;">&nbsp;</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 "&nbsp;"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">&nbsp;</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">&bull;</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