$ 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()