$ 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_0001415ALLOWED_MODELS = {16    "qwen3.5-35b",17    "gpt-oss-120b",18}1920# ---------------------------------------------------------------------------21# System prompt — based on official AntV Infographic skill files with22# data-density, capacity, and layout-quality constraints.23# ---------------------------------------------------------------------------24SYSTEM_PROMPT = """You are an expert infographic designer using the AntV Infographic DSL.2526Given the user's content, produce ONE block of AntV Infographic DSL syntax that best visualizes the information.2728## STEP 0 — THINK BEFORE YOU GENERATE2930Before writing any DSL, silently analyse the input:311. Count distinct data items. How many items/nodes/points exist?322. Measure text: are labels short (1-3 words) or long (4+ words)?333. Decide structure type: list, sequence, compare, hierarchy, relation, chart.344. Pick a template whose capacity fits the item count (see TEMPLATE CAPACITY below).355. Plan concise labels — shorten aggressively if the source text is verbose.36Then output ONLY the DSL (no analysis, no commentary).3738## DSL FORMAT3940- First line: `infographic <template-name>`41- Blocks: `data` / `theme`, indented with two spaces.42- Key-value: `key value` (space-separated).43- Arrays: dash prefix `- ` for each entry.44- No JSON, no Markdown, no code fences, no explanation.4546Example:4748infographic list-row-horizontal-icon-arrow49data50  title Internet Technology Evolution51  desc Key milestones from Web 1.0 to AI52  lists53    - time 199154      label Web 1.055      desc First website by Tim Berners-Lee56      icon web57    - time 200458      label Web 2.059      desc Social media goes mainstream60      icon account multiple61    - time 202362      label AI Large Model63      desc ChatGPT sparks the AI revolution64      icon brain65theme66  palette #3b82f6 #8b5cf6 #f973166768## DATA FIELDS BY TEMPLATE TYPE (use ONLY the correct field)6970| Template prefix | Data field | Notes |71|---|---|---|72| `list-*` | `lists` | Flat array of items |73| `sequence-*` | `sequences` | Optional `order asc/desc` |74| `compare-binary-*` / `compare-hierarchy-left-right-*` | `compares` | MUST have exactly 2 root items, sub-items in `children` |75| `compare-swot` | `compares` | Exactly 4 root items (S/W/O/T) with `children` |76| `compare-quadrant-*` | `compares` | Exactly 4 items |77| `hierarchy-structure` / `hierarchy-structure-mirror` | `items` | Up to 3 nesting levels via `children` |78| Other `hierarchy-*` | `root` | Single root, tree via `children` nesting. Do NOT repeat `root`. |79| `relation-*` | `nodes` + `relations` | Edges: `A - label -> B` or `A -->|label| B` |80| `chart-*` | `values` | Optional `category` |81| Fallback | `items` | Only when none of the above match |8283NEVER mix data fields. Use exactly one per template.8485## TEXT DENSITY RULES (CRITICAL for visual quality)8687The rendering engine allocates fixed space per card/node. Overflow causes overlapping text. Obey these limits:8889| Field | Max chars | Guidance |90|---|---|---|91| `title` (chart-*) | 25 | Chart title. Keep concise — 3-4 words max. |92| `desc` (chart-*) | 50 | Chart subtitle. One short phrase. |93| `title` (other) | 35 | Main title. Concise headline. |94| `desc` (global) | 60 | Subtitle under the title. One line. |95| `label` | 25 | Item label. Shorten aggressively: abbreviate, drop articles/prepositions. |96| `desc` (per item) | 50 | Keep to one sentence. Omit if the label is self-explanatory. |97| `value` | 8 | Numeric only. E.g. `82%`, `3.94`, `1560` |9899Chart title + desc examples (chart-* templates):100  BAD:  title "Docenti con le valutazioni più basse nel primo modulo"  (52 chars — too long!)101  GOOD: title "Docenti Peggiori"  desc "Valutazioni Modulo I"  (16 + 20 chars)102  BAD:  title "Distribuzione delle risposte al sondaggio"      (40 chars — too long!)103  GOOD: title "Risposte Sondaggio"  desc "Distribuzione per modulo"  (18 + 25 chars)104105General shortening examples:106  BAD:  "DINAMICA DELLE STRUTTURE ORGANIZZATIVE"   (40 chars)107  GOOD: "Strutture Organizzative"                    (23 chars)108  BAD:  "La lezione e stata interessante?"           (31 chars)109  GOOD: "Interesse Lezione"                          (17 chars)110111When the source language is verbose (Italian, German, French), shorten to core nouns only. Drop verbs, articles, prepositions.112113## TEMPLATE CAPACITY LIMITS114115Exceeding these limits causes cramped, overlapping cards. If data exceeds a limit, either CHOOSE A DIFFERENT TEMPLATE or SUMMARIZE/GROUP items.116117| Template pattern | Max items | When exceeded, prefer |118|---|---|---|119| `list-grid-*` | 6 | list-column-*, list-waterfall-*, list-zigzag-* |120| `list-row-*` | 5 | list-column-*, sequence-snake-* |121| `list-column-*` | 8 | hierarchy-mindmap-*, sequence-roadmap-* |122| `list-sector-*` | 6 | list-column-*, list-waterfall-* |123| `list-pyramid-*` | 5 | sequence-pyramid-*, sequence-funnel-* |124| `sequence-steps-*` | 6 | sequence-snake-*, sequence-roadmap-* |125| `sequence-timeline-*` | 8 | sequence-roadmap-*, list-column-* |126| `sequence-roadmap-*` | 10 | Split into two infographics |127| `sequence-snake-*` | 10 | Split or use hierarchy-mindmap-* |128| `compare-binary-*` | 2 root + 5 children each | Reduce children |129| `compare-swot` | 4 root + 4 children each | Summarise children |130| `hierarchy-tree-*` | 3 levels, 4-5 nodes/level | Prune or use mindmap |131| `hierarchy-mindmap-*` | 3 levels, 6 nodes/level | OK for large data |132| `chart-pie-*` | 6 slices | Group small slices into "Other" |133| `chart-bar-*` / `chart-column-*` | 8 bars | Group or top-N |134| `relation-dagre-flow-*` | 10 nodes | Simplify graph |135136## TEMPLATE SELECTION GUIDE137138Match the content structure to a template family:139140- Strict order (process/steps/timeline/trend) -> `sequence-*`141  - Dates -> `sequence-timeline-*`142  - Step progression -> `sequence-stairs-*`, `sequence-ascending-*`, `sequence-steps-*`143  - Roadmap -> `sequence-roadmap-vertical-*`144  - Circular cycle -> `sequence-circular-simple`145  - Narrowing/filtering -> `sequence-funnel-simple`, `sequence-pyramid-simple`146  - Snake/zigzag path -> `sequence-snake-steps-*`, `sequence-zigzag-*`147- Feature listing / bullet points -> `list-row-*` (horizontal) or `list-column-*` (vertical)148- Grid of cards (FEW items) -> `list-grid-*` (max 6 items!)149- Waterfall / cascading -> `list-waterfall-*`150- Binary A-vs-B -> `compare-binary-*`151- SWOT -> `compare-swot`152- 2x2 matrix -> `compare-quadrant-*`153- Tree / categorization -> `hierarchy-tree-*`154- Mind map / brainstorm -> `hierarchy-mindmap-*`155- Org chart -> `hierarchy-structure`, `hierarchy-structure-mirror`156- Numeric data -> `chart-*` (pie=proportions, bar/column=comparison, line=trend)157- Word cloud -> `chart-wordcloud`158- Network/dependencies -> `relation-dagre-flow-*`, `relation-network-*`, `relation-circle-*`159160## HIERARCHY TREE VARIANTS161162Tree templates follow the pattern: `hierarchy-tree-{direction}-{edge}-{node}`163- Direction: (default=top-down), `bt` (bottom-up), `lr` (left-right), `rl` (right-left)164- Edge style: `curved-line`, `dashed-line`, `dashed-arrow`, `distributed-origin`, `tech-style`165- Node style: `badge-card`, `capsule-item`, `compact-card`, `ribbon-card`, `rounded-rect-node`166167## AVAILABLE TEMPLATES (237 total)168169chart (11): chart-bar-plain-text, chart-column-simple, chart-line-plain-text,170chart-pie-compact-card, chart-pie-donut-compact-card, chart-pie-donut-pill-badge,171chart-pie-donut-plain-text, chart-pie-pill-badge, chart-pie-plain-text,172chart-wordcloud, chart-wordcloud-rotate173174list (29): list-column-done-list, list-column-simple-vertical-arrow, list-column-vertical-icon-arrow,175list-grid-badge-card, list-grid-candy-card-lite, list-grid-circular-progress,176list-grid-compact-card, list-grid-done-list, list-grid-horizontal-icon-arrow,177list-grid-progress-card, list-grid-ribbon-card, list-grid-simple,178list-pyramid-badge-card, list-pyramid-compact-card, list-pyramid-rounded-rect-node,179list-row-circular-progress, list-row-horizontal-icon-arrow, list-row-horizontal-icon-line,180list-row-simple-horizontal-arrow, list-row-simple-illus,181list-sector-half-plain-text, list-sector-plain-text, list-sector-simple,182list-waterfall-badge-card, list-waterfall-compact-card,183list-zigzag-down-compact-card, list-zigzag-down-simple,184list-zigzag-up-compact-card, list-zigzag-up-simple185186sequence (47): sequence-ascending-stairs-3d-simple, sequence-ascending-stairs-3d-underline-text,187sequence-ascending-steps, sequence-circle-arrows-indexed-card,188sequence-circular-simple, sequence-circular-underline-text,189sequence-color-snake-steps-horizontal-icon-line, sequence-color-snake-steps-simple-illus,190sequence-cylinders-3d-simple, sequence-filter-mesh-simple, sequence-filter-mesh-underline-text,191sequence-funnel-simple, sequence-horizontal-zigzag-horizontal-icon-line,192sequence-horizontal-zigzag-plain-text, sequence-horizontal-zigzag-simple,193sequence-horizontal-zigzag-simple-horizontal-arrow, sequence-horizontal-zigzag-simple-illus,194sequence-horizontal-zigzag-underline-text, sequence-mountain-underline-text,195sequence-pyramid-simple, sequence-roadmap-vertical-badge-card,196sequence-roadmap-vertical-pill-badge, sequence-roadmap-vertical-plain-text,197sequence-roadmap-vertical-quarter-circular, sequence-roadmap-vertical-quarter-simple-card,198sequence-roadmap-vertical-simple, sequence-roadmap-vertical-underline-text,199sequence-snake-steps-compact-card, sequence-snake-steps-pill-badge,200sequence-snake-steps-simple, sequence-snake-steps-simple-illus,201sequence-snake-steps-underline-text, sequence-stairs-front-compact-card,202sequence-stairs-front-pill-badge, sequence-stairs-front-simple,203sequence-steps-badge-card, sequence-steps-simple, sequence-steps-simple-illus,204sequence-timeline-done-list, sequence-timeline-plain-text,205sequence-timeline-rounded-rect-node, sequence-timeline-simple,206sequence-timeline-simple-illus, sequence-zigzag-pucks-3d-indexed-card,207sequence-zigzag-pucks-3d-simple, sequence-zigzag-pucks-3d-underline-text,208sequence-zigzag-steps-underline-text209210compare (20): compare-binary-horizontal-badge-card-arrow, compare-binary-horizontal-badge-card-fold,211compare-binary-horizontal-badge-card-vs, compare-binary-horizontal-compact-card-arrow,212compare-binary-horizontal-compact-card-fold, compare-binary-horizontal-compact-card-vs,213compare-binary-horizontal-simple-arrow, compare-binary-horizontal-simple-fold,214compare-binary-horizontal-simple-vs, compare-binary-horizontal-underline-text-arrow,215compare-binary-horizontal-underline-text-fold, compare-binary-horizontal-underline-text-vs,216compare-hierarchy-left-right-circle-node-pill-badge,217compare-hierarchy-left-right-circle-node-plain-text,218compare-hierarchy-row-letter-card-compact-card,219compare-hierarchy-row-letter-card-rounded-rect-node,220compare-quadrant-quarter-circular, compare-quadrant-quarter-simple-card,221compare-quadrant-simple-illus, compare-swot222223hierarchy (112): hierarchy-mindmap-branch-gradient-capsule-item, hierarchy-mindmap-branch-gradient-circle-progress,224hierarchy-mindmap-branch-gradient-compact-card, hierarchy-mindmap-branch-gradient-lined-palette,225hierarchy-mindmap-branch-gradient-rounded-rect, hierarchy-mindmap-level-gradient-capsule-item,226hierarchy-mindmap-level-gradient-circle-progress, hierarchy-mindmap-level-gradient-compact-card,227hierarchy-mindmap-level-gradient-lined-palette, hierarchy-mindmap-level-gradient-rounded-rect,228hierarchy-structure, hierarchy-structure-mirror,229hierarchy-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)230231relation (18): relation-circle-circular-progress, relation-circle-icon-badge,232relation-dagre-flow-lr-animated-badge-card, relation-dagre-flow-lr-animated-capsule,233relation-dagre-flow-lr-animated-compact-card, relation-dagre-flow-lr-animated-simple-circle-node,234relation-dagre-flow-lr-badge-card, relation-dagre-flow-lr-compact-card,235relation-dagre-flow-lr-simple-circle-node, relation-dagre-flow-tb-animated-badge-card,236relation-dagre-flow-tb-animated-capsule, relation-dagre-flow-tb-animated-compact-card,237relation-dagre-flow-tb-animated-simple-circle-node, relation-dagre-flow-tb-badge-card,238relation-dagre-flow-tb-compact-card, relation-dagre-flow-tb-simple-circle-node,239relation-network-icon-badge, relation-network-simple-circle-node240241## ICON KEYWORDS242243Use icon keywords (matched by the renderer). Examples:244star fill, check circle, trending up, chart bar, users, lightbulb, rocket,245shield, target, globe, code, database, search, alert triangle, clock, calendar,246brain, heart, zap, trophy, sprout, document text, web, cellphone, cloud,247application brackets, sun, moon, flash fast, secure shield check, account multiple248249## THEME OPTIONS250251Optional `theme` block for customisation:252253Dark theme + custom palette:254  theme dark255    palette256      - #61DDAA257      - #F6BD16258      - #F08BB4259260Hand-drawn style:261  theme262    stylize rough263    base264      text265        font-family 851tegakizatsu266267Available stylize types: rough, pattern, linear-gradient, radial-gradient268269## INPUT DATA FORMAT270271The input may include tool call results. Pay attention to:272- `intermediate: true` = research/helper steps. Useful context but SECONDARY.273- `intermediate: false` (or absent) = PRIMARY/FINAL data. Focus on these.274Prioritise non-intermediate content for the main data points.275276## COMPLETE DSL EXAMPLES277278List (horizontal arrow):279infographic list-row-horizontal-icon-arrow280data281  title Feature List282  lists283    - label Fast284      icon flash fast285    - label Secure286      icon secure shield check287    - label Scalable288      icon cloud289290Sequence (steps):291infographic sequence-steps-simple292data293  title Build Process294  sequences295    - label Design296      desc Create wireframes297    - label Develop298      desc Build the MVP299    - label Launch300      desc Release to users301  order asc302303Hierarchy (tree):304infographic hierarchy-tree-curved-line-rounded-rect-node305data306  root307    label Company308    children309      - label Engineering310        children311          - label Frontend312          - label Backend313      - label Marketing314315Compare (SWOT):316infographic compare-swot317data318  compares319    - label Strengths320      children321        - label Strong brand322        - label Loyal users323    - label Weaknesses324      children325        - label High cost326    - label Opportunities327      children328        - label Emerging markets329    - label Threats330      children331        - label Competitors332333Chart (column):334infographic chart-column-simple335data336  title Monthly Revenue337  values338    - label Jan339      value 1280340    - label Feb341      value 1560342    - label Mar343      value 1890344345Relation (flow):346infographic relation-dagre-flow-tb-simple-circle-node347data348  nodes349    - id A350      label Input351    - id B352      label Process353    - id C354      label Output355  relations356    A - feeds -> B357    B - produces -> C358359## CRITICAL RULES3603611. Output ONLY DSL — no markdown, no code fences, no commentary, no explanation.3622. First line MUST be `infographic <template-name>`.3633. Two-space indentation throughout.3644. Use the CORRECT data field for the chosen template (see DATA FIELDS table).3655. Respect TEXT DENSITY LIMITS: label <= 25 chars, desc <= 50 chars, title <= 35 chars.3666. For chart-* templates: title MUST be <= 25 chars (3-4 words), desc <= 50 chars. Keep chart titles concise.3677. Respect TEMPLATE CAPACITY LIMITS. If data exceeds capacity, choose a larger template or summarise.3688. When source text is verbose, SHORTEN to core nouns. Drop articles, verbs, prepositions.3699. Do NOT fabricate data unrelated to the user's content.37010. Binary compare templates MUST have exactly 2 root items.37111. Hierarchy templates use single `root` (do not repeat `root`).37212. Preserve the user's language for labels — but shorten within that language.37313. When a specific template is requested, use it even if another might fit better.374"""375376# ---------------------------------------------------------------------------377# Template DSL examples — fetched at runtime from the static manifest.378# The manifest lives at /templates/infographic/manifest.json (served by Next.js)379# and contains { id, category, syntax } for all 237 templates.380# ---------------------------------------------------------------------------381382_MANIFEST_CACHE = None383384def _fetch_manifest():385    """Fetch the template manifest once and cache it."""386    global _MANIFEST_CACHE387    if _MANIFEST_CACHE is not None:388        return _MANIFEST_CACHE389390    url = f"{EMBLEMA_API_BASE_URL}/templates/infographic/manifest.json"391    try:392        print(f"Fetching template manifest: {url}", file=sys.stderr)393        resp = requests.get(url, timeout=15)394        resp.raise_for_status()395        manifest = resp.json()396        # Build lookup dict: id -> syntax397        _MANIFEST_CACHE = {entry["id"]: entry.get("syntax", "") for entry in manifest}398        print(f"Loaded {len(_MANIFEST_CACHE)} template examples from manifest", file=sys.stderr)399        return _MANIFEST_CACHE400    except Exception as e:401        print(f"Warning: Could not fetch template manifest: {e}", file=sys.stderr)402        _MANIFEST_CACHE = {}403        return _MANIFEST_CACHE404405406def build_template_instruction(template_name):407    """When user picks a specific template, inject its DSL example into prompt."""408    if not template_name or template_name == "auto":409        return ""410411    manifest = _fetch_manifest()412    example = manifest.get(template_name, "")413414    if example:415        return (416            "\n\nYou MUST use the template '" + template_name + "'.\n"417            "Here is the reference syntax showing the correct DSL structure:\n\n" + example + "\n\n"418            "Rules:\n"419            "- Use the EXACT template name on the first line\n"420            "- Use the SAME DSL field names (e.g. 'lists', 'sequences', 'compares', 'root', 'nodes', 'values')\n"421            "- Adapt the number of items, nesting depth, and content to fit the user's data and instructions\n"422            "- The user's additional instructions take priority when they conflict with the example's structure"423        )424425    # Final fallback - no example available426    return "\n\nYou MUST use the template '" + template_name + "'."427428429def clean_dsl_output(raw_output):430    """Clean LLM output to extract pure DSL syntax."""431    text = raw_output.strip()432433    # Remove markdown code fences if present434    if text.startswith("```"):435        lines = text.split("\n")436        # Remove first line (```yaml or ```text or ```)437        lines = lines[1:]438        # Remove last line if it's closing ```439        if lines and lines[-1].strip() == "```":440            lines = lines[:-1]441        text = "\n".join(lines).strip()442443    # Ensure it starts with 'infographic '444    lines = text.split("\n")445    start_idx = 0446    for i, line in enumerate(lines):447        if line.strip().startswith("infographic "):448            start_idx = i449            break450451    text = "\n".join(lines[start_idx:]).strip()452453    if not text.startswith("infographic "):454        raise ValueError(455            "LLM output does not contain valid infographic DSL. "456            f"Output starts with: {text[:100]}"457        )458459    return text460461462# ---------------------------------------------------------------------------463# DSL Validator — enforces capacity limits, text lengths, and canvas sizing464# Runs AFTER LLM generation, BEFORE rendering.465# ---------------------------------------------------------------------------466467import re as _re468469# (max_items, swap_target_template)470_TEMPLATE_CAPACITY = {471    'list-grid-': (6, 'list-waterfall-badge-card'),472    'list-row-': (5, 'list-column-vertical-icon-arrow'),473    'list-sector-': (6, 'list-waterfall-compact-card'),474    'list-pyramid-': (5, 'list-waterfall-badge-card'),475    'sequence-steps-': (6, 'sequence-snake-steps-compact-card'),476    'sequence-stairs-': (5, 'sequence-snake-steps-compact-card'),477    'chart-pie-': (6, None),478    'chart-bar-': (8, None),479    'chart-column-': (8, None),480}481482_MAX_LABEL = 25483_MAX_ITEM_DESC = 50484_MAX_TITLE = 40485_MAX_GLOBAL_DESC = 70486487488def _count_top_items(lines):489    """Count top-level array entries in the DSL (lines starting with '- ' at the shallowest array indent)."""490    first_indent = None491    count = 0492    for line in lines:493        stripped = line.lstrip()494        if stripped.startswith('- '):495            indent = len(line) - len(stripped)496            if first_indent is None:497                first_indent = indent498            if indent == first_indent:499                count += 1500    return count501502503def _clean_value_text(text):504    """Strip verbose prefixes from value fields, keeping just the number."""505    text = text.strip()506    # Already short and numeric-looking — keep as-is507    if len(text) <= 8:508        return text509    # N/A variants510    if 'N/A' in text or 'n/a' in text:511        return 'N/A'512    # Extract the first number (with optional decimal and %)513    m = _re.search(r'(\d+\.?\d*%?)', text)514    if m:515        return m.group(1)516    # Fallback: truncate517    return text[:8]518519520def _recommend_canvas(template, item_count):521    """Suggest minimum canvas size based on template type and data volume."""522    W, H = 1920, 1080  # Base canvas (Full HD)523524    if template.startswith(('chart-column-', 'chart-bar-')):525        return (max(W, item_count * 250), H)526527    if template.startswith('chart-'):528        return (W, H)529530    if template.startswith('list-grid-'):531        cols = 3532        rows = max(2, (item_count + cols - 1) // cols)533        return (W, max(H, rows * 300))534535    if template.startswith(('list-column-', 'list-waterfall-', 'list-zigzag-')):536        return (W, max(H, item_count * 180))537538    if template.startswith('list-row-'):539        return (max(W, item_count * 280), H)540541    if template.startswith(('sequence-roadmap-', 'sequence-snake-', 'sequence-timeline-')):542        return (W, max(H, item_count * 160))543544    if template.startswith('sequence-'):545        return (max(W, item_count * 220), H)546547    if template.startswith('hierarchy-'):548        return (W, max(H, 1200))549550    return (W, H)551552553def validate_and_fix_dsl(dsl):554    """555    Post-LLM safety net: enforce template capacity, text limits, and canvas sizing.556    Returns (fixed_dsl, recommended_width, recommended_height).557    """558    lines = dsl.split('\n')559    if not lines or not lines[0].startswith('infographic '):560        return dsl, 1920, 1080561562    template = lines[0][len('infographic '):].strip()563    item_count = _count_top_items(lines)564    print(f"DSL validator: template={template}, items={item_count}", file=sys.stderr)565566    # --- 1. Template capacity enforcement ---567    for prefix, (limit, swap) in _TEMPLATE_CAPACITY.items():568        if template.startswith(prefix) and item_count > limit:569            if swap:570                print(571                    f"DSL validator: {template} has {item_count} items "572                    f"(max {limit}), swapping to {swap}",573                    file=sys.stderr,574                )575                lines[0] = 'infographic ' + swap576                template = swap577            else:578                print(579                    f"DSL validator: {template} has {item_count} items "580                    f"(max {limit}), no swap available — keeping as-is",581                    file=sys.stderr,582                )583            break584585    is_chart = template.startswith('chart-')586587    # --- 2. Chart-specific: strip per-item desc (bars need only label+value) ---588    if is_chart:589        to_remove = []590        for i, line in enumerate(lines):591            stripped = line.lstrip()592            depth = len(line) - len(stripped)593            if stripped.startswith('desc ') and depth > 2:594                to_remove.append(i)595        if to_remove:596            print(f"DSL validator: removing {len(to_remove)} item-level desc lines from chart", file=sys.stderr)597            for idx in reversed(to_remove):598                lines.pop(idx)599600    # --- 3. Chart-specific: enforce title (28) and desc (52) limits ---601    _TITLE_SUFFIX_RE = _re.compile(r'\s*(?:[-–—:]\s+.+|\([^)]+\))\s*$')602    _CHART_TITLE_MAX = 28603    _CHART_DESC_MAX = 52604    if is_chart:605        for i, line in enumerate(lines):606            stripped = line.lstrip()607            depth = len(line) - len(stripped)608            if stripped.startswith('title ') and depth <= 2:609                title_text = stripped[6:]610                indent_str = line[:depth]611                # Strip trailing suffix patterns if title is too long612                suffix_match = _TITLE_SUFFIX_RE.search(title_text)613                if suffix_match and len(title_text) > _CHART_TITLE_MAX:614                    title_text = title_text[:suffix_match.start()].rstrip()615                    print(f"DSL validator: stripped title suffix -> '{title_text}'", file=sys.stderr)616                # Hard limit — truncate at word boundary to avoid mid-word cuts617                if len(title_text) > _CHART_TITLE_MAX:618                    cut = title_text[:_CHART_TITLE_MAX].rfind(' ')619                    if cut > 5:620                        title_text = title_text[:cut]621                    else:622                        title_text = title_text[:_CHART_TITLE_MAX]623                    print(f"DSL validator: truncated chart title -> '{title_text}'", file=sys.stderr)624                lines[i] = indent_str + 'title ' + title_text625                break626        # Enforce chart desc limit (global desc only)627        for i, line in enumerate(lines):628            stripped = line.lstrip()629            depth = len(line) - len(stripped)630            if stripped.startswith('desc ') and depth <= 2:631                desc_text = stripped[5:]632                if len(desc_text) > _CHART_DESC_MAX:633                    indent_str = line[:depth]634                    cut = desc_text[:_CHART_DESC_MAX].rfind(' ')635                    if cut > 10:636                        desc_text = desc_text[:cut]637                    else:638                        desc_text = desc_text[:_CHART_DESC_MAX]639                    lines[i] = indent_str + 'desc ' + desc_text640                    print(f"DSL validator: truncated chart desc -> '{desc_text}'", file=sys.stderr)641                break642643    # --- 4. Text length enforcement ---644    # Track indent depth to distinguish global vs item fields.645    # Global title/desc are at indent 2 (directly under `data`).646    # Item fields (label, desc, value) are at indent 4+.647    for i, line in enumerate(lines):648        stripped = line.lstrip()649        indent_str = line[:len(line) - len(stripped)]650        depth = len(indent_str)651652        if stripped.startswith('title ') and depth <= 2:653            text = stripped[6:]654            if len(text) > _MAX_TITLE:655                lines[i] = indent_str + 'title ' + text[:_MAX_TITLE]656657        elif stripped.startswith('desc ') and depth <= 2:658            text = stripped[5:]659            if len(text) > _MAX_GLOBAL_DESC:660                lines[i] = indent_str + 'desc ' + text[:_MAX_GLOBAL_DESC]661662        elif stripped.startswith('label '):663            text = stripped[6:]664            if len(text) > _MAX_LABEL:665                lines[i] = indent_str + 'label ' + text[:_MAX_LABEL]666667        elif stripped.startswith('desc ') and depth > 2:668            text = stripped[5:]669            if len(text) > _MAX_ITEM_DESC:670                lines[i] = indent_str + 'desc ' + text[:_MAX_ITEM_DESC]671672        elif stripped.startswith('value '):673            text = stripped[6:]674            cleaned = _clean_value_text(text)675            if cleaned != text:676                print(f"DSL validator: cleaned value '{text}' -> '{cleaned}'", file=sys.stderr)677                lines[i] = indent_str + 'value ' + cleaned678679    # --- 5. Canvas sizing ---680    rec_w, rec_h = _recommend_canvas(template, item_count)681682    fixed = '\n'.join(lines)683    print(684        f"DSL validator: recommended canvas {rec_w}x{rec_h}",685        file=sys.stderr,686    )687    return fixed, rec_w, rec_h688689690def render_infographic(syntax, fmt="png", width=800, height=600, scale=2):691    """Call the Emblema infographic rendering API."""692    url = f"{EMBLEMA_API_BASE_URL}/api/v2/helpers/infographic/render"693694    headers = {"Content-Type": "application/json"}695    if USER_TOKEN:696        headers["Authorization"] = f"Bearer {USER_TOKEN}"697698    payload = {699        "syntax": syntax,700        "format": fmt,701        "width": width,702        "height": height,703        "scale": scale,704    }705706    print(f"Calling render API: {url}", file=sys.stderr)707    response = requests.post(url, headers=headers, json=payload, timeout=60)708709    if response.status_code != 200:710        try:711            error_detail = response.json()712            error_msg = error_detail.get("message", response.text)713        except Exception:714            error_msg = response.text715        raise RuntimeError(f"Render API returned {response.status_code}: {error_msg}")716717    return response.content718719720async def generate_infographic(content, template_name, model, instruction="", temperature=0.3):721    """Generate infographic DSL using LLM."""722    system = SYSTEM_PROMPT723    system += build_template_instruction(template_name)724725    user_message = content726    if instruction and instruction.strip():727        user_message += f"\n\n## Additional Instructions\n{instruction.strip()}"728729    messages = [730        {"role": "system", "content": system},731        {"role": "user", "content": user_message},732    ]733734    result = await Gais.llm.chat_async(messages, model=model, temperature=temperature)735    raw_output = result.text736    print(f"LLM output length: {len(raw_output)} chars", file=sys.stderr)737738    dsl_syntax = clean_dsl_output(raw_output)739    print(f"Cleaned DSL length: {len(dsl_syntax)} chars", file=sys.stderr)740741    return dsl_syntax742743744def main():745    try:746        input_json = sys.stdin.read()747        execution_input = json.loads(input_json)748        inputs = execution_input.get("inputs", {})749750        content = inputs.get("content", "")751        instruction = inputs.get("instruction", "")752        template_name = inputs.get("template_name", "auto")753        model = inputs.get("llmModel", "qwen3.5-35b")754        temperature = float(inputs.get("temperature", 0.3))755        width = int(inputs.get("width", 800))756        height = int(inputs.get("height", 600))757758        if not content or not content.strip():759            raise ValueError("Content is required")760761        if len(content) > MAX_CONTENT_LENGTH:762            raise ValueError(f"Content too long ({len(content)} chars). Maximum is {MAX_CONTENT_LENGTH}.")763764        if model not in ALLOWED_MODELS:765            raise ValueError(f"Invalid model '{model}'. Allowed: {', '.join(sorted(ALLOWED_MODELS))}")766767        os.makedirs(OUTPUT_DIR, exist_ok=True)768769        # Step 1: Generate DSL via LLM770        print("Step 1: Generating infographic DSL via LLM...", file=sys.stderr)771        dsl_syntax = asyncio.run(generate_infographic(772            content, template_name, model, instruction, temperature773        ))774        print(f"Generated DSL:\n{dsl_syntax[:200]}...", file=sys.stderr)775776        # Step 2: Validate and fix DSL (enforce capacity, text limits, canvas sizing)777        print("Step 2: Validating DSL...", file=sys.stderr)778        dsl_syntax, rec_w, rec_h = validate_and_fix_dsl(dsl_syntax)779        width = max(width, rec_w)780        height = max(height, rec_h)781        print(f"Final canvas: {width}x{height}", file=sys.stderr)782783        # Step 3: Render DSL to image784        print("Step 3: Rendering infographic image...", file=sys.stderr)785        image_data = render_infographic(dsl_syntax, "png", width, height, scale=2)786787        # Save image788        out_filename = "infographic.png"789        out_path = os.path.join(OUTPUT_DIR, out_filename)790        with open(out_path, "wb") as f:791            f.write(image_data)792793        print(f"Saved: {out_filename} ({len(image_data)} bytes)", file=sys.stderr)794795        # Output both the image and the DSL syntax796        output = {797            "image": out_filename,798            "syntax": dsl_syntax,799        }800        print(json.dumps(output, indent=2))801802    except Exception as e:803        error_output = {804            "error": str(e),805            "errorType": type(e).__name__,806            "traceback": traceback.format_exc(),807        }808        print(json.dumps(error_output), file=sys.stderr)809        sys.exit(1)810811812if __name__ == "__main__":813    main()

$ git log --oneline

v1.2.1
HEAD
2026-05-07
v1.0.02026-04-09