$ cat node-template.py

Infographic Generator

// Generates a professional infographic from text content. Uses an LLM to analyze the content, select the best visualization template, and produce AntV Infographic DSL. The DSL is then rendered into a PNG or SVG image. Supports 237 templates across charts, lists, comparisons, hierarchies, sequences, and relations.

Process
Document
template.py
1import os2import sys3import json4import traceback56try:7    import httpx8except ImportError:9    import subprocess10    subprocess.check_call([sys.executable, "-m", "pip", "install", "httpx"])11    import httpx1213try:14    import requests15except ImportError:16    import subprocess17    subprocess.check_call([sys.executable, "-m", "pip", "install", "requests"])18    import requests1920import asyncio2122# Environment23LITELLM_BASE_URL = os.getenv("OPENAI_BASE_URL")24LITELLM_API_KEY = os.getenv("LITELLM_API_KEY")25EMBLEMA_API_BASE_URL = os.getenv("EMBLEMA_API_BASE_URL", "http://localhost:3000")26USER_TOKEN = os.getenv("USER_TOKEN")27OUTPUT_DIR = "/data/output"28MAX_CONTENT_LENGTH = 100_0002930ALLOWED_MODELS = {31    "llama3.3-70b-instruct",32    "openai/gpt-oss-120b",33    "gemini-2.5-flash",34    "gemini-2.5-pro",35    "deepseek-chat",36}3738# ---------------------------------------------------------------------------39# System prompt — based on official AntV Infographic skill files with40# data-density, capacity, and layout-quality constraints.41# ---------------------------------------------------------------------------42SYSTEM_PROMPT = """You are an expert infographic designer using the AntV Infographic DSL.4344Given the user's content, produce ONE block of AntV Infographic DSL syntax that best visualizes the information.4546## STEP 0 — THINK BEFORE YOU GENERATE4748Before writing any DSL, silently analyse the input:491. Count distinct data items. How many items/nodes/points exist?502. Measure text: are labels short (1-3 words) or long (4+ words)?513. Decide structure type: list, sequence, compare, hierarchy, relation, chart.524. Pick a template whose capacity fits the item count (see TEMPLATE CAPACITY below).535. Plan concise labels — shorten aggressively if the source text is verbose.54Then output ONLY the DSL (no analysis, no commentary).5556## DSL FORMAT5758- First line: `infographic <template-name>`59- Blocks: `data` / `theme`, indented with two spaces.60- Key-value: `key value` (space-separated).61- Arrays: dash prefix `- ` for each entry.62- No JSON, no Markdown, no code fences, no explanation.6364Example:6566infographic list-row-horizontal-icon-arrow67data68  title Internet Technology Evolution69  desc Key milestones from Web 1.0 to AI70  lists71    - time 199172      label Web 1.073      desc First website by Tim Berners-Lee74      icon web75    - time 200476      label Web 2.077      desc Social media goes mainstream78      icon account multiple79    - time 202380      label AI Large Model81      desc ChatGPT sparks the AI revolution82      icon brain83theme84  palette #3b82f6 #8b5cf6 #f973168586## DATA FIELDS BY TEMPLATE TYPE (use ONLY the correct field)8788| Template prefix | Data field | Notes |89|---|---|---|90| `list-*` | `lists` | Flat array of items |91| `sequence-*` | `sequences` | Optional `order asc/desc` |92| `compare-binary-*` / `compare-hierarchy-left-right-*` | `compares` | MUST have exactly 2 root items, sub-items in `children` |93| `compare-swot` | `compares` | Exactly 4 root items (S/W/O/T) with `children` |94| `compare-quadrant-*` | `compares` | Exactly 4 items |95| `hierarchy-structure` / `hierarchy-structure-mirror` | `items` | Up to 3 nesting levels via `children` |96| Other `hierarchy-*` | `root` | Single root, tree via `children` nesting. Do NOT repeat `root`. |97| `relation-*` | `nodes` + `relations` | Edges: `A - label -> B` or `A -->|label| B` |98| `chart-*` | `values` | Optional `category` |99| Fallback | `items` | Only when none of the above match |100101NEVER mix data fields. Use exactly one per template.102103## TEXT DENSITY RULES (CRITICAL for visual quality)104105The rendering engine allocates fixed space per card/node. Overflow causes overlapping text. Obey these limits:106107| Field | Max chars | Guidance |108|---|---|---|109| `title` (chart-*) | 25 | Chart title. Keep concise — 3-4 words max. |110| `desc` (chart-*) | 50 | Chart subtitle. One short phrase. |111| `title` (other) | 35 | Main title. Concise headline. |112| `desc` (global) | 60 | Subtitle under the title. One line. |113| `label` | 25 | Item label. Shorten aggressively: abbreviate, drop articles/prepositions. |114| `desc` (per item) | 50 | Keep to one sentence. Omit if the label is self-explanatory. |115| `value` | 8 | Numeric only. E.g. `82%`, `3.94`, `1560` |116117Chart title + desc examples (chart-* templates):118  BAD:  title "Docenti con le valutazioni più basse nel primo modulo"  (52 chars — too long!)119  GOOD: title "Docenti Peggiori"  desc "Valutazioni Modulo I"  (16 + 20 chars)120  BAD:  title "Distribuzione delle risposte al sondaggio"      (40 chars — too long!)121  GOOD: title "Risposte Sondaggio"  desc "Distribuzione per modulo"  (18 + 25 chars)122123General shortening examples:124  BAD:  "DINAMICA DELLE STRUTTURE ORGANIZZATIVE"   (40 chars)125  GOOD: "Strutture Organizzative"                    (23 chars)126  BAD:  "La lezione e stata interessante?"           (31 chars)127  GOOD: "Interesse Lezione"                          (17 chars)128129When the source language is verbose (Italian, German, French), shorten to core nouns only. Drop verbs, articles, prepositions.130131## TEMPLATE CAPACITY LIMITS132133Exceeding these limits causes cramped, overlapping cards. If data exceeds a limit, either CHOOSE A DIFFERENT TEMPLATE or SUMMARIZE/GROUP items.134135| Template pattern | Max items | When exceeded, prefer |136|---|---|---|137| `list-grid-*` | 6 | list-column-*, list-waterfall-*, list-zigzag-* |138| `list-row-*` | 5 | list-column-*, sequence-snake-* |139| `list-column-*` | 8 | hierarchy-mindmap-*, sequence-roadmap-* |140| `list-sector-*` | 6 | list-column-*, list-waterfall-* |141| `list-pyramid-*` | 5 | sequence-pyramid-*, sequence-funnel-* |142| `sequence-steps-*` | 6 | sequence-snake-*, sequence-roadmap-* |143| `sequence-timeline-*` | 8 | sequence-roadmap-*, list-column-* |144| `sequence-roadmap-*` | 10 | Split into two infographics |145| `sequence-snake-*` | 10 | Split or use hierarchy-mindmap-* |146| `compare-binary-*` | 2 root + 5 children each | Reduce children |147| `compare-swot` | 4 root + 4 children each | Summarise children |148| `hierarchy-tree-*` | 3 levels, 4-5 nodes/level | Prune or use mindmap |149| `hierarchy-mindmap-*` | 3 levels, 6 nodes/level | OK for large data |150| `chart-pie-*` | 6 slices | Group small slices into "Other" |151| `chart-bar-*` / `chart-column-*` | 8 bars | Group or top-N |152| `relation-dagre-flow-*` | 10 nodes | Simplify graph |153154## TEMPLATE SELECTION GUIDE155156Match the content structure to a template family:157158- Strict order (process/steps/timeline/trend) -> `sequence-*`159  - Dates -> `sequence-timeline-*`160  - Step progression -> `sequence-stairs-*`, `sequence-ascending-*`, `sequence-steps-*`161  - Roadmap -> `sequence-roadmap-vertical-*`162  - Circular cycle -> `sequence-circular-simple`163  - Narrowing/filtering -> `sequence-funnel-simple`, `sequence-pyramid-simple`164  - Snake/zigzag path -> `sequence-snake-steps-*`, `sequence-zigzag-*`165- Feature listing / bullet points -> `list-row-*` (horizontal) or `list-column-*` (vertical)166- Grid of cards (FEW items) -> `list-grid-*` (max 6 items!)167- Waterfall / cascading -> `list-waterfall-*`168- Binary A-vs-B -> `compare-binary-*`169- SWOT -> `compare-swot`170- 2x2 matrix -> `compare-quadrant-*`171- Tree / categorization -> `hierarchy-tree-*`172- Mind map / brainstorm -> `hierarchy-mindmap-*`173- Org chart -> `hierarchy-structure`, `hierarchy-structure-mirror`174- Numeric data -> `chart-*` (pie=proportions, bar/column=comparison, line=trend)175- Word cloud -> `chart-wordcloud`176- Network/dependencies -> `relation-dagre-flow-*`, `relation-network-*`, `relation-circle-*`177178## HIERARCHY TREE VARIANTS179180Tree templates follow the pattern: `hierarchy-tree-{direction}-{edge}-{node}`181- Direction: (default=top-down), `bt` (bottom-up), `lr` (left-right), `rl` (right-left)182- Edge style: `curved-line`, `dashed-line`, `dashed-arrow`, `distributed-origin`, `tech-style`183- Node style: `badge-card`, `capsule-item`, `compact-card`, `ribbon-card`, `rounded-rect-node`184185## AVAILABLE TEMPLATES (237 total)186187chart (11): chart-bar-plain-text, chart-column-simple, chart-line-plain-text,188chart-pie-compact-card, chart-pie-donut-compact-card, chart-pie-donut-pill-badge,189chart-pie-donut-plain-text, chart-pie-pill-badge, chart-pie-plain-text,190chart-wordcloud, chart-wordcloud-rotate191192list (29): list-column-done-list, list-column-simple-vertical-arrow, list-column-vertical-icon-arrow,193list-grid-badge-card, list-grid-candy-card-lite, list-grid-circular-progress,194list-grid-compact-card, list-grid-done-list, list-grid-horizontal-icon-arrow,195list-grid-progress-card, list-grid-ribbon-card, list-grid-simple,196list-pyramid-badge-card, list-pyramid-compact-card, list-pyramid-rounded-rect-node,197list-row-circular-progress, list-row-horizontal-icon-arrow, list-row-horizontal-icon-line,198list-row-simple-horizontal-arrow, list-row-simple-illus,199list-sector-half-plain-text, list-sector-plain-text, list-sector-simple,200list-waterfall-badge-card, list-waterfall-compact-card,201list-zigzag-down-compact-card, list-zigzag-down-simple,202list-zigzag-up-compact-card, list-zigzag-up-simple203204sequence (47): sequence-ascending-stairs-3d-simple, sequence-ascending-stairs-3d-underline-text,205sequence-ascending-steps, sequence-circle-arrows-indexed-card,206sequence-circular-simple, sequence-circular-underline-text,207sequence-color-snake-steps-horizontal-icon-line, sequence-color-snake-steps-simple-illus,208sequence-cylinders-3d-simple, sequence-filter-mesh-simple, sequence-filter-mesh-underline-text,209sequence-funnel-simple, sequence-horizontal-zigzag-horizontal-icon-line,210sequence-horizontal-zigzag-plain-text, sequence-horizontal-zigzag-simple,211sequence-horizontal-zigzag-simple-horizontal-arrow, sequence-horizontal-zigzag-simple-illus,212sequence-horizontal-zigzag-underline-text, sequence-mountain-underline-text,213sequence-pyramid-simple, sequence-roadmap-vertical-badge-card,214sequence-roadmap-vertical-pill-badge, sequence-roadmap-vertical-plain-text,215sequence-roadmap-vertical-quarter-circular, sequence-roadmap-vertical-quarter-simple-card,216sequence-roadmap-vertical-simple, sequence-roadmap-vertical-underline-text,217sequence-snake-steps-compact-card, sequence-snake-steps-pill-badge,218sequence-snake-steps-simple, sequence-snake-steps-simple-illus,219sequence-snake-steps-underline-text, sequence-stairs-front-compact-card,220sequence-stairs-front-pill-badge, sequence-stairs-front-simple,221sequence-steps-badge-card, sequence-steps-simple, sequence-steps-simple-illus,222sequence-timeline-done-list, sequence-timeline-plain-text,223sequence-timeline-rounded-rect-node, sequence-timeline-simple,224sequence-timeline-simple-illus, sequence-zigzag-pucks-3d-indexed-card,225sequence-zigzag-pucks-3d-simple, sequence-zigzag-pucks-3d-underline-text,226sequence-zigzag-steps-underline-text227228compare (20): compare-binary-horizontal-badge-card-arrow, compare-binary-horizontal-badge-card-fold,229compare-binary-horizontal-badge-card-vs, compare-binary-horizontal-compact-card-arrow,230compare-binary-horizontal-compact-card-fold, compare-binary-horizontal-compact-card-vs,231compare-binary-horizontal-simple-arrow, compare-binary-horizontal-simple-fold,232compare-binary-horizontal-simple-vs, compare-binary-horizontal-underline-text-arrow,233compare-binary-horizontal-underline-text-fold, compare-binary-horizontal-underline-text-vs,234compare-hierarchy-left-right-circle-node-pill-badge,235compare-hierarchy-left-right-circle-node-plain-text,236compare-hierarchy-row-letter-card-compact-card,237compare-hierarchy-row-letter-card-rounded-rect-node,238compare-quadrant-quarter-circular, compare-quadrant-quarter-simple-card,239compare-quadrant-simple-illus, compare-swot240241hierarchy (112): hierarchy-mindmap-branch-gradient-capsule-item, hierarchy-mindmap-branch-gradient-circle-progress,242hierarchy-mindmap-branch-gradient-compact-card, hierarchy-mindmap-branch-gradient-lined-palette,243hierarchy-mindmap-branch-gradient-rounded-rect, hierarchy-mindmap-level-gradient-capsule-item,244hierarchy-mindmap-level-gradient-circle-progress, hierarchy-mindmap-level-gradient-compact-card,245hierarchy-mindmap-level-gradient-lined-palette, hierarchy-mindmap-level-gradient-rounded-rect,246hierarchy-structure, hierarchy-structure-mirror,247hierarchy-tree-[bt|lr|rl]-{curved-line|dashed-line|dashed-arrow|distributed-origin|tech-style}-{badge-card|capsule-item|compact-card|ribbon-card|rounded-rect-node} (100 combinations)248249relation (18): relation-circle-circular-progress, relation-circle-icon-badge,250relation-dagre-flow-lr-animated-badge-card, relation-dagre-flow-lr-animated-capsule,251relation-dagre-flow-lr-animated-compact-card, relation-dagre-flow-lr-animated-simple-circle-node,252relation-dagre-flow-lr-badge-card, relation-dagre-flow-lr-compact-card,253relation-dagre-flow-lr-simple-circle-node, relation-dagre-flow-tb-animated-badge-card,254relation-dagre-flow-tb-animated-capsule, relation-dagre-flow-tb-animated-compact-card,255relation-dagre-flow-tb-animated-simple-circle-node, relation-dagre-flow-tb-badge-card,256relation-dagre-flow-tb-compact-card, relation-dagre-flow-tb-simple-circle-node,257relation-network-icon-badge, relation-network-simple-circle-node258259## ICON KEYWORDS260261Use icon keywords (matched by the renderer). Examples:262star fill, check circle, trending up, chart bar, users, lightbulb, rocket,263shield, target, globe, code, database, search, alert triangle, clock, calendar,264brain, heart, zap, trophy, sprout, document text, web, cellphone, cloud,265application brackets, sun, moon, flash fast, secure shield check, account multiple266267## THEME OPTIONS268269Optional `theme` block for customisation:270271Dark theme + custom palette:272  theme dark273    palette274      - #61DDAA275      - #F6BD16276      - #F08BB4277278Hand-drawn style:279  theme280    stylize rough281    base282      text283        font-family 851tegakizatsu284285Available stylize types: rough, pattern, linear-gradient, radial-gradient286287## INPUT DATA FORMAT288289The input may include tool call results. Pay attention to:290- `intermediate: true` = research/helper steps. Useful context but SECONDARY.291- `intermediate: false` (or absent) = PRIMARY/FINAL data. Focus on these.292Prioritise non-intermediate content for the main data points.293294## COMPLETE DSL EXAMPLES295296List (horizontal arrow):297infographic list-row-horizontal-icon-arrow298data299  title Feature List300  lists301    - label Fast302      icon flash fast303    - label Secure304      icon secure shield check305    - label Scalable306      icon cloud307308Sequence (steps):309infographic sequence-steps-simple310data311  title Build Process312  sequences313    - label Design314      desc Create wireframes315    - label Develop316      desc Build the MVP317    - label Launch318      desc Release to users319  order asc320321Hierarchy (tree):322infographic hierarchy-tree-curved-line-rounded-rect-node323data324  root325    label Company326    children327      - label Engineering328        children329          - label Frontend330          - label Backend331      - label Marketing332333Compare (SWOT):334infographic compare-swot335data336  compares337    - label Strengths338      children339        - label Strong brand340        - label Loyal users341    - label Weaknesses342      children343        - label High cost344    - label Opportunities345      children346        - label Emerging markets347    - label Threats348      children349        - label Competitors350351Chart (column):352infographic chart-column-simple353data354  title Monthly Revenue355  values356    - label Jan357      value 1280358    - label Feb359      value 1560360    - label Mar361      value 1890362363Relation (flow):364infographic relation-dagre-flow-tb-simple-circle-node365data366  nodes367    - id A368      label Input369    - id B370      label Process371    - id C372      label Output373  relations374    A - feeds -> B375    B - produces -> C376377## CRITICAL RULES3783791. Output ONLY DSL — no markdown, no code fences, no commentary, no explanation.3802. First line MUST be `infographic <template-name>`.3813. Two-space indentation throughout.3824. Use the CORRECT data field for the chosen template (see DATA FIELDS table).3835. Respect TEXT DENSITY LIMITS: label <= 25 chars, desc <= 50 chars, title <= 35 chars.3846. For chart-* templates: title MUST be <= 25 chars (3-4 words), desc <= 50 chars. Keep chart titles concise.3857. Respect TEMPLATE CAPACITY LIMITS. If data exceeds capacity, choose a larger template or summarise.3868. When source text is verbose, SHORTEN to core nouns. Drop articles, verbs, prepositions.3879. Do NOT fabricate data unrelated to the user's content.38810. Binary compare templates MUST have exactly 2 root items.38911. Hierarchy templates use single `root` (do not repeat `root`).39012. Preserve the user's language for labels — but shorten within that language.39113. When a specific template is requested, use it even if another might fit better.392"""393394# ---------------------------------------------------------------------------395# Template DSL examples — fetched at runtime from the static manifest.396# The manifest lives at /templates/infographic/manifest.json (served by Next.js)397# and contains { id, category, syntax } for all 237 templates.398# ---------------------------------------------------------------------------399400_MANIFEST_CACHE = None401402def _fetch_manifest():403    """Fetch the template manifest once and cache it."""404    global _MANIFEST_CACHE405    if _MANIFEST_CACHE is not None:406        return _MANIFEST_CACHE407408    url = f"{EMBLEMA_API_BASE_URL}/templates/infographic/manifest.json"409    try:410        print(f"Fetching template manifest: {url}", file=sys.stderr)411        resp = requests.get(url, timeout=15)412        resp.raise_for_status()413        manifest = resp.json()414        # Build lookup dict: id -> syntax415        _MANIFEST_CACHE = {entry["id"]: entry.get("syntax", "") for entry in manifest}416        print(f"Loaded {len(_MANIFEST_CACHE)} template examples from manifest", file=sys.stderr)417        return _MANIFEST_CACHE418    except Exception as e:419        print(f"Warning: Could not fetch template manifest: {e}", file=sys.stderr)420        _MANIFEST_CACHE = {}421        return _MANIFEST_CACHE422423424def build_template_instruction(template_name):425    """When user picks a specific template, inject its DSL example into prompt."""426    if not template_name or template_name == "auto":427        return ""428429    manifest = _fetch_manifest()430    example = manifest.get(template_name, "")431432    if example:433        return (434            "\n\nYou MUST use the template '" + template_name + "'.\n"435            "Here is the reference syntax showing the correct DSL structure:\n\n" + example + "\n\n"436            "Rules:\n"437            "- Use the EXACT template name on the first line\n"438            "- Use the SAME DSL field names (e.g. 'lists', 'sequences', 'compares', 'root', 'nodes', 'values')\n"439            "- Adapt the number of items, nesting depth, and content to fit the user's data and instructions\n"440            "- The user's additional instructions take priority when they conflict with the example's structure"441        )442443    # Final fallback - no example available444    return "\n\nYou MUST use the template '" + template_name + "'."445446447async def call_llm(messages, model, temperature=0.3):448    """Call LiteLLM proxy using OpenAI-compatible API."""449    if not LITELLM_BASE_URL:450        raise ValueError("OPENAI_BASE_URL environment variable not set")451    if not LITELLM_API_KEY:452        raise ValueError("LITELLM_API_KEY environment variable not set")453454    headers = {455        "Authorization": f"Bearer {LITELLM_API_KEY}",456        "Content-Type": "application/json",457    }458459    payload = {460        "model": model,461        "messages": messages,462        "temperature": temperature,463    }464465    url = f"{LITELLM_BASE_URL}/v1/chat/completions"466    print(f"Calling LLM: model={model}, url={url}", file=sys.stderr)467468    async with httpx.AsyncClient(timeout=120) as client:469        response = await client.post(url, headers=headers, json=payload)470        response.raise_for_status()471        result = response.json()472        return result["choices"][0]["message"]["content"].strip()473474475def clean_dsl_output(raw_output):476    """Clean LLM output to extract pure DSL syntax."""477    text = raw_output.strip()478479    # Remove markdown code fences if present480    if text.startswith("```"):481        lines = text.split("\n")482        # Remove first line (```yaml or ```text or ```)483        lines = lines[1:]484        # Remove last line if it's closing ```485        if lines and lines[-1].strip() == "```":486            lines = lines[:-1]487        text = "\n".join(lines).strip()488489    # Ensure it starts with 'infographic '490    lines = text.split("\n")491    start_idx = 0492    for i, line in enumerate(lines):493        if line.strip().startswith("infographic "):494            start_idx = i495            break496497    text = "\n".join(lines[start_idx:]).strip()498499    if not text.startswith("infographic "):500        raise ValueError(501            "LLM output does not contain valid infographic DSL. "502            f"Output starts with: {text[:100]}"503        )504505    return text506507508# ---------------------------------------------------------------------------509# DSL Validator — enforces capacity limits, text lengths, and canvas sizing510# Runs AFTER LLM generation, BEFORE rendering.511# ---------------------------------------------------------------------------512513import re as _re514515# (max_items, swap_target_template)516_TEMPLATE_CAPACITY = {517    'list-grid-': (6, 'list-waterfall-badge-card'),518    'list-row-': (5, 'list-column-vertical-icon-arrow'),519    'list-sector-': (6, 'list-waterfall-compact-card'),520    'list-pyramid-': (5, 'list-waterfall-badge-card'),521    'sequence-steps-': (6, 'sequence-snake-steps-compact-card'),522    'sequence-stairs-': (5, 'sequence-snake-steps-compact-card'),523    'chart-pie-': (6, None),524    'chart-bar-': (8, None),525    'chart-column-': (8, None),526}527528_MAX_LABEL = 25529_MAX_ITEM_DESC = 50530_MAX_TITLE = 40531_MAX_GLOBAL_DESC = 70532533534def _count_top_items(lines):535    """Count top-level array entries in the DSL (lines starting with '- ' at the shallowest array indent)."""536    first_indent = None537    count = 0538    for line in lines:539        stripped = line.lstrip()540        if stripped.startswith('- '):541            indent = len(line) - len(stripped)542            if first_indent is None:543                first_indent = indent544            if indent == first_indent:545                count += 1546    return count547548549def _clean_value_text(text):550    """Strip verbose prefixes from value fields, keeping just the number."""551    text = text.strip()552    # Already short and numeric-looking — keep as-is553    if len(text) <= 8:554        return text555    # N/A variants556    if 'N/A' in text or 'n/a' in text:557        return 'N/A'558    # Extract the first number (with optional decimal and %)559    m = _re.search(r'(\d+\.?\d*%?)', text)560    if m:561        return m.group(1)562    # Fallback: truncate563    return text[:8]564565566def _recommend_canvas(template, item_count):567    """Suggest minimum canvas size based on template type and data volume."""568    W, H = 1920, 1080  # Base canvas (Full HD)569570    if template.startswith(('chart-column-', 'chart-bar-')):571        return (max(W, item_count * 250), H)572573    if template.startswith('chart-'):574        return (W, H)575576    if template.startswith('list-grid-'):577        cols = 3578        rows = max(2, (item_count + cols - 1) // cols)579        return (W, max(H, rows * 300))580581    if template.startswith(('list-column-', 'list-waterfall-', 'list-zigzag-')):582        return (W, max(H, item_count * 180))583584    if template.startswith('list-row-'):585        return (max(W, item_count * 280), H)586587    if template.startswith(('sequence-roadmap-', 'sequence-snake-', 'sequence-timeline-')):588        return (W, max(H, item_count * 160))589590    if template.startswith('sequence-'):591        return (max(W, item_count * 220), H)592593    if template.startswith('hierarchy-'):594        return (W, max(H, 1200))595596    return (W, H)597598599def validate_and_fix_dsl(dsl):600    """601    Post-LLM safety net: enforce template capacity, text limits, and canvas sizing.602    Returns (fixed_dsl, recommended_width, recommended_height).603    """604    lines = dsl.split('\n')605    if not lines or not lines[0].startswith('infographic '):606        return dsl, 1920, 1080607608    template = lines[0][len('infographic '):].strip()609    item_count = _count_top_items(lines)610    print(f"DSL validator: template={template}, items={item_count}", file=sys.stderr)611612    # --- 1. Template capacity enforcement ---613    for prefix, (limit, swap) in _TEMPLATE_CAPACITY.items():614        if template.startswith(prefix) and item_count > limit:615            if swap:616                print(617                    f"DSL validator: {template} has {item_count} items "618                    f"(max {limit}), swapping to {swap}",619                    file=sys.stderr,620                )621                lines[0] = 'infographic ' + swap622                template = swap623            else:624                print(625                    f"DSL validator: {template} has {item_count} items "626                    f"(max {limit}), no swap available — keeping as-is",627                    file=sys.stderr,628                )629            break630631    is_chart = template.startswith('chart-')632633    # --- 2. Chart-specific: strip per-item desc (bars need only label+value) ---634    if is_chart:635        to_remove = []636        for i, line in enumerate(lines):637            stripped = line.lstrip()638            depth = len(line) - len(stripped)639            if stripped.startswith('desc ') and depth > 2:640                to_remove.append(i)641        if to_remove:642            print(f"DSL validator: removing {len(to_remove)} item-level desc lines from chart", file=sys.stderr)643            for idx in reversed(to_remove):644                lines.pop(idx)645646    # --- 3. Chart-specific: enforce title (28) and desc (52) limits ---647    _TITLE_SUFFIX_RE = _re.compile(r'\s*(?:[-–—:]\s+.+|\([^)]+\))\s*$')648    _CHART_TITLE_MAX = 28649    _CHART_DESC_MAX = 52650    if is_chart:651        for i, line in enumerate(lines):652            stripped = line.lstrip()653            depth = len(line) - len(stripped)654            if stripped.startswith('title ') and depth <= 2:655                title_text = stripped[6:]656                indent_str = line[:depth]657                # Strip trailing suffix patterns if title is too long658                suffix_match = _TITLE_SUFFIX_RE.search(title_text)659                if suffix_match and len(title_text) > _CHART_TITLE_MAX:660                    title_text = title_text[:suffix_match.start()].rstrip()661                    print(f"DSL validator: stripped title suffix -> '{title_text}'", file=sys.stderr)662                # Hard limit — truncate at word boundary to avoid mid-word cuts663                if len(title_text) > _CHART_TITLE_MAX:664                    cut = title_text[:_CHART_TITLE_MAX].rfind(' ')665                    if cut > 5:666                        title_text = title_text[:cut]667                    else:668                        title_text = title_text[:_CHART_TITLE_MAX]669                    print(f"DSL validator: truncated chart title -> '{title_text}'", file=sys.stderr)670                lines[i] = indent_str + 'title ' + title_text671                break672        # Enforce chart desc limit (global desc only)673        for i, line in enumerate(lines):674            stripped = line.lstrip()675            depth = len(line) - len(stripped)676            if stripped.startswith('desc ') and depth <= 2:677                desc_text = stripped[5:]678                if len(desc_text) > _CHART_DESC_MAX:679                    indent_str = line[:depth]680                    cut = desc_text[:_CHART_DESC_MAX].rfind(' ')681                    if cut > 10:682                        desc_text = desc_text[:cut]683                    else:684                        desc_text = desc_text[:_CHART_DESC_MAX]685                    lines[i] = indent_str + 'desc ' + desc_text686                    print(f"DSL validator: truncated chart desc -> '{desc_text}'", file=sys.stderr)687                break688689    # --- 4. Text length enforcement ---690    # Track indent depth to distinguish global vs item fields.691    # Global title/desc are at indent 2 (directly under `data`).692    # Item fields (label, desc, value) are at indent 4+.693    for i, line in enumerate(lines):694        stripped = line.lstrip()695        indent_str = line[:len(line) - len(stripped)]696        depth = len(indent_str)697698        if stripped.startswith('title ') and depth <= 2:699            text = stripped[6:]700            if len(text) > _MAX_TITLE:701                lines[i] = indent_str + 'title ' + text[:_MAX_TITLE]702703        elif stripped.startswith('desc ') and depth <= 2:704            text = stripped[5:]705            if len(text) > _MAX_GLOBAL_DESC:706                lines[i] = indent_str + 'desc ' + text[:_MAX_GLOBAL_DESC]707708        elif stripped.startswith('label '):709            text = stripped[6:]710            if len(text) > _MAX_LABEL:711                lines[i] = indent_str + 'label ' + text[:_MAX_LABEL]712713        elif stripped.startswith('desc ') and depth > 2:714            text = stripped[5:]715            if len(text) > _MAX_ITEM_DESC:716                lines[i] = indent_str + 'desc ' + text[:_MAX_ITEM_DESC]717718        elif stripped.startswith('value '):719            text = stripped[6:]720            cleaned = _clean_value_text(text)721            if cleaned != text:722                print(f"DSL validator: cleaned value '{text}' -> '{cleaned}'", file=sys.stderr)723                lines[i] = indent_str + 'value ' + cleaned724725    # --- 5. Canvas sizing ---726    rec_w, rec_h = _recommend_canvas(template, item_count)727728    fixed = '\n'.join(lines)729    print(730        f"DSL validator: recommended canvas {rec_w}x{rec_h}",731        file=sys.stderr,732    )733    return fixed, rec_w, rec_h734735736def render_infographic(syntax, fmt="png", width=800, height=600, scale=2):737    """Call the Emblema infographic rendering API."""738    url = f"{EMBLEMA_API_BASE_URL}/api/v2/helpers/infographic/render"739740    headers = {"Content-Type": "application/json"}741    if USER_TOKEN:742        headers["Authorization"] = f"Bearer {USER_TOKEN}"743744    payload = {745        "syntax": syntax,746        "format": fmt,747        "width": width,748        "height": height,749        "scale": scale,750    }751752    print(f"Calling render API: {url}", file=sys.stderr)753    response = requests.post(url, headers=headers, json=payload, timeout=60)754755    if response.status_code != 200:756        try:757            error_detail = response.json()758            error_msg = error_detail.get("message", response.text)759        except Exception:760            error_msg = response.text761        raise RuntimeError(f"Render API returned {response.status_code}: {error_msg}")762763    return response.content764765766async def generate_infographic(content, template_name, model, instruction="", temperature=0.3):767    """Generate infographic DSL using LLM."""768    system = SYSTEM_PROMPT769    system += build_template_instruction(template_name)770771    user_message = content772    if instruction and instruction.strip():773        user_message += f"\n\n## Additional Instructions\n{instruction.strip()}"774775    messages = [776        {"role": "system", "content": system},777        {"role": "user", "content": user_message},778    ]779780    raw_output = await call_llm(messages, model, temperature=temperature)781    print(f"LLM output length: {len(raw_output)} chars", file=sys.stderr)782783    dsl_syntax = clean_dsl_output(raw_output)784    print(f"Cleaned DSL length: {len(dsl_syntax)} chars", file=sys.stderr)785786    return dsl_syntax787788789def main():790    try:791        input_json = sys.stdin.read()792        execution_input = json.loads(input_json)793        inputs = execution_input.get("inputs", {})794795        content = inputs.get("content", "")796        instruction = inputs.get("instruction", "")797        template_name = inputs.get("template_name", "auto")798        model = inputs.get("llmModel", "gemini-2.5-flash")799        temperature = float(inputs.get("temperature", 0.3))800        width = int(inputs.get("width", 800))801        height = int(inputs.get("height", 600))802803        if not content or not content.strip():804            raise ValueError("Content is required")805806        if len(content) > MAX_CONTENT_LENGTH:807            raise ValueError(f"Content too long ({len(content)} chars). Maximum is {MAX_CONTENT_LENGTH}.")808809        if model not in ALLOWED_MODELS:810            raise ValueError(f"Invalid model '{model}'. Allowed: {', '.join(sorted(ALLOWED_MODELS))}")811812        os.makedirs(OUTPUT_DIR, exist_ok=True)813814        # Step 1: Generate DSL via LLM815        print("Step 1: Generating infographic DSL via LLM...", file=sys.stderr)816        dsl_syntax = asyncio.run(generate_infographic(817            content, template_name, model, instruction, temperature818        ))819        print(f"Generated DSL:\n{dsl_syntax[:200]}...", file=sys.stderr)820821        # Step 2: Validate and fix DSL (enforce capacity, text limits, canvas sizing)822        print("Step 2: Validating DSL...", file=sys.stderr)823        dsl_syntax, rec_w, rec_h = validate_and_fix_dsl(dsl_syntax)824        width = max(width, rec_w)825        height = max(height, rec_h)826        print(f"Final canvas: {width}x{height}", file=sys.stderr)827828        # Step 3: Render DSL to image829        print("Step 3: Rendering infographic image...", file=sys.stderr)830        image_data = render_infographic(dsl_syntax, "png", width, height, scale=2)831832        # Save image833        out_filename = "infographic.png"834        out_path = os.path.join(OUTPUT_DIR, out_filename)835        with open(out_path, "wb") as f:836            f.write(image_data)837838        print(f"Saved: {out_filename} ({len(image_data)} bytes)", file=sys.stderr)839840        # Output both the image and the DSL syntax841        output = {842            "image": out_filename,843            "syntax": dsl_syntax,844        }845        print(json.dumps(output, indent=2))846847    except Exception as e:848        error_output = {849            "error": str(e),850            "errorType": type(e).__name__,851            "traceback": traceback.format_exc(),852        }853        print(json.dumps(error_output), file=sys.stderr)854        sys.exit(1)855856857if __name__ == "__main__":858    main()