$ cat node-template.py

I

Infographic Generator

// Generates a professional infographic from text content. Uses an LLM to analyze the content and select the best visualization layout. Outputs a PNG or SVG image. Supports 237 templates across charts, lists, comparisons, hierarchies, sequences, and relations.

Process
Document
template.py
1import os2import sys3import json4import traceback5import requests6import asyncio7from gais import Gais89# Environment10EMBLEMA_API_BASE_URL = os.getenv("EMBLEMA_API_BASE_URL", "http://localhost:3000")11USER_TOKEN = os.getenv("USER_TOKEN")12OUTPUT_DIR = "/data/output"13MAX_CONTENT_LENGTH = 100_0001415# ---------------------------------------------------------------------------16# System prompt — based on official AntV Infographic skill files with17# data-density, capacity, and layout-quality constraints.18# ---------------------------------------------------------------------------19SYSTEM_PROMPT = """You are an expert infographic designer using the AntV Infographic DSL.2021Given the user's content, produce ONE block of AntV Infographic DSL syntax that best visualizes the information.2223## STEP 0 — THINK BEFORE YOU GENERATE2425Before writing any DSL, silently analyse the input:261. Count distinct data items. How many items/nodes/points exist?272. Measure text: are labels short (1-3 words) or long (4+ words)?283. Decide structure type: list, sequence, compare, hierarchy, relation, chart.294. Pick a template whose capacity fits the item count (see TEMPLATE CAPACITY below).305. Plan concise labels — shorten aggressively if the source text is verbose.31Then output ONLY the DSL (no analysis, no commentary).3233## DSL FORMAT3435- First line: `infographic <template-name>`36- Blocks: `data` / `theme`, indented with two spaces.37- Key-value: `key value` (space-separated).38- Arrays: dash prefix `- ` for each entry.39- No JSON, no Markdown, no code fences, no explanation.4041Example:4243infographic list-row-horizontal-icon-arrow44data45  title Internet Technology Evolution46  desc Key milestones from Web 1.0 to AI47  lists48    - time 199149      label Web 1.050      desc First website by Tim Berners-Lee51      icon web52    - time 200453      label Web 2.054      desc Social media goes mainstream55      icon account multiple56    - time 202357      label AI Large Model58      desc ChatGPT sparks the AI revolution59      icon brain60theme61  palette #3b82f6 #8b5cf6 #f973166263## DATA FIELDS BY TEMPLATE TYPE (use ONLY the correct field)6465| Template prefix | Data field | Notes |66|---|---|---|67| `list-*` | `lists` | Flat array of items |68| `sequence-*` | `sequences` | Optional `order asc/desc` |69| `compare-binary-*` / `compare-hierarchy-left-right-*` | `compares` | MUST have exactly 2 root items, sub-items in `children` |70| `compare-swot` | `compares` | Exactly 4 root items (S/W/O/T) with `children` |71| `compare-quadrant-*` | `compares` | Exactly 4 items |72| `hierarchy-structure` / `hierarchy-structure-mirror` | `items` | Up to 3 nesting levels via `children` |73| Other `hierarchy-*` | `root` | Single root, tree via `children` nesting. Do NOT repeat `root`. |74| `relation-*` | `nodes` + `relations` | Edges: `A - label -> B` or `A -->|label| B` |75| `chart-*` | `values` | Optional `category` |76| Fallback | `items` | Only when none of the above match |7778NEVER mix data fields. Use exactly one per template.7980## TEXT DENSITY RULES (CRITICAL for visual quality)8182The rendering engine allocates fixed space per card/node. Overflow causes overlapping text. Obey these limits:8384| Field | Max chars | Guidance |85|---|---|---|86| `title` (chart-*) | 25 | Chart title. Keep concise — 3-4 words max. |87| `desc` (chart-*) | 50 | Chart subtitle. One short phrase. |88| `title` (other) | 35 | Main title. Concise headline. |89| `desc` (global) | 60 | Subtitle under the title. One line. |90| `label` | 25 | Item label. Shorten aggressively: abbreviate, drop articles/prepositions. |91| `desc` (per item) | 50 | Keep to one sentence. Omit if the label is self-explanatory. |92| `value` | 8 | Numeric only. E.g. `82%`, `3.94`, `1560` |9394Chart title + desc examples (chart-* templates):95  BAD:  title "Docenti con le valutazioni più basse nel primo modulo"  (52 chars — too long!)96  GOOD: title "Docenti Peggiori"  desc "Valutazioni Modulo I"  (16 + 20 chars)97  BAD:  title "Distribuzione delle risposte al sondaggio"      (40 chars — too long!)98  GOOD: title "Risposte Sondaggio"  desc "Distribuzione per modulo"  (18 + 25 chars)99100General shortening examples:101  BAD:  "DINAMICA DELLE STRUTTURE ORGANIZZATIVE"   (40 chars)102  GOOD: "Strutture Organizzative"                    (23 chars)103  BAD:  "La lezione e stata interessante?"           (31 chars)104  GOOD: "Interesse Lezione"                          (17 chars)105106When the source language is verbose (Italian, German, French), shorten to core nouns only. Drop verbs, articles, prepositions.107108## TEMPLATE CAPACITY LIMITS109110Exceeding these limits causes cramped, overlapping cards. If data exceeds a limit, either CHOOSE A DIFFERENT TEMPLATE or SUMMARIZE/GROUP items.111112| Template pattern | Max items | When exceeded, prefer |113|---|---|---|114| `list-grid-*` | 6 | list-column-*, list-waterfall-*, list-zigzag-* |115| `list-row-*` | 5 | list-column-*, sequence-snake-* |116| `list-column-*` | 8 | hierarchy-mindmap-*, sequence-roadmap-* |117| `list-sector-*` | 6 | list-column-*, list-waterfall-* |118| `list-pyramid-*` | 5 | sequence-pyramid-*, sequence-funnel-* |119| `sequence-steps-*` | 6 | sequence-snake-*, sequence-roadmap-* |120| `sequence-timeline-*` | 8 | sequence-roadmap-*, list-column-* |121| `sequence-roadmap-*` | 10 | Split into two infographics |122| `sequence-snake-*` | 10 | Split or use hierarchy-mindmap-* |123| `compare-binary-*` | 2 root + 5 children each | Reduce children |124| `compare-swot` | 4 root + 4 children each | Summarise children |125| `hierarchy-tree-*` | 3 levels, 4-5 nodes/level | Prune or use mindmap |126| `hierarchy-mindmap-*` | 3 levels, 6 nodes/level | OK for large data |127| `chart-pie-*` | 6 slices | Group small slices into "Other" |128| `chart-bar-*` / `chart-column-*` | 8 bars | Group or top-N |129| `relation-dagre-flow-*` | 10 nodes | Simplify graph |130131## TEMPLATE SELECTION GUIDE132133Match the content structure to a template family:134135- Strict order (process/steps/timeline/trend) -> `sequence-*`136  - Dates -> `sequence-timeline-*`137  - Step progression -> `sequence-stairs-*`, `sequence-ascending-*`, `sequence-steps-*`138  - Roadmap -> `sequence-roadmap-vertical-*`139  - Circular cycle -> `sequence-circular-simple`140  - Narrowing/filtering -> `sequence-funnel-simple`, `sequence-pyramid-simple`141  - Snake/zigzag path -> `sequence-snake-steps-*`, `sequence-zigzag-*`142- Feature listing / bullet points -> `list-row-*` (horizontal) or `list-column-*` (vertical)143- Grid of cards (FEW items) -> `list-grid-*` (max 6 items!)144- Waterfall / cascading -> `list-waterfall-*`145- Binary A-vs-B -> `compare-binary-*`146- SWOT -> `compare-swot`147- 2x2 matrix -> `compare-quadrant-*`148- Tree / categorization -> `hierarchy-tree-*`149- Mind map / brainstorm -> `hierarchy-mindmap-*`150- Org chart -> `hierarchy-structure`, `hierarchy-structure-mirror`151- Numeric data -> `chart-*` (pie=proportions, bar/column=comparison, line=trend)152- Word cloud -> `chart-wordcloud`153- Network/dependencies -> `relation-dagre-flow-*`, `relation-network-*`, `relation-circle-*`154155## HIERARCHY TREE VARIANTS156157Tree templates follow the pattern: `hierarchy-tree-{direction}-{edge}-{node}`158- Direction: (default=top-down), `bt` (bottom-up), `lr` (left-right), `rl` (right-left)159- Edge style: `curved-line`, `dashed-line`, `dashed-arrow`, `distributed-origin`, `tech-style`160- Node style: `badge-card`, `capsule-item`, `compact-card`, `ribbon-card`, `rounded-rect-node`161162## AVAILABLE TEMPLATES (237 total)163164chart (11): chart-bar-plain-text, chart-column-simple, chart-line-plain-text,165chart-pie-compact-card, chart-pie-donut-compact-card, chart-pie-donut-pill-badge,166chart-pie-donut-plain-text, chart-pie-pill-badge, chart-pie-plain-text,167chart-wordcloud, chart-wordcloud-rotate168169list (29): list-column-done-list, list-column-simple-vertical-arrow, list-column-vertical-icon-arrow,170list-grid-badge-card, list-grid-candy-card-lite, list-grid-circular-progress,171list-grid-compact-card, list-grid-done-list, list-grid-horizontal-icon-arrow,172list-grid-progress-card, list-grid-ribbon-card, list-grid-simple,173list-pyramid-badge-card, list-pyramid-compact-card, list-pyramid-rounded-rect-node,174list-row-circular-progress, list-row-horizontal-icon-arrow, list-row-horizontal-icon-line,175list-row-simple-horizontal-arrow, list-row-simple-illus,176list-sector-half-plain-text, list-sector-plain-text, list-sector-simple,177list-waterfall-badge-card, list-waterfall-compact-card,178list-zigzag-down-compact-card, list-zigzag-down-simple,179list-zigzag-up-compact-card, list-zigzag-up-simple180181sequence (47): sequence-ascending-stairs-3d-simple, sequence-ascending-stairs-3d-underline-text,182sequence-ascending-steps, sequence-circle-arrows-indexed-card,183sequence-circular-simple, sequence-circular-underline-text,184sequence-color-snake-steps-horizontal-icon-line, sequence-color-snake-steps-simple-illus,185sequence-cylinders-3d-simple, sequence-filter-mesh-simple, sequence-filter-mesh-underline-text,186sequence-funnel-simple, sequence-horizontal-zigzag-horizontal-icon-line,187sequence-horizontal-zigzag-plain-text, sequence-horizontal-zigzag-simple,188sequence-horizontal-zigzag-simple-horizontal-arrow, sequence-horizontal-zigzag-simple-illus,189sequence-horizontal-zigzag-underline-text, sequence-mountain-underline-text,190sequence-pyramid-simple, sequence-roadmap-vertical-badge-card,191sequence-roadmap-vertical-pill-badge, sequence-roadmap-vertical-plain-text,192sequence-roadmap-vertical-quarter-circular, sequence-roadmap-vertical-quarter-simple-card,193sequence-roadmap-vertical-simple, sequence-roadmap-vertical-underline-text,194sequence-snake-steps-compact-card, sequence-snake-steps-pill-badge,195sequence-snake-steps-simple, sequence-snake-steps-simple-illus,196sequence-snake-steps-underline-text, sequence-stairs-front-compact-card,197sequence-stairs-front-pill-badge, sequence-stairs-front-simple,198sequence-steps-badge-card, sequence-steps-simple, sequence-steps-simple-illus,199sequence-timeline-done-list, sequence-timeline-plain-text,200sequence-timeline-rounded-rect-node, sequence-timeline-simple,201sequence-timeline-simple-illus, sequence-zigzag-pucks-3d-indexed-card,202sequence-zigzag-pucks-3d-simple, sequence-zigzag-pucks-3d-underline-text,203sequence-zigzag-steps-underline-text204205compare (20): compare-binary-horizontal-badge-card-arrow, compare-binary-horizontal-badge-card-fold,206compare-binary-horizontal-badge-card-vs, compare-binary-horizontal-compact-card-arrow,207compare-binary-horizontal-compact-card-fold, compare-binary-horizontal-compact-card-vs,208compare-binary-horizontal-simple-arrow, compare-binary-horizontal-simple-fold,209compare-binary-horizontal-simple-vs, compare-binary-horizontal-underline-text-arrow,210compare-binary-horizontal-underline-text-fold, compare-binary-horizontal-underline-text-vs,211compare-hierarchy-left-right-circle-node-pill-badge,212compare-hierarchy-left-right-circle-node-plain-text,213compare-hierarchy-row-letter-card-compact-card,214compare-hierarchy-row-letter-card-rounded-rect-node,215compare-quadrant-quarter-circular, compare-quadrant-quarter-simple-card,216compare-quadrant-simple-illus, compare-swot217218hierarchy (112): hierarchy-mindmap-branch-gradient-capsule-item, hierarchy-mindmap-branch-gradient-circle-progress,219hierarchy-mindmap-branch-gradient-compact-card, hierarchy-mindmap-branch-gradient-lined-palette,220hierarchy-mindmap-branch-gradient-rounded-rect, hierarchy-mindmap-level-gradient-capsule-item,221hierarchy-mindmap-level-gradient-circle-progress, hierarchy-mindmap-level-gradient-compact-card,222hierarchy-mindmap-level-gradient-lined-palette, hierarchy-mindmap-level-gradient-rounded-rect,223hierarchy-structure, hierarchy-structure-mirror,224hierarchy-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)225226relation (18): relation-circle-circular-progress, relation-circle-icon-badge,227relation-dagre-flow-lr-animated-badge-card, relation-dagre-flow-lr-animated-capsule,228relation-dagre-flow-lr-animated-compact-card, relation-dagre-flow-lr-animated-simple-circle-node,229relation-dagre-flow-lr-badge-card, relation-dagre-flow-lr-compact-card,230relation-dagre-flow-lr-simple-circle-node, relation-dagre-flow-tb-animated-badge-card,231relation-dagre-flow-tb-animated-capsule, relation-dagre-flow-tb-animated-compact-card,232relation-dagre-flow-tb-animated-simple-circle-node, relation-dagre-flow-tb-badge-card,233relation-dagre-flow-tb-compact-card, relation-dagre-flow-tb-simple-circle-node,234relation-network-icon-badge, relation-network-simple-circle-node235236## ICON KEYWORDS237238Use icon keywords (matched by the renderer). Examples:239star fill, check circle, trending up, chart bar, users, lightbulb, rocket,240shield, target, globe, code, database, search, alert triangle, clock, calendar,241brain, heart, zap, trophy, sprout, document text, web, cellphone, cloud,242application brackets, sun, moon, flash fast, secure shield check, account multiple243244## THEME OPTIONS245246Optional `theme` block for customisation:247248Dark theme + custom palette:249  theme dark250    palette251      - #61DDAA252      - #F6BD16253      - #F08BB4254255Hand-drawn style:256  theme257    stylize rough258    base259      text260        font-family 851tegakizatsu261262Available stylize types: rough, pattern, linear-gradient, radial-gradient263264## INPUT DATA FORMAT265266The input may include tool call results. Pay attention to:267- `intermediate: true` = research/helper steps. Useful context but SECONDARY.268- `intermediate: false` (or absent) = PRIMARY/FINAL data. Focus on these.269Prioritise non-intermediate content for the main data points.270271## COMPLETE DSL EXAMPLES272273List (horizontal arrow):274infographic list-row-horizontal-icon-arrow275data276  title Feature List277  lists278    - label Fast279      icon flash fast280    - label Secure281      icon secure shield check282    - label Scalable283      icon cloud284285Sequence (steps):286infographic sequence-steps-simple287data288  title Build Process289  sequences290    - label Design291      desc Create wireframes292    - label Develop293      desc Build the MVP294    - label Launch295      desc Release to users296  order asc297298Hierarchy (tree):299infographic hierarchy-tree-curved-line-rounded-rect-node300data301  root302    label Company303    children304      - label Engineering305        children306          - label Frontend307          - label Backend308      - label Marketing309310Compare (SWOT):311infographic compare-swot312data313  compares314    - label Strengths315      children316        - label Strong brand317        - label Loyal users318    - label Weaknesses319      children320        - label High cost321    - label Opportunities322      children323        - label Emerging markets324    - label Threats325      children326        - label Competitors327328Chart (column):329infographic chart-column-simple330data331  title Monthly Revenue332  values333    - label Jan334      value 1280335    - label Feb336      value 1560337    - label Mar338      value 1890339340Relation (flow):341infographic relation-dagre-flow-tb-simple-circle-node342data343  nodes344    - id A345      label Input346    - id B347      label Process348    - id C349      label Output350  relations351    A - feeds -> B352    B - produces -> C353354## CRITICAL RULES3553561. Output ONLY DSL — no markdown, no code fences, no commentary, no explanation.3572. First line MUST be `infographic <template-name>`.3583. Two-space indentation throughout.3594. Use the CORRECT data field for the chosen template (see DATA FIELDS table).3605. Respect TEXT DENSITY LIMITS: label <= 25 chars, desc <= 50 chars, title <= 35 chars.3616. For chart-* templates: title MUST be <= 25 chars (3-4 words), desc <= 50 chars. Keep chart titles concise.3627. Respect TEMPLATE CAPACITY LIMITS. If data exceeds capacity, choose a larger template or summarise.3638. When source text is verbose, SHORTEN to core nouns. Drop articles, verbs, prepositions.3649. Do NOT fabricate data unrelated to the user's content.36510. Binary compare templates MUST have exactly 2 root items.36611. Hierarchy templates use single `root` (do not repeat `root`).36712. Preserve the user's language for labels — but shorten within that language.36813. When a specific template is requested, use it even if another might fit better.369"""370371# ---------------------------------------------------------------------------372# Template DSL examples — fetched at runtime from the static manifest.373# The manifest lives at /templates/infographic/manifest.json (served by Next.js)374# and contains { id, category, syntax } for all 237 templates.375# ---------------------------------------------------------------------------376377_MANIFEST_CACHE = None378379def _fetch_manifest():380    """Fetch the template manifest once and cache it."""381    global _MANIFEST_CACHE382    if _MANIFEST_CACHE is not None:383        return _MANIFEST_CACHE384385    url = f"{EMBLEMA_API_BASE_URL}/templates/infographic/manifest.json"386    try:387        print(f"Fetching template manifest: {url}", file=sys.stderr)388        resp = requests.get(url, timeout=15)389        resp.raise_for_status()390        manifest = resp.json()391        # Build lookup dict: id -> syntax392        _MANIFEST_CACHE = {entry["id"]: entry.get("syntax", "") for entry in manifest}393        print(f"Loaded {len(_MANIFEST_CACHE)} template examples from manifest", file=sys.stderr)394        return _MANIFEST_CACHE395    except Exception as e:396        print(f"Warning: Could not fetch template manifest: {e}", file=sys.stderr)397        _MANIFEST_CACHE = {}398        return _MANIFEST_CACHE399400401def build_template_instruction(template_name):402    """When user picks a specific template, inject its DSL example into prompt."""403    if not template_name or template_name == "auto":404        return ""405406    manifest = _fetch_manifest()407    example = manifest.get(template_name, "")408409    if example:410        return (411            "\n\nYou MUST use the template '" + template_name + "'.\n"412            "Here is the reference syntax showing the correct DSL structure:\n\n" + example + "\n\n"413            "Rules:\n"414            "- Use the EXACT template name on the first line\n"415            "- Use the SAME DSL field names (e.g. 'lists', 'sequences', 'compares', 'root', 'nodes', 'values')\n"416            "- Adapt the number of items, nesting depth, and content to fit the user's data and instructions\n"417            "- The user's additional instructions take priority when they conflict with the example's structure"418        )419420    # Final fallback - no example available421    return "\n\nYou MUST use the template '" + template_name + "'."422423424def clean_dsl_output(raw_output):425    """Clean LLM output to extract pure DSL syntax."""426    text = raw_output.strip()427428    # Remove markdown code fences if present429    if text.startswith("```"):430        lines = text.split("\n")431        # Remove first line (```yaml or ```text or ```)432        lines = lines[1:]433        # Remove last line if it's closing ```434        if lines and lines[-1].strip() == "```":435            lines = lines[:-1]436        text = "\n".join(lines).strip()437438    # Ensure it starts with 'infographic '439    lines = text.split("\n")440    start_idx = 0441    for i, line in enumerate(lines):442        if line.strip().startswith("infographic "):443            start_idx = i444            break445446    text = "\n".join(lines[start_idx:]).strip()447448    if not text.startswith("infographic "):449        raise ValueError(450            "LLM output does not contain valid infographic DSL. "451            f"Output starts with: {text[:100]}"452        )453454    return text455456457# ---------------------------------------------------------------------------458# DSL Validator — enforces capacity limits, text lengths, and canvas sizing459# Runs AFTER LLM generation, BEFORE rendering.460# ---------------------------------------------------------------------------461462import re as _re463464# (max_items, swap_target_template)465_TEMPLATE_CAPACITY = {466    'list-grid-': (6, 'list-waterfall-badge-card'),467    'list-row-': (5, 'list-column-vertical-icon-arrow'),468    'list-sector-': (6, 'list-waterfall-compact-card'),469    'list-pyramid-': (5, 'list-waterfall-badge-card'),470    'sequence-steps-': (6, 'sequence-snake-steps-compact-card'),471    'sequence-stairs-': (5, 'sequence-snake-steps-compact-card'),472    'chart-pie-': (6, None),473    'chart-bar-': (8, None),474    'chart-column-': (8, None),475}476477_MAX_LABEL = 25478_MAX_ITEM_DESC = 50479_MAX_TITLE = 40480_MAX_GLOBAL_DESC = 70481482483def _count_top_items(lines):484    """Count top-level array entries in the DSL (lines starting with '- ' at the shallowest array indent)."""485    first_indent = None486    count = 0487    for line in lines:488        stripped = line.lstrip()489        if stripped.startswith('- '):490            indent = len(line) - len(stripped)491            if first_indent is None:492                first_indent = indent493            if indent == first_indent:494                count += 1495    return count496497498def _clean_value_text(text):499    """Strip verbose prefixes from value fields, keeping just the number."""500    text = text.strip()501    # Already short and numeric-looking — keep as-is502    if len(text) <= 8:503        return text504    # N/A variants505    if 'N/A' in text or 'n/a' in text:506        return 'N/A'507    # Extract the first number (with optional decimal and %)508    m = _re.search(r'(\d+\.?\d*%?)', text)509    if m:510        return m.group(1)511    # Fallback: truncate512    return text[:8]513514515def _recommend_canvas(template, item_count):516    """Suggest minimum canvas size based on template type and data volume."""517    W, H = 1920, 1080  # Base canvas (Full HD)518519    if template.startswith(('chart-column-', 'chart-bar-')):520        return (max(W, item_count * 250), H)521522    if template.startswith('chart-'):523        return (W, H)524525    if template.startswith('list-grid-'):526        cols = 3527        rows = max(2, (item_count + cols - 1) // cols)528        return (W, max(H, rows * 300))529530    if template.startswith(('list-column-', 'list-waterfall-', 'list-zigzag-')):531        return (W, max(H, item_count * 180))532533    if template.startswith('list-row-'):534        return (max(W, item_count * 280), H)535536    if template.startswith(('sequence-roadmap-', 'sequence-snake-', 'sequence-timeline-')):537        return (W, max(H, item_count * 160))538539    if template.startswith('sequence-'):540        return (max(W, item_count * 220), H)541542    if template.startswith('hierarchy-'):543        return (W, max(H, 1200))544545    return (W, H)546547548def validate_and_fix_dsl(dsl):549    """550    Post-LLM safety net: enforce template capacity, text limits, and canvas sizing.551    Returns (fixed_dsl, recommended_width, recommended_height).552    """553    lines = dsl.split('\n')554    if not lines or not lines[0].startswith('infographic '):555        return dsl, 1920, 1080556557    template = lines[0][len('infographic '):].strip()558    item_count = _count_top_items(lines)559    print(f"DSL validator: template={template}, items={item_count}", file=sys.stderr)560561    # --- 1. Template capacity enforcement ---562    for prefix, (limit, swap) in _TEMPLATE_CAPACITY.items():563        if template.startswith(prefix) and item_count > limit:564            if swap:565                print(566                    f"DSL validator: {template} has {item_count} items "567                    f"(max {limit}), swapping to {swap}",568                    file=sys.stderr,569                )570                lines[0] = 'infographic ' + swap571                template = swap572            else:573                print(574                    f"DSL validator: {template} has {item_count} items "575                    f"(max {limit}), no swap available — keeping as-is",576                    file=sys.stderr,577                )578            break579580    is_chart = template.startswith('chart-')581582    # --- 2. Chart-specific: strip per-item desc (bars need only label+value) ---583    if is_chart:584        to_remove = []585        for i, line in enumerate(lines):586            stripped = line.lstrip()587            depth = len(line) - len(stripped)588            if stripped.startswith('desc ') and depth > 2:589                to_remove.append(i)590        if to_remove:591            print(f"DSL validator: removing {len(to_remove)} item-level desc lines from chart", file=sys.stderr)592            for idx in reversed(to_remove):593                lines.pop(idx)594595    # --- 3. Chart-specific: enforce title (28) and desc (52) limits ---596    _TITLE_SUFFIX_RE = _re.compile(r'\s*(?:[-–—:]\s+.+|\([^)]+\))\s*$')597    _CHART_TITLE_MAX = 28598    _CHART_DESC_MAX = 52599    if is_chart:600        for i, line in enumerate(lines):601            stripped = line.lstrip()602            depth = len(line) - len(stripped)603            if stripped.startswith('title ') and depth <= 2:604                title_text = stripped[6:]605                indent_str = line[:depth]606                # Strip trailing suffix patterns if title is too long607                suffix_match = _TITLE_SUFFIX_RE.search(title_text)608                if suffix_match and len(title_text) > _CHART_TITLE_MAX:609                    title_text = title_text[:suffix_match.start()].rstrip()610                    print(f"DSL validator: stripped title suffix -> '{title_text}'", file=sys.stderr)611                # Hard limit — truncate at word boundary to avoid mid-word cuts612                if len(title_text) > _CHART_TITLE_MAX:613                    cut = title_text[:_CHART_TITLE_MAX].rfind(' ')614                    if cut > 5:615                        title_text = title_text[:cut]616                    else:617                        title_text = title_text[:_CHART_TITLE_MAX]618                    print(f"DSL validator: truncated chart title -> '{title_text}'", file=sys.stderr)619                lines[i] = indent_str + 'title ' + title_text620                break621        # Enforce chart desc limit (global desc only)622        for i, line in enumerate(lines):623            stripped = line.lstrip()624            depth = len(line) - len(stripped)625            if stripped.startswith('desc ') and depth <= 2:626                desc_text = stripped[5:]627                if len(desc_text) > _CHART_DESC_MAX:628                    indent_str = line[:depth]629                    cut = desc_text[:_CHART_DESC_MAX].rfind(' ')630                    if cut > 10:631                        desc_text = desc_text[:cut]632                    else:633                        desc_text = desc_text[:_CHART_DESC_MAX]634                    lines[i] = indent_str + 'desc ' + desc_text635                    print(f"DSL validator: truncated chart desc -> '{desc_text}'", file=sys.stderr)636                break637638    # --- 4. Text length enforcement ---639    # Track indent depth to distinguish global vs item fields.640    # Global title/desc are at indent 2 (directly under `data`).641    # Item fields (label, desc, value) are at indent 4+.642    for i, line in enumerate(lines):643        stripped = line.lstrip()644        indent_str = line[:len(line) - len(stripped)]645        depth = len(indent_str)646647        if stripped.startswith('title ') and depth <= 2:648            text = stripped[6:]649            if len(text) > _MAX_TITLE:650                lines[i] = indent_str + 'title ' + text[:_MAX_TITLE]651652        elif stripped.startswith('desc ') and depth <= 2:653            text = stripped[5:]654            if len(text) > _MAX_GLOBAL_DESC:655                lines[i] = indent_str + 'desc ' + text[:_MAX_GLOBAL_DESC]656657        elif stripped.startswith('label '):658            text = stripped[6:]659            if len(text) > _MAX_LABEL:660                lines[i] = indent_str + 'label ' + text[:_MAX_LABEL]661662        elif stripped.startswith('desc ') and depth > 2:663            text = stripped[5:]664            if len(text) > _MAX_ITEM_DESC:665                lines[i] = indent_str + 'desc ' + text[:_MAX_ITEM_DESC]666667        elif stripped.startswith('value '):668            text = stripped[6:]669            cleaned = _clean_value_text(text)670            if cleaned != text:671                print(f"DSL validator: cleaned value '{text}' -> '{cleaned}'", file=sys.stderr)672                lines[i] = indent_str + 'value ' + cleaned673674    # --- 5. Canvas sizing ---675    rec_w, rec_h = _recommend_canvas(template, item_count)676677    fixed = '\n'.join(lines)678    print(679        f"DSL validator: recommended canvas {rec_w}x{rec_h}",680        file=sys.stderr,681    )682    return fixed, rec_w, rec_h683684685def render_infographic(syntax, fmt="png", width=800, height=600, scale=2):686    """Call the Emblema infographic rendering API."""687    url = f"{EMBLEMA_API_BASE_URL}/api/v2/helpers/infographic/render"688689    headers = {"Content-Type": "application/json"}690    if USER_TOKEN:691        headers["Authorization"] = f"Bearer {USER_TOKEN}"692693    payload = {694        "syntax": syntax,695        "format": fmt,696        "width": width,697        "height": height,698        "scale": scale,699    }700701    print(f"Calling render API: {url}", file=sys.stderr)702    response = requests.post(url, headers=headers, json=payload, timeout=60)703704    if response.status_code != 200:705        try:706            error_detail = response.json()707            error_msg = error_detail.get("message", response.text)708        except Exception:709            error_msg = response.text710        raise RuntimeError(f"Render API returned {response.status_code}: {error_msg}")711712    return response.content713714715# Output-language directive (language-persistence workstream). Values are716# platform locale codes; legacy full names are accepted via the allowlist.717# Anything else (wired upstream output, free-form strings) falls back to718# auto: the value is interpolated into the system prompt, so unknown719# strings must never pass through.720LANGUAGE_NAMES = {721    "de": "German", "en": "English", "es": "Spanish", "fr": "French",722    "it": "Italian", "ja": "Japanese", "ko": "Korean", "nl": "Dutch",723    "pt": "Portuguese", "ru": "Russian", "zh": "Chinese",724}725726727def resolve_language_name(language):728    """Full display name for an allowlisted language value, else None."""729    if not isinstance(language, str):730        return None731    value = language.strip()732    if not value or value == "auto":733        return None734    if value in LANGUAGE_NAMES:735        return LANGUAGE_NAMES[value]736    if value in LANGUAGE_NAMES.values():737        return value738    print(f"[language] unrecognized value {value!r} ignored - using auto", file=sys.stderr)739    return None740741742async def generate_infographic(content, template_name, model, instruction="", temperature=0.3, language="auto"):743    """Generate infographic DSL using LLM."""744    system = SYSTEM_PROMPT745    system += build_template_instruction(template_name)746    lang_name = resolve_language_name(language)747    if lang_name:748        system += (749            f"\n\nOUTPUT LANGUAGE: {lang_name}. All labels, titles, and text in "750            f"the infographic MUST be written in {lang_name}, regardless of the "751            "source material's language (still shortened per the label rules "752            "above). Keep proper nouns in their original language."753        )754755    user_message = content756    if instruction and instruction.strip():757        user_message += f"\n\n## Additional Instructions\n{instruction.strip()}"758759    messages = [760        {"role": "system", "content": system},761        {"role": "user", "content": user_message},762    ]763764    # thinking=False: emits a parsed DSL; the reasoning on/off eval showed OFF765    # gives cleaner, predictable markup at far lower latency (reasoning wasted).766    result = await Gais.llm.chat_async(messages, model_id=model, temperature=temperature, thinking=False)767    raw_output = result.text768    print(f"LLM output length: {len(raw_output)} chars", file=sys.stderr)769770    dsl_syntax = clean_dsl_output(raw_output)771    print(f"Cleaned DSL length: {len(dsl_syntax)} chars", file=sys.stderr)772773    return dsl_syntax774775776def main():777    try:778        input_json = sys.stdin.read()779        execution_input = json.loads(input_json)780        inputs = execution_input.get("inputs", {})781782        content = inputs.get("content", "")783        instruction = inputs.get("instruction", "")784        template_name = inputs.get("template_name", "auto")785        model = inputs.get("llmModelId", "")786        temperature = float(inputs.get("temperature", 0.3) or 0.3)787        width = int(inputs.get("width", 800) or 800)788        height = int(inputs.get("height", 600) or 600)789        language = inputs.get("language", "auto")790791        if not content or not content.strip():792            raise ValueError("Content is required")793794        if len(content) > MAX_CONTENT_LENGTH:795            raise ValueError(f"Content too long ({len(content)} chars). Maximum is {MAX_CONTENT_LENGTH}.")796797        # NOTE: no model allowlist — `model` is the resolved `llmModelId` (an798        # llm.id/identity from the picker), not a name. The workspace planner +799        # WORKSPACE_LLM_MODEL injection already select a runnable model, same as800        # the other LLM node templates.801802        os.makedirs(OUTPUT_DIR, exist_ok=True)803804        # Step 1: Generate DSL via LLM805        print("Step 1: Generating infographic DSL via LLM...", file=sys.stderr)806        dsl_syntax = asyncio.run(generate_infographic(807            content, template_name, model, instruction, temperature, language808        ))809        print(f"Generated DSL:\n{dsl_syntax[:200]}...", file=sys.stderr)810811        # Step 2: Validate and fix DSL (enforce capacity, text limits, canvas sizing)812        print("Step 2: Validating DSL...", file=sys.stderr)813        dsl_syntax, rec_w, rec_h = validate_and_fix_dsl(dsl_syntax)814        width = max(width, rec_w)815        height = max(height, rec_h)816        print(f"Final canvas: {width}x{height}", file=sys.stderr)817818        # Step 3: Render DSL to image819        print("Step 3: Rendering infographic image...", file=sys.stderr)820        image_data = render_infographic(dsl_syntax, "png", width, height, scale=2)821822        # Save image823        out_filename = "infographic.png"824        out_path = os.path.join(OUTPUT_DIR, out_filename)825        with open(out_path, "wb") as f:826            f.write(image_data)827828        print(f"Saved: {out_filename} ({len(image_data)} bytes)", file=sys.stderr)829830        # Output both the image and the DSL syntax831        output = {832            "image": out_filename,833            "syntax": dsl_syntax,834        }835        print(json.dumps(output, indent=2))836837    except Exception as e:838        error_output = {839            "error": str(e),840            "errorType": type(e).__name__,841            "traceback": traceback.format_exc(),842        }843        print(json.dumps(error_output), file=sys.stderr)844        sys.exit(1)845846847if __name__ == "__main__":848    main()

$ git log --oneline

v1.5.0
HEAD
2026-08-18
v1.4.12026-06-27
v1.4.02026-06-24
v1.2.22026-05-22
v1.2.12026-05-07
v1.0.02026-04-09