Claude Skill

architecture-drawer

Use when asked to draw system architecture diagrams, generate technical architecture SVGs, or export architecture diagrams to editable PowerPoint presentations. Supports multi-layer diagrams with automatic layout validation and scoring (16-dimension evaluator catches collisions,

LLM Mart · 0 points · 33 views 39 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download Andy1314Chen-architecture-drawer-plugins_architecture-drawer_skills_architecture-drawer-3f5fb5a.zip · 156 KB

Install

skills CLI npx skills add https://github.com/Andy1314Chen/architecture-drawer/tree/main/plugins/architecture-drawer/skills/architecture-drawer
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install andy1314chen-architecture-drawer@llmmart
Git git clone https://github.com/Andy1314Chen/architecture-drawer.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole andy1314chen/architecture-drawer collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

SVG Architecture Drawer (Smart Version)

This Skill converts complex technical descriptions into structured SVG architecture diagrams. It integrates layout constraints, collision detection, connection/arrow connectivity validation, and quality evaluation, automatically identifying and guiding the correction of layout errors. The script directory (referred to as $SKILL below) is this skill's own scripts/ folder. From a generator script that lives next to its artifacts, resolve it relative to the script's own location (never hard-code an absolute path, which breaks on other machines):

import os, sys
_HERE = os.path.dirname(os.path.abspath(__file__))
# From evals/<name>/gen.py -> ../../scripts ; adjust depth for your layout.
_SKILL = os.path.normpath(os.path.join(_HERE, "..", "..", "scripts"))
if _SKILL not in sys.path:
    sys.path.insert(0, _SKILL)

Fast Path (read this first; details below are on-demand reference)

Ordinary generation follows this bounded loop. Do not read the full specification sections before the first candidate runs — the checks below tell you what to fix, and each section explains its own vocabulary when a report line names it.

Strategy — the evaluator is the oracle, not your own verification. Do not read scripts/evaluator.py to internalize every threshold, and do not mentally verify the layout against all 16 checks before writing: that duplicates work the evaluator does in under a second. Sketch an approximate layout with sane coordinates, run gen.py, read the report lines, and fix exactly what they name (thresholds + repairs are tabulated in references/checks_cheatsheet.md — read that table instead of the evaluator source).

Do the arithmetic in code, not in your head. references/api_quickref.md tabulates every DSL signature + the known traps (which default draws an arrow, text-y semantics, container node_kind), and the layout helpers (layout_grid / layout_row / layout_band / layout_radial) compute positions from the array spec — state "6 chips, 3 per row, 24px gutters", not forty hand-derived coordinates. Start from the skeleton in references/gen_template.md (constants block for bands/nodes/flow, fixed evaluate-export tail) instead of re-deriving the boilerplate.

  1. Write the candidate — resolve $SKILL (snippet above), pick ONE palette preset from references/design_specs.md (do not invent hex values), land the Step-1 design-brief tokens as a constants block atop gen.py, draw with drawer.rect/circle/connect (register node_ids so connections validate), then immediately run the script. One clear main flow beats a dense map; ≤12 primary nodes before the first evaluation.
  2. Evaluateevaluate_svg(drawer) prints the score; every [FAIL] line names the defect class (dangle / route-through / crossing / text overlap / contrast / palette). Fix exactly what the lines name; the Auto-Correction section maps each tag to its repair.
  3. Bounded repair — at most 2 focused correction rounds on the highest-penalty FAILs (call auto_refine(drawer) first: gutter and spacing fix themselves). If the score reaches ≥80 with no [FAIL] remaining, ship. If two rounds do not converge, stop and report the unresolved [FAIL] lines truthfully — never claim success with defects outstanding, and never widen the canvas or shrink text to hide them.
digraph fastpath {
    "write candidate" -> "evaluate_svg()" -> "auto_refine + fix FAILs";
    "auto_refine + fix FAILs" -> "evaluate_svg()" [label="round ≤2"];
    "auto_refine + fix FAILs" -> "ship (score≥80, 0 FAIL)" [label="clean"];
    "auto_refine + fix FAILs" -> "report unresolved FAILs truthfully"
        [label="2 rounds spent"];
}

Step 0 — Intent Judgment (fidelity vs. completion)

Before writing any code, classify the requirement — the two failure modes are mirror images: transcribing a vague spec literally produces a broken diagram, and "improving" a precise spec produces one the user did not ask for.

Faithful mode — the description is clear and detailed (explicit components, relations, flow direction, canvas): transcribe it exactly. Do not invent components, layers, edges, or legend entries the user did not state, and do not "upgrade" the palette or topology on your own taste. Adding unrequested boxes is a defect, not a feature (画蛇添足).

Completion mode — the description is vague (the user may not have a fixed picture in mind yet): infer a reasonable design, then state what you inferred. Ambiguity signals and the corresponding conservative defaults:

Missing in the spec Conservative default
Relations between named components connect adjacent tiers only, in the domain's natural flow (client → API → compute → storage)
Canvas / size 1200×800 (or the diagram-type preset in references/diagram_types.md)
Diagram type pick from references/diagram_types.md by content keywords
Layer grouping group only when the spec's own vocabulary implies it ("… layer", "… module")
A composite-sounding component ("gateway", "engine") stays ONE node — never split into sub-nodes the user did not mention

Completion rule: an addition is legitimate only when the diagram is structurally incoherent without it — never decorative. List every assumption in the final reply ("assumed top-to-bottom flow; inferred gateway→auth edge") so the user can veto. When two readings are both plausible, pick the simpler one and note the alternative.

Step 1 — Design Brief (开工前完整设计)

After Step 0 classifies the requirement and BEFORE writing any code, produce a complete design proposal that combines the user's input.md with this skill's design system — state the design, then draw it. In an interactive session you may show the brief first and let the user veto; in headless/automation, print it in the reply and proceed.

The brief has five mandatory sections:

  1. Canvas & layout skeleton — canvas size, band/column/grid partition, margins, and the placement strategy as relative formulas (e.g. gap = (band_w − n·card_w) / (n + 1), y[i+1] = y[i] + h + GUTTER), not per-element hard-coded coordinates. Name what Step 0 left open (completion mode) or what the spec pinned (faithful mode).
  2. Palette — pick ONE preset scheme from references/design_specs.md (S1–S4) by information need and give the role→color mapping table: each business role gets a light tint fill PAIRED with its dark accent stroke; op cards stay white; accents ≤8; at least one chromatic accent (the ⑯ 无配色 floor). Exact hex from the spec, when given, wins over the preset. Color must OWN THE STRUCTURE, not decorate it: tint the band/container fills (band-style) or color the primary nodes (node-style) — a mostly gray/white skeleton with color confined to small chips FAILs the ⑯ 灰色主导 check (chromatic coverage <35% of elements AND <15% of area).
  3. Typography tiers — 3–4 tiers with concrete values (e.g. 20 / 14 / 12 / 10) and which text class uses each (title / section header / node label / note).
  4. Edge routing — flow directions, solid vs dashed semantics, spine/bus corridors routed OUTSIDE content areas (kiss container edges, never slice filled rects — the semantic-QA 箭头线盖在组件上 check), junction/merge points, and edge-label placement rules (off the line, perpendicular offset).
  5. Risk checklist — the pairings and budgets you expect to flirt with: text-on-fill contrast (⑮) for every tint+text pair, node spacing ≥14px, container gutter ≥20px, font-tier ratios ≥1.15×, marker ids actually used (marker 缺省陷阱).

Landing rule (常量落盘): the brief must not stay prose. It lands in TWO executable forms:

  1. a constants block at the top of gen.py (dimensions and tokens the drawing code reads), and
  2. a BRIEF = DesignBrief(...) contract object from $SKILL/design_brief.py — the machine-readable declaration the semantic-QA layer asserts the RENDERED SVG against (palette/layout/flow). The brief is the single source of truth and mutable during refine: if a contrast fix changes a tint, update BRIEF in the same round rather than silently deviating.
# --- Design Brief tokens (Step 1) — edit here, not scattered below --------
W, H       = 1240, 970                 # canvas
GUTTER     = 20                        # band spacing
INK, SUB   = "#1A1A1A", "#555555"      # text tiers 20/14/12/10
TINTS      = ["#D5E1EB", "#BBCEDF"]    # S1 layer fills (paired strokes below)
STROKES    = ["#1B3A5C", "#2563EB"]    # dark accent per tint
F_TIERS    = [20, 14, 12, 10]

from design_brief import DesignBrief, ColorSpec
BRIEF = DesignBrief(
    scheme="S1", layout="band", flow="top-down",   # layout: band|node; flow: top-down|left-right|none
    palette_role={                                  # key = data-node-id on the shape
        "api":    ColorSpec(TINTS[0], STROKES[0]),  # tinted container: fill+stroke PAIR
        "engine": ColorSpec(TINTS[1], STROKES[0]),
        "store":  ColorSpec("white",  STROKES[0]),  # plain op cards stay white
    },
    flow_chain=("api", "engine"),   # ordered pipeline stages ONLY — side
)                                   # bands / text-only bands stay out of the chain
# Render each palette key with a matching node_id= so the contract can
# attribute rendered shapes: drawer.rect(..., node_id="api", role="layer")

Contract rules (enforced by check_design_brief in semantic QA):

  • palette_role keys are data-node-id values — band layout: layer containers (role="layer"); node layout: primary nodes. One map, no duplicate layer list to drift.
  • flow_chain is the ordered pipeline (⊆ palette keys). Memory/cache side columns and text-only bands are palette members but NOT chain stages.
  • Declared tints rendered white → FAIL (structure lost its color); wrong tint/stroke or undeclared chromatic paint → WARN; ≥70% of inter-layer edges must follow the declared flow (return edges tolerated); chain first/middle/last layers need out/both/in ≥1.
  • Capability boundary: the checker verifies rendering ↔ self-declared contract consistency, not contract ↔ user intent — spec-entity coverage and human review of the brief guard the intent side.

Core Workflow: Generate-Evaluate-Correct

digraph eval_loop {
    "Generate gen.py" -> "evaluate_svg()" [label="run"];
    "evaluate_svg()" -> "score≥100 AND no [FAIL]?" [label="score"];
    "score≥100 AND no [FAIL]?" -> "Done" [label="yes"];
    "score≥100 AND no [FAIL]?" -> "auto_refine(drawer, max_iter=3)" [label="no"];
    "auto_refine(drawer, max_iter=3)" -> "score≥80?" [label="after n iterations"];
    "score≥80?" -> "Manual fix (coordinates/text)" [label="yes · ship"] ;
    "score≥80?" -> "Regenerate gen.py" [label="no · restart"];
}
  1. Content Parsing & Design Brief:

    • Identify layers, components, and flow direction.
    • Determine canvas dimensions (default 1200x800).
    • Write the Step 1 Design Brief (above) — layout skeleton, palette, tiers, edge routing, risks — BEFORE any drawing code; land its tokens as the gen.py constants block.
  2. Coding & Layout:

    • Write a Python script calling $SKILL/svg_utils.py.
    • Required: use drawer.check_collisions() to check overlaps; use the semantic API (below) to register nodes and edges so connections can be auto-validated.
  3. Quality Evaluation:

    • Call evaluate_svg(drawer) from $SKILL/evaluator.py.
    • Evaluation dimensions: ① Containment-aware element overlap detection (parent-child nesting does not count as a collision); ② boundary checks; ③ canvas coverage; ④ connection/arrow connectivity (endpoints must land on registered node borders, 12px tolerance; also detects degenerate zero-length edges and duplicate edges); ⑤ phantom anchor detection (nodes referenced by edges but invisible); ⑥ edge-routes-through-node (edges must not pass through the interior of a non-endpoint node, 3px inset); ⑦ edge crossing (two edge segments intersecting internally); ⑧ same-kind node minimum spacing (Euclidean distance between op/junction nodes ≥ 14px); ⑨ font-size tier detection (parse SVG to extract all font-size values, deduplicate to ≤4 tiers, adjacent tiers must be ≥1.15× apart — prevents accidental micro-steps like 11/12/13/14); ⑩ palette detection (accent colors ≤8 warning, ≤12 hard limit; coexistence of very dark L<0.2 and very light L>0.8 accents is flagged as a conflict; background defaults to light); ⑪ text overflow detection (parse all <text> elements' true geometry, estimate text width by font metrics: overflowing the canvas = FAIL, text wider than its container [smallest rectangle containing the text center] = WARN — closes the blind spot of text drawn via add_element that doesn't enter bboxes and is invisible to collision/boundary checks); ⑫ composition quality budget (ported from fireworks assess_composition: ≤2 bends per edge, path stretch ratio ≤1.35, container gutter ≥20px, shortest path segment ≥16px; text as a measurable obstacle — edge segments passing through a <text> bbox = FAIL, systematically eliminating "text crossed/covered by edges"; gutter is only checked for nodes fully contained within a container, cross-band nodes are not false-flagged). ⑨⑩⑪⑫ parse the actual SVG rather than relying on API calls, so they work equally well for raw add_element drawing.
    • text-vs-shape & text-vs-text overlap detection (check_text_overlaps): parses every <text> bbox (center model, dominant-baseline="central") against all visible circles/rects/polygons/lines/paths AND against other <text> — closes the registry blind spot where bbox=False text/shapes and add_element shapes are invisible to check_collisions. Legend/background shapes, the full-canvas bg rect, and rects fully containing the text (intentional in-box labels) are exempt. Like ⑨⑩⑪⑫, this parses the actual SVG rather than trusting the API.
    • same-kind peer alignment (check_alignment): two SAME-SIZED same-kind visible nodes that read as a row or column (strong overlap on the perpendicular axis) yet share NEITHER a top/bottom/left/right edge (within 5px) NOR a center line (within 15% of the shorter side) are flagged — the "align to shared edges" layout principle. Differently-sized peers are skipped (a row of varied components legitimately staggers).
    • text-on-fill contrast (check_contrast): WCAG 2 contrast ratio between each <text>'s fill and the fill of the smallest <rect> containing it — FAIL below 3:1 (large-text floor), WARN below 4.5:1 (AA for normal text) / 3:1 large (≥24px, or ≥18.5px bold). Only text on a non-neutral (accent) fill is measured; accent-colored text on a white/neutral canvas (category labels, captions) is a typographic choice, not a fill defect, and is skipped. Replaces the former "manual review recommended" placeholder.
    • chromatic palette floor & gray-dominance (check_palette): at least one accent must carry a readable hue (HSL saturation ≥0.25) — a diagram whose only "accents" are desaturated slate tones (#546E7A et al.) or none at all is effectively colorless (无配色) and FAILs. And color must own the structure, not just decorate it: when chromatic shapes cover <35% of business elements AND <15% of painted area, the diagram is gray-dominant (灰色主导) — neutral bands/containers with color confined to small chips — and also FAILs. One strong axis is a legitimate scheme: band-style diagrams ride the area axis (tinted container fills), node-style diagrams the element axis (colored primary nodes). Pastel tints (#DAE8FC) count as chromatic; slate/pure grays do not. The classic trigger for both: "fixing" a contrast WARN by de-coloring.

    3b. Semantic QA (after the geometry score): call run_semantic_qa from $SKILL/semantic_qa.py. The evaluator above checks how the picture renders; this checks what the picture means — the three defect classes a bounding-box evaluator structurally cannot see:

    • marker 缺省陷阱marker-end="url(#X)" referencing an undefined <marker id> (the classic case: arrow_head("arrow", ...) registered but a connect() call left at its default marker_end="arrowhead") → every arrowhead on that edge silently vanishes. FAIL. A defined-but-never-used marker (usually a forgotten marker_end=) is flagged as WARN.
    • FIGS 尺寸漂移 — declared canvas vs. actual content bbox: content far smaller than the canvas (mis-sized diagram), content poking outside (clipped), or a mismatch against the design-spec size passed as expected_size=(w, h).
    • 标签错位 — a centered label off its node's centre, a label floating in whitespace (not inside, near, or beneath any node — legitimate top-band titles, branch labels beside edges, and cluster captions are exempt), or a business node box with no label at all.
    • 箭头线盖在组件上 (rail-slices-container / connector-through-card) — parsed straight from the rendered geometry, role-blind to the registry: a raw-line() bus rail that slices through filled band containers (the right-spine-at-x≈776-inside-the-band trap), or a connector crossing a business card's interior. The registry evaluator is structurally blind to both (rails are never registered as edges; role='layer' containers are never registered as nodes).
    • 文本语义 (check_text_semantics, pass spec_text=input.md) — placeholder/garbled/empty <text> → FAIL; spec component identifiers (bold/backtick identifiers like AgentEvent, server_queue) missing from the diagram: coverage <40% → FAIL (regenerate — whole components lost), 40–85% → WARN (paraphrase advisory fed back into refine rounds).
    from semantic_qa import run_semantic_qa
    
    score, report = evaluate_svg(drawer)          # geometry first
    spec = Path("input.md").read_text() if Path("input.md").exists() else None
    qa = run_semantic_qa(drawer, expected_size=(1240, 970), spec_text=spec,
                         brief=BRIEF)             # + the Step-1 contract
    for line in qa.report():
        print(line)
    # qa.has_fail → semantic defect (dangling marker ref, rail over a
    # component, lost spec entities, brief-contract violation): fix before export
    # brief omitted → brief-absent WARN: declaring the contract is not optional
    
  4. Auto-Correction:

    • If the evaluation score is below 80, analyze the [FAIL] items in the report.
    • Connection issues (dangles / Degenerate edge / overlaps): use drawer.connect(...) to let endpoints auto-snap to node borders; avoid manually computing offset coordinates.
    • phantom (phantom anchors): the node referenced by an edge is invisible → give it a real fill/stroke, or use the distinct-port pattern to connect to a visible junction.
    • routes through node: an edge cuts through an intermediate node → reroute via orthogonal bypass channels, or relay through a junction (see distinct-port pattern), keeping a ≥20px gap from the intermediate node.
    • cross (edge crossings): adjust node layout or routing channels so edges don't intersect (reference fireworks' zero-crossing budget).
    • too close: same-kind nodes are clustered → increase spacing or enlarge the canvas.
    • Arrow position: connect() auto-retracts by marker_tip_depth — retraction = (markerWidth − refX) × stroke_width, derived from the marker dimensions registered by arrow_head(), so the arrow tip lands exactly on the target border (neither poking in nor leaving a gap). For custom markers, pass the real dimensions via arrow_head(id, color, marker_width=, ref_x=) — no manual tweaking needed.
    • font (font sizes): more than 4 distinct tiers after dedup → converge to 3-4 tiers (title/body/note); near-overlapping tiers (ratio <1.15, e.g. 11/12) → merge into one tier. Recommended modular scale: 20 / 14 / 12 / 10 (all steps ≥1.15). This matches the tier count measured in each ink-graph style.
    • palette: accent count >8 → trim toward a preset scheme (S1–S4) — consolidate near-hue accents, drop redundant category colors; >12 → same, harder. no chromatic accent (无配色, FAIL) → restore tinted layer fills + accent strokes from a preset scheme. gray-dominant (灰色主导, FAIL) → color is marginal: tint the band/container fills (band-style) or color the primary nodes (node-style) so the scheme owns the skeleton — do not merely enlarge a legend/chip. Never satisfy a palette or contrast finding by reverting the whole diagram to neutral — that trades a WARN for a colorless or gray-dominant diagram, which now FAILs. Luminance conflict (very dark + very light coexist) → unify into one brightness family. Non-light background → apply white by default; dark themes must declare set_background()/bg=. See references/design_specs.md for the 4 preset schemes (S1–S4) and when to use each.
    • Layout issues: adjust component coordinates, spacing, or scale ratio, then regenerate.
    • text overflow: text exceeds the canvas → shorten the copy or shift the start point left; text wider than its card/container → shorten, auto-wrap by container width (greedy word-wrap), or widen the container. Note that <text> does not enter bboxes by default, so collision/boundary checks can't see it — this detection fills that gap.
    • text overlap (text on a shape or another text): a label sits on top of a circle/triangle/arc/line or collides with a neighboring label → move the label clear of the shape (place it above/below the icon, not on it) or shorten it. auto_refine cannot fix this (no geometry handle for raw add_element text) — adjust coordinates manually. This catches overlaps check_collisions misses because bbox=False text/shapes and add_element shapes bypass the collision registry.
    • contrast (low text-on-fill contrast): a label doesn't read against its accent card (ratio <3:1 FAIL, <4.5:1 WARN for normal text) → darken/lighten the text fill toward the channel extreme (pure #000000/#ffffff on a mid-tone card is always safe), or switch the card to a lighter tint of the same hue so a dark label clears AA. De-coloring the card to white/gray is NOT a fix — it silences this check by making the diagram colorless, which the ⑯ chromatic floor then FAILs; always keep a tint fill paired with its dark accent stroke. Note: accent-colored text on a neutral canvas is a deliberate category/heading choice and is not flagged — only labels on accent fills are. auto_refine does not touch colors; adjust manually.
    • alignment (misaligned same-size peers): two same-sized same-kind nodes in a row/column share no edge/center line → nudge one onto the other's top/bottom (row) or left/right (column) edge, or onto a shared center line. Differently-sized peers are exempt (they legitimately stagger). auto_refine does not handle alignment yet — adjust coordinates manually.
    • composition gutter (insufficient container margin): node too close to the container edge → push the node toward the container center; or call auto_refine(drawer) (below) to iteratively auto-correct.
    • auto_refine(drawer, target_score=100, max_iter=3): reads the evaluate_svg report and auto-corrects programmable issue categories (gutter → nudge node toward container center; too close → spread along the primary axis), looping until the target is met or iterations are exhausted. Returns (score, report, fixes). Complex fixes (dangles/cross/route-through) still need manual intervention — auto_refine only handles geometric micro-adjustments.

Node & Edge Semantics

For the evaluator to "see" connections, drawing code must register connectable rectangles as nodes and connections as edges:

  • Register nodes: drawer.rect(..., node_id="op1", node_kind="op") — draws a rectangle and registers a node simultaneously. node_kind can be "op" | "layer" | "block" | "region"; used for provenance only, not for validation.
  • Circle nodes (junctions/markers): drawer.circle(cx, cy, r, ..., node_id="jn", node_kind="junction") — draws a visible circle and registers a square Node of side 2r as a snap anchor; endpoints landing at the center register distance 0. Ideal for bus junctions and port markers.
  • Register edges: prefer drawer.connect(from_id, from_side, to_id, to_side, ...) — endpoints are taken from node border midpoints ("top"|"bottom"|"left"|"right"), so arrows always land precisely on the node edge. When several edges share one node side, their endpoints fan out symmetrically along that border (deterministic same-port spread, ≤14px steps, skipped on sides shorter than 32px) instead of stacking into one line — no manual offset juggling. Options: dashed=True (dashed, e.g. lowering/bypass flows), as_curve=True + curve_dir="left"|"right" (curve), edge_label= (annotation).
  • Dashed rendering: dashed= is available on all primitives — rect, circle, line, path, and connect. Pass dashed=True for the standard "6,3" pattern, or dashed="4,3" for a custom dash pattern. This replaces the old extra='stroke-dasharray="..."' spelling (which still works for backward compatibility).
  • Low-level entry: drawer.line(..., register_edge=True) / drawer.path(..., register_edge=True, start=..., end=...) can also manually register edges (for curves, start/end are the semantic endpoints; d can be any path).
  • group(transform) context: nodes/edges/bboxes drawn inside with drawer.group("translate(100,50) rotate(30)"): are registered in absolute coordinates via the accumulated affine matrix, so local coordinates inside a group are also validated. Supports chained matrix()/translate()/scale()/rotate()/skewX()/skewY().
  • Advanced shapes (ported from ink-graph shapes.md, local coordinates via <g transform>): drawer.database(x,y,w,h,...) (cylinder, top ellipse depth=min(8,h0.12)), drawer.decision(...) (diamond, four points around center), drawer.hexagon(...) (gateway, 25% corner insets), drawer.component(...) (with left-edge double tabs), drawer.cloud(...) (multi-lobe cubic curves). All accept node_id/role/label, register nodes consistently with rect()/circle(), and support connection snapping. Text centering uses dominant-baseline="central" (exact, replacing the old y+0.35fs approximation).
  • Semantic role role= (optional): rect/circle/connect/line/path all accept role="node|edge|decoration|legend|background|layer". Elements set to decoration/legend/background emit a data-graph-role attribute and are excluded from business checks (spacing, collision, palette count) — used for decorative layers (rail casings, background textures, legends). role="layer" marks tinted band containers: they emit data-graph-role="layer" and are the primary signal for band detection in the design-brief layout check (pair each with a node_id= that matches a palette_role key). Default node/edge means business elements.
  • Math formulas with sub/superscripts: drawer.formula(x, y, markup, font_size=, fill=, anchor=, weight=) renders genuine <tspan> baseline shifts — unlike text() (which HTML-escapes content and can only show literal underscores/carets). Markup: _{...} → subscript, ^{...} → superscript; baseline auto-resets between tokens so multiple indices align (e.g. "F_{k} = MS^{↑}_{k} + g_{k}"). Default monospace family + bold for an equation look; pass weight="normal" for inline annotations. The sub/superscript glyph sizes (~0.72×) are derivative of the parent text size and are excluded from the font-tier count (see check_font_scale), so formulas do not inflate the 3–4-tier typography budget. (Note: svg2pptx concatenates <tspan> text flat — use image mode if you need exact subscript fidelity in PowerPoint.)

"Invisible anchor" anti-pattern (now forcefully blocked): it used to be possible to create fill="none" stroke="none" invisible rectangles to cheat the connection validation — the evaluator would pass, but the human eye would see dangling lines. Now check_phantom_anchors() detects any node referenced by an edge that is invisible (fill=none ∧ stroke=none/opacity=0/zero-size) and flags it as FAIL. When you need a "bus rail" or cross-layer channel, use the distinct-port pattern below to connect to a visible junction.

Distinct-port / junction pattern (cross-layer aggregation, side channels): place visible circular junction nodes at the channel position, connect each real component to the junction with a short solid line connect(layer, "left", junction, "right"), then chain them into a rail with dashed lines connect(junction, "bottom", junction2, "top", dashed=True). This way each rail segment lands between real visible nodes — passing validation while remaining clear to humans.

Tip: container-type large rectangles (Module/Layer) enter bbox collision and coverage stats by default; for interior small elements (text, operation nodes), pass bbox=False to avoid false overlap reports or inflated coverage.

Design Specifications

  • Font-size tiers: use only 3-4 tiers per diagram (title 20 / section header 14 / body 12 / note 10), adjacent tiers ≥1.15× apart (modular type scale). Exceeding this triggers an evaluator warning.
  • Palette: pick by information need (see references/design_specs.md and the per-type table in references/diagram_types.md) — S2 Categorical when 2–4 classes / branches / roles need hue, S3 Semantic for fixed component types (cloud / network), S4 Duotone for one focal element, S1 Monochrome Blue only as the fallback for pure layering with no categorical role. Do not reflex-default to S1 — it makes every diagram blue. All schemes pre-verified (accent ≤12, no luminance clash). Op cards stay fill="white"; color lives in layer fills + borders — and it must own the structure: at least one chromatic accent (无配色 floor) AND enough chromatic weight that the skeleton doesn't read gray (灰色主导: <35% of elements AND <15% of painted area FAILs; tint the containers or color the primary nodes). Background defaults to white (SVGDrawer(bg="#FFFFFF")); only use set_background() for dark themes.
  • Color & typography: see references/design_specs.md.
  • Shapes & layout per type: when the user names a diagram type (architecture / flowchart / ML model / ER / sequence / swimlane / network), apply the matching preset in references/diagram_types.md — it maps each semantic role to a primitive (rect / database / decision / hexagon / component / cloud) and gives direction + spacing defaults that pass the evaluator.
  • Stability: prefer relative layout logic (i.e. compute new component positions based on known component coordinates).
  • Entity escaping: SVG is strict XML — never use HTML entities in text (&middot;/&mdash; etc. are rejected by the parser). Use Unicode characters (· ) or html.escape instead.

Example: Generation with Evaluation

import sys
# Resolve the skill scripts dir relative to this file (see $SKILL note above).
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..", "scripts"))
from svg_utils import SVGDrawer, save_svg
from evaluator import evaluate_svg
from pathlib import Path
# Write outputs next to this script (see "Output Layout Convention" below).
OUT = Path(__file__).resolve().parent

drawer = SVGDrawer(1200, 800, bg="#FFFFFF")   # white background is the default; change only for dark themes
drawer.arrow_head("arrowhead", "#333")

# Nodes (Scheme S1 Monochrome Blue — L1 tier; see design_specs.md for the full library)
drawer.rect(100, 100, 90, 34, fill="#D5E1EB", stroke="#1B3A5C",
            node_id="a", node_kind="op", bbox=False)
drawer.rect(300, 100, 90, 34, fill="#D5E1EB", stroke="#1B3A5C",
            node_id="b", node_kind="op", bbox=False)

# Edges: endpoints auto-snap to node borders, arrows land precisely on the edge
drawer.connect("a", "right", "b", "left",
               stroke="#1B3A5C", marker_end="arrowhead", edge_label="value")

# Evaluation (includes connection/arrow validation)
score, report = evaluate_svg(drawer)
print(f"Quality Score: {score}")
for line in report:
    print(line)

if score >= 80:
    save_svg(drawer.render(), str(OUT / "diagram.svg"))
else:
    print("Score too low, need adjustment.")

Output Layout Convention

Every diagram generation is self-contained in its own subdirectory under output/. One diagram = one directory; the generator script and its SVG/PNG/PPTX triplet live together.

output/<timestamp>_<name>/
- gen_<name>.py        # generator script (version-controlled source)
- <name>.svg           # -
- <name>.png           #  |- triplet, regenerated in place on each run
- <name>.pptx          # -

Rules:

  • Script and outputs are co-located. The gen_*.py lives inside its output/<ts>_<name>/ directory — never in the repo root, never scattered away from its artifacts. Deleting the directory removes script + outputs together.
  • Write to the script's own directory, not a fresh timestamped dir per run — re-running refreshes the triplet in place rather than accumulating duplicate directories:
    from pathlib import Path
    OUT = Path(__file__).resolve().parent   # this script's own directory
    NAME = "<name>"
    
  • The <timestamp>_<name> directory name is frozen at creation (a born-on date); it is not regenerated on each run.
  • Always emit the full quartet — SVG (save_svg), PNG (rasterize_svg, wraps rsvg-convert), PPTX (svg2pptx.svg_to_pptx), and brief.json (BRIEF.write(...), the declared design contract) — so the directory is self-describing.
  • Artifacts are gitignored by extension (**/*.svg, **/*.png, **/*.pptx); the gen_*.py scripts stay version-controlled. Never gitignore the whole output directory — that hides the scripts.

The save/rasterize helpers

save_svg(), rasterize_svg(), and svg2pptx.svg_to_pptx() write wherever you ask — they create parent directories as needed and impose no layout constraint. A prior version enforced an output/<task>/ directory at the library boundary (validate_output_path / OutputPathError); that was removed for the public release because it refused legitimate cross-project and temporary paths.

from svg_utils import save_svg, rasterize_svg

save_svg(content, OUT / "diagram.svg")      # writes, mkdir -p the parent
rasterize_svg(OUT / "diagram.svg", OUT / "diagram.png", width=1200)
  • save_svg(content, filename) — writes SVG content to filename, creating parents; returns the resolved path.
  • rasterize_svg(svg_path, png_path, width) — runs rsvg-convert -w <width>; creates parents; returns the PNG path.
  • svg2pptx.svg_to_pptx(svg, pptx_path, config=None) — converts to PPTX; creates parents.

Prefer these wrappers over a raw subprocess.run(["rsvg-convert", ...]) so path handling stays uniform.

SVG to PPTX Export (svg2pptx)

After generating an SVG, you can export it to an editable PowerPoint file with one call. The module $SKILL/svg2pptx.py parses SVG elements into native PowerPoint shapes (rectangles, ovals, connectors, text boxes, freeforms) rather than embedding an image — so each element can be individually resized, recolored, and edited in PowerPoint/Keynote/LibreOffice. Inspired by the svg2pptx project.

Two Export Modes

Mode Parameter Effect Use Case
shapes (default) mode="shapes" Each element → an independent editable shape Architecture diagrams you want to fine-tune in PPT
image mode="image" Rasterize to PNG and embed (100% visual fidelity) Complex SVGs for display only, no editing needed

API

# sys.path was set above in the Example section ($SKILL = scripts directory)
from svg2pptx import svg_to_pptx, PptxConfig, save_pptx

# Option 1: SVG string → PPTX (most common, takes drawer.render())
svg_to_pptx(drawer.render(), OUT / "diagram.pptx")

# Option 2: SVG file → PPTX (file must end with .svg, otherwise parsed as an SVG string)
svg_to_pptx(OUT / "diagram.svg", OUT / "diagram.pptx")

# Option 3: Export directly from an SVGDrawer (equivalent to Option 1)
save_pptx(drawer, OUT / "diagram.pptx")

# Custom config: 16:9 slide, 2x scale, shapes mode
svg_to_pptx(OUT / "diagram.svg", OUT / "diagram.pptx",
            config=PptxConfig(slide_w=13.333, slide_h=7.5, scale=2.0))

# Image mode (rasterized embed, requires rsvg-convert)
svg_to_pptx(OUT / "diagram.svg", OUT / "diagram.pptx", config=PptxConfig(mode="image"))

# Add to an existing presentation's slide (no new file created)
# Note: add_svg_to_slide only supports shapes mode, not image rasterization
from svg2pptx import add_svg_to_slide
add_svg_to_slide(drawer.render(), slide, x=1.0, y=0.5, scale=0.8)

SVG → PPTX Element Mapping

SVG Element PPTX Shape Notes
<rect rx=0> Rectangle Auto shape; rotates correctly inside a rotated <g>
<rect rx>0> Rounded Rectangle Corner radius auto-mapped; rotation also handled
<circle> / <ellipse> Oval Ellipse/circle; rotation handled correctly
<line> Connector (Straight) Straight-line connector
<polygon> Freeform (closed) Polygon → freeform
<polyline> Freeform (open) Polyline → freeform
<path> Freeform Bezier/Arc auto-flattened to line segments (curve_tolerance controls precision)
<text> / <tspan> Text Box Preserves font/size/color/alignment, CJK works; <tspan> child text is auto-concatenated
<g transform> Coordinate transform Accumulated affine matrix (translate/scale/rotate) applied to all child shapes
marker-end Freeform triangle Arrow auto-rendered: draws a triangle at the segment endpoint based on marker geometry
fill-opacity Transparency Implemented via <a:alpha> XML injection (python-pptx does not support this natively)
stroke-dasharray Dashed line prstDash or custDash XML injection

Limitations

  • Gradients are not supported (the first color is used).
  • Filters/effects (blur, shadow) are not supported (shape shadows are disabled by default).
  • Bezier curves are flattened to line segments (lower curve_tolerance = smoother, default 1.0px).
  • Font scaling: font size scales proportionally with the fit-to-slide scale (Pt(fs * scale * 72/96)) — i.e. a large canvas mapped to a small slide shrinks text, and vice versa. The text-to-box ratio always stays consistent. To fix the font size, set scale=1.0 and adjust slide_w/slide_h yourself.
  • add_svg_to_slide only supports shapes mode (no image rasterization); for image mode use svg_to_pptx.
  • Image mode requires rsvg-convert (installed on this system); shapes mode only requires python-pptx.

Capabilities

All detection/export capabilities parse the actually-rendered SVG (evaluate, don't assert), so they work equally well for raw add_element drawing.

# Capability Description
Containment-aware collision Parent-child nesting does not count as a collision
Phantom anchor detection Nodes referenced by edges but invisible → FAIL
Edge × edge crossing Two edge segments intersecting internally
Marker depth auto-derivation (markerWidth−refX)×stroke
Font-size tier detection Parses SVG, ≤4 tiers, adjacent tiers ≥1.15× apart
Palette detection Accent ≤8/12, ≥1 chromatic (无配色 floor), and color owns the structure (灰色主导: FAIL when chromatic coverage <35% of elements AND <15% of painted area — one strong axis suffices); background defaults to light
Curve bezier/arc sampling Ported from fireworks path_routes
Luminance conflict by channel Fill/stroke checked separately for dark+light coexistence
Transform matrix accumulation group() context
data-graph-role semantic roles decoration/legend/background skip business checks
Coverage bbox union Sweep-line algorithm
Text overflow detection Parses <text> geometry
Composition quality budget bend≤2/stretch≤1.35/gutter≥20/segment≥16 + text as obstacle
Node shape library database/decision/hexagon/component/cloud
Barycenter crossing minimization Ported from DiagramForge: reorder by barycenter within layers
auto_refine auto-correction Reads eval report, iteratively fixes gutter/spacing by issue code
SVG→PPTX export Native editable shapes + rasterized image dual modes, arrow rendering, Bezier/Arc flattening, transparency/dash injection
Text-vs-shape & text-vs-text overlap Parses rendered SVG: <text> bbox vs visible circles/rects/polygons/lines/paths + text vs text; closes the bbox-registry blind spot (bbox=False/add_element)
Formula rendering (sub/superscript) drawer.formula() emits real <tspan> baseline shifts for _{}/^{} markup; evaluator strips markup in width estimates and counts only <text>-tier font sizes so subscripts don't inflate the tier budget
Text-on-fill contrast (WCAG 2) check_contrast: ratio of each <text> fill vs its smallest containing <rect> fill — FAIL <3:1, WARN <4.5:1 (AA) / 3:1 large (≥24px / ≥18.5px bold); only accent fills measured, accent text on neutral canvas skipped
Same-kind peer alignment check_alignment: same-sized same-kind nodes in a row/column must share a top/bottom/left/right edge (±5px) or a center line (±15%); differently-sized peers exempt
Semantic QA (semantic_qa.py) Meaning-level smoke check after scoring: dangling marker refs (marker 缺省陷阱, FAIL), defined-but-unused markers (WARN), declared-vs-actual canvas size drift (FIGS 尺寸漂移), label/host mismatch (标签错位), raw rails slicing filled containers or cards (箭头线盖在组件上), text semantics vs spec (placeholder/garbled/empty FAIL; spec-entity coverage <40% FAIL / <85% WARN) — parses the rendered SVG incl. grouped shapes, composite arcs, and stroke widths
Design-brief contract (design_brief.py + check_design_brief) Step-1 declared intent as data: DesignBrief(scheme, layout band|node, flow top-down|left-right|none, palette_role {data-node-id: (fill,stroke)}, flow_chain). The rendered SVG is asserted against it — declared tint gone white FAIL, wrong/undeclared paint WARN, empty declared band FAIL, side-band-in-chain chain-broken FAIL, ≥70% inter-layer flow dominance (return edges tolerated), chain degree rules, declared order vs geometry. Absent brief → visible WARN. Capability boundary: verifies rendering ↔ self-declared contract, not contract ↔ user intent

References & Acknowledgments

This Skill's geometry/connection detection draws on the following open-source projects (their references and validator implementations were actually studied):

  • ink-graph (qaz1230sp/ink-graph): its references/pitfalls.md #2 (arrow occluded by node → retract endpoint 8px), #3/#10 (edge crossing through node → 20px gap bypass), #17 (fan-out alignment), #26 (marker size proportional to stroke), #29 (fan-out/fan-in + junction dot); its references/shapes.md dominant-baseline="central" centering, its references/layout-rules.md grid/spacing rules. Measured each of its style-*.md at exactly 3-4 font tiers, 4-13 palette colors — empirical basis for the font-tier/palette thresholds. (These files live in the ink-graph repo, not this skill.)
  • fireworks-tech-graph (yizhiyanhua-ai/fireworks-tech-graph): its references/composition-quality-contract.md (executable budget: zero crossings/≤2 bends/≥40px node spacing/≥20px container gutter); its scripts/validate_svg.py find_collisions + segment_hits_bounds (path sampling vs node bbox), data-graph-role semantic roles, transform matrix accumulation, "evaluate, don't assert" (parse actual SVG rather than trusting API calls). (Files live in the fireworks repo, not this skill.)
  • svg-animations (supermemoryai/skills): SMIL/CSS animation basics and stroke-dasharray stroke animation recipes (this Skill does not enable animation yet, reserved for later).
  • svg-design (tryopendata/skills): primitive-first (circles use <circle>), stroke-linecap="round", strict XML with no HTML entities, and other hygiene conventions.
  • svg2pptx (benouinirachid/svg2pptx): architectural blueprint for the PPTX export module. Its "SVG element → PowerPoint native editable shape" philosophy (rect→rectangle, circle→oval, line→connector, path→freeform, text→textbox), Config dataclass design, build_freeform + add_line_segments usage, and Bezier flattening tolerance parameter were all adapted into the self-contained scripts/svg2pptx.py module (which adds arrow marker rendering, fill-opacity transparency injection, stroke-dasharray dash injection, and an image rasterization fallback mode).
Files (architecture-drawer)
  • evals
    • 20260728_120000_mlir_pipeline
      • brief.json 705 B
        {
          "scheme": "S2",
          "layout": "band",
          "flow": "top-down",
          "flow_chain": [],
          "palette_role": {
            "graph_opt": {
              "fill": "#fcfcfc",
              "stroke": "#9673a6"
            },
            "runtime_sched": {
              "fill": "#fcfcfc",
              "stroke": "#2e5aac"
            },
            "hw_concurrency": {
              "fill": "#fcfcfc",
              "stroke": "#b45f06"
            },
            "mem_pool": {
              "fill": "#fcfcfc",
              "stroke": "#d6b656"
            },
            "sched": {
              "fill": "#dae8fc",
              "stroke": "#2e5aac"
            },
            "s0": {
              "fill": "#dae8fc",
              "stroke": "#2e5aac"
            },
            "s1": {
              "fill": "#ffcc99",
              "stroke": "#b45f06"
            },
            "s2": {
              "fill": "#d5e8d4",
              "stroke": "#82b366"
            }
          }
        }
      • gen.py 16.4 KB
        # -*- coding: utf-8 -*-
        """MLIR AI Compiler · Multi-Stream Execution Pipeline (4-layer matrix diagram)."""
        import sys, math, html
        from pathlib import Path
        
        import os, sys
        _HERE = os.path.dirname(os.path.abspath(__file__))
        _SKILL = os.path.normpath(os.path.join(_HERE, "..", "..", "scripts"))
        if _SKILL not in sys.path:
            sys.path.insert(0, _SKILL)
        from svg_utils import SVGDrawer, save_svg, rasterize_svg, BBox
        from evaluator import evaluate_svg
        from design_brief import DesignBrief, ColorSpec
        from semantic_qa import run_semantic_qa
        
        # Script co-located with its SVG/PNG/PPTX in this dir (output/<ts>_<name>/).
        NAME = "mlir_pipeline"
        OUT = Path(__file__).resolve().parent
        
        W, H = 1440, 1200
        d = SVGDrawer(W, H, bg="#F7F7F7")
        esc = html.escape
        
        # ---- palette: exactly 8 accents (5 dark strokes + 3 light fills) ----
        PURPLE = "#9673A6"; BLUE = "#2E5AAC"; ORANGE = "#B45F06"
        GREEN = "#82B366"; YELLOW = "#D6B656"
        ORANGE_F = "#FFCC99"; GREEN_F = "#D5E8D4"; BLUE_F = "#DAE8FC"
        GRAY_S = "#888888"; GRAY_D = "#555555"; GRAY_L = "#BBBBBB"
        
        def txt(x, y, s, sz=12, fill="#222222", anchor="middle", weight="normal"):
            d.add_element(
                f'<text x="{x}" y="{y}" font-family="Arial, sans-serif" font-size="{sz}" '
                f'fill="{fill}" text-anchor="{anchor}" font-weight="{weight}">{esc(s)}</text>')
        
        def rrect(x, y, w, h, fill, stroke, sw=1.5, rx=8, ry=8, extra="", role=None, bbox=False):
            ra = f' data-graph-role="{role}"' if role else ""
            d.add_element(
                f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{rx}" ry="{ry}" '
                f'fill="{fill}" stroke="{stroke}" stroke-width="{sw}"{ra} {extra}/>',
                BBox(x, y, w, h) if bbox else None)
        
        def _tw(s, fs, bold=False):
            # text width estimate, matching evaluator's metric (0.55 regular / 0.62 bold, CJK x1)
            coef = 0.62 if bold else 0.55
            return sum(fs * (1.0 if ord(c) > 0x2E80 else coef) for c in s)
        
        def card(x, y, w, title, body, stroke, h=None, pad=12, body_fs=10, title_fs=12):
            """White legend card; body auto-wraps to fit width, height auto-grows."""
            inner = w - 2 * pad
            words = body.split(' ')
            lines, cur = [], ''
            for wd in words:
                trial = wd if not cur else cur + ' ' + wd
                if _tw(trial, body_fs) <= inner or not cur:
                    cur = trial
                else:
                    lines.append(cur); cur = wd
            if cur:
                lines.append(cur)
            title_h = title_fs + 8
            line_h = body_fs * 1.35
            total = pad + title_h + len(lines) * line_h + pad
            if h:
                total = max(total, h)
            rrect(x, y, w, total, "#FFFFFF", stroke, 1.4, 8, role="legend")
            txt(x + pad, y + pad + title_fs - 1, title, title_fs, stroke, "start", "bold")
            for i, ln in enumerate(lines):
                txt(x + pad, y + pad + title_h + i * line_h + body_fs - 1, ln, body_fs, GRAY_D, "start")
            return total
        
        # ---- arrow markers (colors reused from the 8-accent set + neutral) ----
        d.arrow_head("ah", GRAY_S, 10, 7, 9, 3.5)
        d.arrow_head("ap", PURPLE)
        d.arrow_head("ab", BLUE)
        d.arrow_head("ao", ORANGE)
        d.arrow_head("ag", GREEN)
        
        # =================== TITLE ===================
        txt(W / 2, 34, "MLIR AI Compiler · Multi-Stream Execution Pipeline", 20, "#1a1a1a", "middle", "bold")
        txt(W / 2, 54, "Static Graph Optimization → Runtime Scheduling → Hardware Concurrency → Memory Reuse",
            12, GRAY_D, "middle")
        
        # =================== BAND 1 : GRAPH OPTIMIZATION ===================
        rrect(50, 64, 1340, 266, "#FCFCFC", PURPLE, 2, 12,
              extra='data-node-id="graph_opt"', role="layer")
        txt(72, 92, "Graph Optimization — Compile-time · Static DAG & Fusion", 14, PURPLE, "start", "bold")
        txt(72, 110, "Operator fusion · algebraic reordering · parallel-branch detection", 12, GRAY_D, "start")
        
        # fusion dashed box
        rrect(76, 140, 482, 110, "none", PURPLE, 1.5, 8, extra='stroke-dasharray="7,4" ', role="decoration")
        txt(86, 158, "Vertical Fusion · 3 Kernels → 1", 12, PURPLE, "start", "bold")
        
        # DAG op nodes (white fill, colored stroke)
        def opnode(nid, x, y, w, h, top, sub, stroke):
            d.rect(x, y, w, h, rx=6, ry=6, fill="#FFFFFF", stroke=stroke, stroke_width=1.6,
                   node_id=nid, node_kind="op", bbox=True)
            txt(x + w / 2, y + 20, top, 12, "#222222", "middle", "bold")
            txt(x + w / 2, y + 36, sub, 10, GRAY_D, "middle")
        
        opnode("opA", 96, 178, 100, 52, "A · Conv", "Conv2D", PURPLE)
        opnode("opB", 246, 178, 100, 52, "B · BN", "BatchNorm", PURPLE)
        opnode("opC", 396, 178, 100, 52, "C · ReLU", "ReLU", PURPLE)
        opnode("opD", 612, 178, 100, 52, "D · Add", "elementwise", BLUE)
        opnode("opPar", 558, 268, 130, 40, "Parallel Branch", "no data dep", BLUE)
        
        # chain edges
        d.connect("opA", "right", "opB", "left", stroke=PURPLE, stroke_width=1.6, marker_end="ap")
        d.connect("opB", "right", "opC", "left", stroke=PURPLE, stroke_width=1.6, marker_end="ap")
        d.connect("opC", "right", "opD", "left", stroke=GRAY_S, stroke_width=1.6, marker_end="ah")
        # parallel branch feeds D's second input (dashed)
        d.connect("opPar", "top", "opD", "bottom", stroke=BLUE, stroke_width=1.5, marker_end="ab",
                  dashed=True)
        txt(690, 262, "③ Stream Concurrency", 10, BLUE, "start", "bold")
        
        # reorder swap indicator (decorative curved double-arrow above C-D)
        d.add_element(
            f'<path d="M446,168 C500,140 612,140 662,168" fill="none" stroke="{PURPLE}" '
            f'stroke-width="1.4" stroke-dasharray="5,3" marker-end="url(#ap)" '
            f'data-graph-role="decoration"/>')
        txt(530, 132, "② Reorder D↔C (commutativity)", 10, PURPLE, "middle", "bold")
        
        # right annotation cards (legend)
        h1 = card(870, 140, 250, "① Vertical Fusion",
             "Conv+BN+ReLU → 1 fused kernel; ↓ global mem R/W (cf. FlashAttention, Fused GEMV)", PURPLE)
        h2 = card(1140, 140, 240, "② Reordering",
             "Algebraic simplification / commutativity → eliminate intermediate buffer storage", BLUE)
        card(870, 140 + max(h1, h2) + 14, 510, "③ Parallel Branch Detection",
             "Data-independent branches tagged as stream-concurrency candidates → overlapped at runtime", GREEN)
        
        # fused-kernel output badge (under the fusion box, away from the opPar→opD edge)
        rrect(200, 258, 160, 42, ORANGE_F, ORANGE, 1.4, 6, role="legend")
        txt(280, 277, "Fused Kernel K1", 12, ORANGE, "middle", "bold")
        txt(280, 292, "Conv + BN + ReLU", 10, GRAY_D, "middle")
        d.add_element(
            f'<line x1="290" y1="250" x2="290" y2="257" stroke="{ORANGE}" stroke-width="1.4" '
            f'marker-end="url(#ao)" data-graph-role="decoration"/>')
        txt(298, 248, "fuses", 10, ORANGE, "start")
        
        # =================== BAND 2 : RUNTIME SCHEDULING ===================
        rrect(50, 340, 1340, 235, "#FCFCFC", BLUE, 2, 12,
              extra='data-node-id="runtime_sched"', role="layer")
        txt(72, 368, "Runtime Scheduling — Async Launch · Priority · Load Balance", 14, BLUE, "start", "bold")
        txt(72, 386, "Host launch / device-exec split · work stealing for dynamic shapes", 12, GRAY_D, "start")
        
        # scheduler
        d.rect(82, 408, 196, 116, rx=10, ry=10, fill=BLUE_F, stroke=BLUE, stroke_width=1.6,
               node_id="sched", node_kind="layer", bbox=True)
        txt(180, 436, "Task Scheduler", 14, BLUE, "middle", "bold")
        txt(180, 456, "Host (CPU)", 12, "#333333", "middle")
        txt(180, 474, "Async Launch", 10, GRAY_D, "middle")
        txt(180, 490, "dependency resolve", 10, GRAY_D, "middle")
        txt(180, 506, "graph capture", 10, GRAY_D, "middle")
        
        # streams
        streams = [("s0", 420, "Stream 0 · Comm", "high priority", BLUE),
                   ("s1", 478, "Stream 1 · Compute", "kernel queue", ORANGE),
                   ("s2", 536, "Stream 2 · D2D Copy", "low priority", GREEN)]
        for nid, y, lbl, sub, st in streams:
            fl = {BLUE: BLUE_F, ORANGE: ORANGE_F, GREEN: GREEN_F}[st]
            d.rect(360, y, 214, 42, rx=6, ry=6, fill=fl, stroke=st, stroke_width=1.5,
                   node_id=nid, node_kind="op", bbox=True)
            txt(467, y + 18, lbl, 12, "#222222", "middle", "bold")
            txt(467, y + 33, sub, 10, GRAY_D, "middle")
            d.connect("sched", "right", nid, "left", stroke=BLUE, stroke_width=1.4, marker_end="ab")
        
        # queued task chips (decorative)
        chip_sets = [(420, ["T", "T", "T"], BLUE),
                     (478, ["K1", "K2", "K3"], ORANGE),
                     (536, ["cp", "cp", "cp"], GREEN)]
        for y, labs, st in chip_sets:
            for i, lb in enumerate(labs):
                cx = 600 + i * 42
                rrect(cx, y + 9, 36, 24, "#FFFFFF", st, 1.1, 4, role="legend")
                txt(cx + 18, y + 25, lb, 10, st, "middle", "bold")
        
        txt(745, 470, "device-side queues", 10, GRAY_D, "start")
        d.add_element(
            f'<path d="M738,446 C770,446 770,500 738,500" fill="none" stroke="{GRAY_S}" '
            f'stroke-width="1.2" stroke-dasharray="4,3" data-graph-role="decoration"/>')
        
        h1 = card(870, 398, 510, "Async Launch · Priority Preemption · Work Stealing",
             "Host enqueues kernels non-blocking; high-prio comm stream preempts; idle SMs steal tasks under dynamic shapes",
             BLUE)
        card(870, 398 + h1 + 12, 510, "Back-pressure & Amortization",
             "Device queues throttle host when saturated; CUDA-graph capture amortizes per-kernel launch overhead", GRAY_S)
        
        # =================== BAND 3 : HARDWARE CONCURRENCY ===================
        rrect(50, 585, 1340, 340, "#FCFCFC", ORANGE, 2, 12,
              extra='data-node-id="hw_concurrency"', role="layer")
        txt(72, 613, "Hardware Concurrency — Multi-Stream Overlap · SM Partition", 14, ORANGE, "start", "bold")
        txt(72, 631, "GPU SM array · timeline swimlanes · MPS / MIG spatial multiplexing", 12, GRAY_D, "start")
        
        # GPU chip
        rrect(70, 658, 214, 244, "#F2F2F2", GRAY_S, 1.4, 10, role="decoration", bbox=True)
        txt(177, 681, "GPU · SM Array", 12, "#333333", "middle", "bold")
        sm_x0, sm_y0, cell, sm = 88, 696, 42, 36
        for r in range(4):
            for c in range(4):
                x, y = sm_x0 + c * cell, sm_y0 + r * cell
                if c < 2:
                    fl, st = ORANGE_F, ORANGE
                elif c == 2:
                    fl, st = GREEN_F, GREEN
                else:
                    fl, st = BLUE_F, BLUE
                rrect(x, y, sm, sm, fl, st, 1, 2, role="decoration")
        # MPS partition dividers (span the grid height)
        for xx in [sm_x0 + 2 * cell - 4, sm_x0 + 3 * cell - 4]:
            d.add_element(
                f'<line x1="{xx}" y1="{sm_y0 - 4}" x2="{xx}" y2="{sm_y0 + 4 * cell - 4}" '
                f'stroke="{GRAY_D}" stroke-width="1" stroke-dasharray="3,2" data-graph-role="decoration"/>')
        txt(128, 882, "compute", 10, ORANGE, "middle", "bold")
        txt(191, 882, "copy", 10, GREEN, "middle", "bold")
        txt(233, 882, "comm", 10, BLUE, "middle", "bold")
        txt(177, 898, "MPS / MIG partition", 10, GRAY_D, "middle")
        
        # time axis
        AX0, AX1, AY = 320, 1370, 712
        d.add_element(f'<line x1="{AX0}" y1="{AY}" x2="{AX1}" y2="{AY}" stroke="{GRAY_S}" stroke-width="1.4"/>')
        for xx, lab in [(380, "T0"), (680, "T1"), (980, "T2"), (1280, "T3")]:
            d.add_element(f'<line x1="{xx}" y1="{AY-5}" x2="{xx}" y2="{AY+5}" stroke="{GRAY_S}" stroke-width="1.4"/>')
            txt(xx, AY + 19, lab, 10, GRAY_D, "middle")
        txt(1342, AY - 6, "time →", 10, GRAY_S, "start")
        
        # swimlanes
        def lane_label(y, lbl, sub, st):
            txt(348, y + 16, lbl, 10, st, "end", "bold")
            txt(348, y + 30, sub, 10, GRAY_D, "end")
        
        def bar(x, y, w, lbl, fl, st, big=False):
            rrect(x, y, w, 40, fl, st, 1.5, 5, role="decoration", bbox=True)
            txt(x + w / 2, y + 17, lbl, 12 if big else 10, "#222222", "middle", "bold")
            txt(x + w / 2, y + 31, "active" if big else "", 10, GRAY_D, "middle")
        
        lane_label(732, "Compute", "(Tensor Core)", ORANGE)
        bar(380, 736, 360, "Kernel A · MatMul", ORANGE_F, ORANGE, big=True)
        bar(780, 736, 320, "Kernel B", ORANGE_F, ORANGE)
        lane_label(786, "Copy D2D", "(layout xform)", GREEN)
        bar(430, 790, 250, "NHWC→NCHW", GREEN_F, GREEN)
        bar(820, 790, 230, "D2D copy", GREEN_F, GREEN)
        lane_label(840, "Comm H2D", "(next batch)", BLUE)
        bar(380, 844, 180, "H2D preload", BLUE_F, BLUE)
        bar(720, 844, 220, "H2D preload", BLUE_F, BLUE)
        bar(1080, 844, 220, "H2D preload", BLUE_F, BLUE)
        
        # overlap region guides
        for xx in [430, 680]:
            d.add_element(
                f'<line x1="{xx}" y1="730" x2="{xx}" y2="886" stroke="{ORANGE}" '
                f'stroke-width="1" stroke-dasharray="3,3" data-graph-role="decoration"/>')
        txt(555, 902, "↕ fully concurrent · Multi-Stream Overlap", 10, ORANGE, "middle", "bold")
        
        # =================== BAND 4 : MEMORY POOL ===================
        rrect(50, 935, 1340, 235, "#FCFCFC", YELLOW, 2, 12,
              extra='data-node-id="mem_pool"', role="layer")
        txt(72, 963, "Memory Pool — Lifetime · In-place · Workspace Reuse", 14, YELLOW, "start", "bold")
        txt(72, 981, "Ring buffer · producer→consumer L2 locality", 12, GRAY_D, "start")
        
        # ring buffer
        cx, cy, R = 188, 1062, 56
        segs = [(0, 90, YELLOW), (90, 180, ORANGE), (180, 270, GREEN), (270, 360, BLUE)]
        for a0, a1, st in segs:
            x1 = cx + R * math.cos(math.radians(a0)); y1 = cy + R * math.sin(math.radians(a0))
            x2 = cx + R * math.cos(math.radians(a1)); y2 = cy + R * math.sin(math.radians(a1))
            d.add_element(
                f'<path d="M{x1:.1f},{y1:.1f} A{R},{R} 0 0 1 {x2:.1f},{y2:.1f}" fill="none" '
                f'stroke="{st}" stroke-width="15" data-graph-role="decoration"/>')
        txt(cx, cy - 2, "Memory Pool", 12, "#333333", "middle", "bold")
        txt(cx, cy + 15, "Ring Buffer", 10, GRAY_D, "middle")
        # rotation arrow
        d.add_element(
            f'<path d="M{cx + R + 4},{cy - 14} A{R + 14},{R + 14} 0 0 1 {cx + R + 4},{cy + 14}" '
            f'fill="none" stroke="{GRAY_S}" stroke-width="1.4" marker-end="url(#ah)" '
            f'data-graph-role="decoration"/>')
        
        h_i = card(330, 988, 330, "In-place Update",
             "Add / ReLU overwrite the input buffer → zero extra allocation", YELLOW)
        h_w = card(690, 988, 330, "Workspace Reuse",
             "Operators share one scratch buffer → ↓ allocation overhead", ORANGE)
        h_p = card(1050, 988, 330, "Producer→Consumer Locality",
             "Next kernel consumes output while still warm in L2 cache", GREEN)
        ht = max(h_i, h_w, h_p)
        # arrows ring -> cards (decorative)
        for ex in [330, 690, 1050]:
            d.add_element(
                f'<line x1="{cx + R}" y1="{cy}" x2="{ex}" y2="1018" stroke="{GRAY_L}" '
                f'stroke-width="1" stroke-dasharray="3,3" data-graph-role="decoration"/>')
        card(330, 988 + ht + 12, 1050, "Buffer Lifetime Timeline",
             "t0 alloc K1 → t1 in-place ReLU → t2 workspace reused by K2 → t3 free   (ring arcs = ownership)", GRAY_S)
        
        # =================== LEFT PIPELINE SPINE (drawn LAST → sits above bands, never occluded) ===================
        SPX = 26
        d.add_element(
            f'<line x1="{SPX}" y1="80" x2="{SPX}" y2="1156" stroke="{GRAY_L}" stroke-width="2" '
            f'stroke-dasharray="4,4" data-graph-role="decoration"/>')
        for num, yy in [("1", 165), ("2", 457), ("3", 755), ("4", 1052)]:
            d.add_element(
                f'<circle cx="{SPX}" cy="{yy}" r="13" fill="#FFFFFF" stroke="{GRAY_S}" '
                f'stroke-width="1.5" data-graph-role="decoration"/>')
            txt(SPX, yy + 4, num, 12, GRAY_D, "middle", "bold")
        # verbs sit inside the band gutters (330-340 / 575-585 / 925-935) so they clear every band
        for yy, vb in [(330, "lowers"), (575, "dispatches"), (925, "reclaims")]:
            d.add_element(
                f'<polygon points="{SPX-5},{yy} {SPX+5},{yy} {SPX},{yy+9}" fill="{GRAY_S}" '
                f'data-graph-role="decoration"/>')
            txt(SPX + 16, yy + 7, vb, 10, GRAY_S, "start")
        
        # =================== DESIGN BRIEF (Step 1) ===================
        # Declared from input.md's matrix intent: 4 horizontal layer bands (the
        # matrix rows) stacking top -> bottom, one hue per concern (graph ops /
        # scheduling / hardware / memory) — S2 categorical semantics. The bands are
        # palette members; the intra-band tinted containers (task scheduler + the
        # three device streams) are members too. flow_chain stays empty: the numbered
        # spine and gutter verbs linking the bands are decorative annotations, and no
        # business edge crosses a band boundary, so no chain is assertable.
        BRIEF = DesignBrief(
            scheme="S2",
            layout="band",
            flow="top-down",
            palette_role={
                "graph_opt":       ColorSpec("#FCFCFC", PURPLE),
                "runtime_sched":   ColorSpec("#FCFCFC", BLUE),
                "hw_concurrency":  ColorSpec("#FCFCFC", ORANGE),
                "mem_pool":        ColorSpec("#FCFCFC", YELLOW),
                "sched":           ColorSpec(BLUE_F, BLUE),
                "s0":              ColorSpec(BLUE_F, BLUE),
                "s1":              ColorSpec(ORANGE_F, ORANGE),
                "s2":              ColorSpec(GREEN_F, GREEN),
            },
            flow_chain=(),
        )
        
        # =================== EVALUATE & SAVE ===================
        score, report = evaluate_svg(d, conn_tolerance=12.0)
        print(f"Score: {score}")
        for line in report:
            print(line)
        qa = run_semantic_qa(d, expected_size=(W, H), brief=BRIEF)
        print("Semantic QA:")
        for line in qa.report():
            print(line)
        svg_path = OUT / f"{NAME}.svg"
        png_path = OUT / f"{NAME}.png"
        pptx_path = OUT / f"{NAME}.pptx"
        save_svg(d.render(), str(svg_path))
        rasterize_svg(svg_path, png_path, W)
        from svg2pptx import svg_to_pptx
        svg_to_pptx(str(svg_path), str(pptx_path))
        BRIEF.write(str(OUT / "brief.json"))
        print(f"Saved triplet -> {OUT}")
        
      • input.md 2.5 KB
        # MLIR AI Compiler — Multi-Stream Execution Pipeline
        
        Draw a 4-layer MATRIX diagram of an AI-compiler runtime pipeline. Four
        horizontal layer bands stack top → bottom (the matrix rows); within each band
        stages and streams read left → right (the matrix columns). A vertical numbered
        spine on the far left links the four bands into one downward flow.
        
        ## Structure
        
        A matrix grid: 4 horizontal layers (top → bottom) × multiple stage columns
        (left → right). Data and execution flow left → right inside every band.
        
        ### Layer 1 — Graph Optimization (compile-time · static DAG & fusion)
        - Operator fusion, algebraic reordering, parallel-branch detection.
        - A 3-node DAG (**Conv** → **BatchNorm** → **ReLU**) feeding an **Add** node; a
          parallel branch (no data dependency) feeds Add's second input.
        - Vertical-fusion group drawn as a dashed box around Conv+BN+ReLU → fused into
          one kernel **K1**. A curved reorder-swap arc marks a commutative D↔C swap.
        
        ### Layer 2 — Runtime Scheduling (async launch · priority · load balance)
        - A host-side **Task Scheduler** (CPU) fans out to three device streams:
          **Stream 0** · Comm (high priority), **Stream 1** · Compute (kernel queue),
          **Stream 2** · D2D Copy (low priority).
        - Each stream owns a device-side queue of small task chips (T / K / cp).
        
        ### Layer 3 — Hardware Concurrency (multi-stream overlap · SM partition)
        - A GPU **SM array** (4×4 grid) partitioned into MPS/MIG zones: compute, copy,
          comm.
        - A horizontal time axis (T0→T3) with three swimlanes — Compute (Tensor Core),
          Copy D2D (layout xform), Comm H2D (next batch) — whose bars overlap in time.
        
        ### Layer 4 — Memory Pool (lifetime · in-place · workspace reuse)
        - A 4-quadrant ring buffer (**Memory Pool**) whose colored arcs encode
          ownership.
        - Three reuse-strategy cards: **In-place Update**, **Workspace Reuse**,
          **Producer→Consumer (L2) Locality**; a full-width **Buffer Lifetime
          Timeline** below.
        
        ### Cross-cutting
        - Far-left dashed vertical spine with numbered circles 1–4 marking the bands;
          downward-triangle verbs in the band gutters: "lowers", "dispatches",
          "reclaims".
        - Right-edge annotation cards in every band explain each stage's technique.
        
        Colors distinguish the pipeline's concerns (graph ops, scheduling, hardware,
        memory); keep the accent count within the skill's design-system budget. Layout,
        palette, typography, and all geometry are yours to design — follow the
        architecture-drawer skill's design system and let the evaluator guide
        iteration.
        
    • 20260728_153000_agent_infra_architecture
      • brief.json 887 B
        {
          "scheme": "S2",
          "layout": "band",
          "flow": "top-down",
          "flow_chain": [
            "L1",
            "L2",
            "L3",
            "L4",
            "L5"
          ],
          "palette_role": {
            "L1": {
              "fill": "#f2f2f2",
              "stroke": "#b0b0b0"
            },
            "L2": {
              "fill": "#f2f2f2",
              "stroke": "#b0b0b0"
            },
            "L3": {
              "fill": "#f2f2f2",
              "stroke": "#b0b0b0"
            },
            "L4": {
              "fill": "#f2f2f2",
              "stroke": "#b0b0b0"
            },
            "L5": {
              "fill": "#f2f2f2",
              "stroke": "#b0b0b0"
            },
            "memory_ctx": {
              "fill": "#b2e2e2",
              "stroke": "#333333"
            },
            "tools_gw": {
              "fill": "#ffe0b2",
              "stroke": "#333333"
            },
            "exec_engine": {
              "fill": "#c5e1a5",
              "stroke": "#333333"
            },
            "sandbox": {
              "fill": "#fff59d",
              "stroke": "#333333"
            },
            "SEC": {
              "fill": "#ef9a9a",
              "stroke": "#333333"
            }
          }
        }
      • gen.py 11.8 KB
        """Agent Infra layered architecture diagram generator.
        
        Layout: 5 horizontal layers (Application -> Orchestration -> Core Capabilities ->
        Execution & Environment -> Infrastructure) with the 5 core modules colored, and a
        "Security & Observability" band spanning all layers on the right (cross-cutting).
        
        Output: agent_infra_architecture.svg
        """
        from pathlib import Path
        import sys
        
        import os, sys
        _HERE = os.path.dirname(os.path.abspath(__file__))
        _SKILL = os.path.normpath(os.path.join(_HERE, "..", "..", "scripts"))
        if _SKILL not in sys.path:
            sys.path.insert(0, _SKILL)
        from svg_utils import SVGDrawer, save_svg, rasterize_svg
        from evaluator import evaluate_svg
        from design_brief import DesignBrief, ColorSpec
        from semantic_qa import run_semantic_qa
        
        # Script co-located with its SVG/PNG/PPTX in this dir (output/<ts>_<name>/).
        # Re-run refreshes the triplet in place.
        NAME = "agent_infra_architecture"
        OUT = Path(__file__).resolve().parent
        
        # ---------------------------------------------------------------------------
        # Design tokens
        # ---------------------------------------------------------------------------
        W, H = 1280, 900
        
        # Stack (left) + cross-cutting band (right)
        SX0, SW = 30, 860          # stack x-range 30..890
        SX1 = SX0 + SW             # 890
        BX0, BW = 910, 330         # band x-range 910..1240
        
        # Structural colors are PURE grays (R==G==B) so the validator treats them as
        # neutral and does NOT count them as accents -> palette stays at the 5 module fills.
        INK = "#333333"            # primary text / module borders
        SUB = "#666666"            # secondary text
        NEU_FILL = "#F2F2F2"       # neutral layer fill
        NEU_STROKE = "#B0B0B0"     # neutral layer border
        CARD_FILL = "#FFFFFF"
        CARD_STROKE = "#BEBEBE"
        EDGE = "#555555"           # connectors (pure gray)
        
        # 5 core-module accent FILLS only (borders stay neutral gray -> 5 accents total)
        C_MEM = "#B2E2E2"     # Memory & Context  (teal)
        C_TOOL = "#FFE0B2"    # Tools & Gateway   (orange)
        C_EXEC = "#C5E1A5"    # Execution Engine  (green)
        C_SAND = "#FFF59D"    # Environment/Sandbox(yellow)
        C_SEC = "#EF9A9A"     # Security band     (coral)
        
        # Font tiers (<=4 distinct, adjacent ratio >=1.15): 22 / 14 / 11 / 9
        F_TITLE, F_HEAD, F_LABEL, F_NOTE = 22, 14, 11, 9
        
        drawer = SVGDrawer(W, H, bg="#FFFFFF")
        drawer.arrow_head("arrow", EDGE, marker_width=10, marker_height=8, ref_x=9, ref_y=4)
        
        
        def head_two(x, y_cn, y_en, cn, en, fill=INK, weight="bold", anchor="middle"):
            drawer.text(x, y_cn, cn, F_HEAD, fill=fill, anchor=anchor, weight=weight)
            if en:
                drawer.text(x, y_en, en, F_NOTE, fill=SUB, anchor=anchor)
        
        
        def card(x, y, w, h, cn, en, fs_cn=F_LABEL, fs_en=F_NOTE):
            drawer.rect(x, y, w, h, rx=6, ry=6, fill=CARD_FILL, stroke=CARD_STROKE,
                        stroke_width=1, bbox=True)
            if en:
                drawer.text(x + w / 2, y + h / 2 - fs_cn * 0.55, cn, fs_cn, fill=INK, anchor="middle")
                drawer.text(x + w / 2, y + h / 2 + fs_en * 0.75, en, fs_en, fill=SUB, anchor="middle")
            else:
                drawer.text(x + w / 2, y + h / 2, cn, fs_cn, fill=INK, anchor="middle")
        
        
        def card_row(items, y, h, inner_x, inner_w, cn_fs=F_LABEL):
            n = len(items)
            gap = 14
            cw = (inner_w - gap * (n - 1)) / n
            for i, (cn, en) in enumerate(items):
                x = inner_x + i * (cw + gap)
                card(x, y, cw, h, cn, en, fs_cn=cn_fs)
        
        
        def module(x, y, w, h, fill, cn, en, sub_items, nid=None):
            drawer.rect(x, y, w, h, rx=8, ry=8, fill=fill, stroke=INK, stroke_width=1.2, bbox=True,
                        node_id=nid)
            head_two(x + w / 2, y + 22, y + 37, cn, en)
            pad = 12
            sub_y = y + 50
            sub_h = h - 50 - pad
            card_row(sub_items, sub_y, sub_h, x + pad, w - 2 * pad)
        
        
        # ---------------------------------------------------------------------------
        # Title
        # ---------------------------------------------------------------------------
        drawer.text(W / 2, 34, "Agent Infra 架构图", F_TITLE, fill=INK, anchor="middle", weight="bold")
        drawer.text(W / 2, 58, "Agent Infrastructure  ·  分层架构 Layered Architecture",
                    F_LABEL, fill=SUB, anchor="middle")
        
        # ---------------------------------------------------------------------------
        # Layer containers (neutral) + their nodes for inter-layer arrows.
        # Each layer: header band (cn y+18 / en y+32), content starts at y+44.
        # Inter-layer gaps = 20px so arrow segments stay >=16px after marker retraction.
        # ---------------------------------------------------------------------------
        inner_x, inner_w = SX0 + 18, SW - 36   # 48 .. 872  (width 824)
        layers = [
            (90, 90, "应用层", "Application Layer", "L1"),
            (200, 92, "编排与治理层", "Orchestration & Governance", "L2"),
            (312, 188, "核心能力层", "Core Capabilities", "L3"),
            (520, 176, "执行与环境层", "Execution & Environment", "L4"),
            (716, 90, "基础设施层", "Infrastructure", "L5"),
        ]
        for y, h, hcn, hen, nid in layers:
            drawer.rect(SX0, y, SW, h, rx=8, ry=8, fill=NEU_FILL, stroke=NEU_STROKE,
                        stroke_width=1, node_id=nid, node_kind="layer", bbox=True)
            head_two(inner_x + 4, y + 18, y + 32, hcn, hen, anchor="start")
        
        # L1 - Application (3 cards)
        card_row([("Web 应用", "Web App"), ("REST / API", "Gateway"), ("CLI 工具", "CLI")],
                 134, 46, inner_x, inner_w)
        # L2 - Orchestration (4 cards)
        card_row([("生命周期管理", "Lifecycle"), ("任务调度", "Scheduling"),
                  ("多智能体协作", "Multi-Agent"), ("策略控制", "Policy")],
                 244, 46, inner_x, inner_w)
        
        # L3 - Core Capabilities: Memory & Context (teal) | Tools & Gateway (orange)
        mW = 410
        module(SX0 + 18, 356, mW, 132, C_MEM, "记忆与上下文", "Memory & Context",
               [("向量数据库", "Vector DB"), ("知识图谱", "Knowledge Graph"), ("RAG 检索", "Retrieval")],
               nid="memory_ctx")
        module(SX0 + 18 + mW + 20, 356, mW, 132, C_TOOL, "工具与网关", "Tools & Gateway",
               [("MCP 协议", "MCP"), ("API 集成", "API Integration"), ("函数调用", "Function Call")],
               nid="tools_gw")
        
        # L4 - Execution & Environment: Execution Engine (green) | Sandbox (yellow)
        module(SX0 + 18, 564, mW, 120, C_EXEC, "执行引擎", "Execution Engine",
               [("高并发", "Concurrency"), ("秒级扩容", "Autoscale"), ("快速启动", "Fast Start")],
               nid="exec_engine")
        module(SX0 + 18 + mW + 20, 564, mW, 120, C_SAND, "环境与沙箱", "Environment & Sandbox",
               [("代码执行", "Code Exec"), ("Serverless", "Elastic"), ("安全隔离", "Isolation")],
               nid="sandbox")
        
        # L5 - Infrastructure (4 cards)
        card_row([("计算", "Compute · GPU/CPU"), ("存储", "Storage"), ("网络", "Network"), ("K8s 编排", "Kubernetes")],
                 760, 42, inner_x, inner_w)
        
        # ---------------------------------------------------------------------------
        # Security & Observability - cross-cutting band (spans full stack height)
        # ---------------------------------------------------------------------------
        BY0, BH = 90, 716     # 90..806, matches the layer stack
        drawer.rect(BX0, BY0, BW, BH, rx=10, ry=10, fill=C_SEC, stroke=INK,
                    stroke_width=1.2, node_id="SEC", node_kind="region", bbox=True)
        drawer.text(BX0 + BW / 2, BY0 + 26, "安全与可观测", F_HEAD, fill=INK, anchor="middle", weight="bold")
        drawer.text(BX0 + BW / 2, BY0 + 44, "Security & Observability", F_NOTE, fill=SUB, anchor="middle")
        drawer.text(BX0 + BW / 2, BY0 + 60, "横向贯穿所有层 · Cross-cutting", F_NOTE, fill=SUB,
                    anchor="middle", style="italic")
        
        sec_items = [("身份认证", "Authentication"), ("数据加密", "Encryption"),
                     ("行为审计", "Behavior Audit"), ("日志", "Logging"),
                     ("监控", "Metrics"), ("链路追踪", "Tracing")]
        s_y0, s_h, s_gap = 180, 92, 12
        for i, (cn, en) in enumerate(sec_items):
            yy = s_y0 + i * (s_h + s_gap)
            card(BX0 + 14, yy, BW - 28, s_h, cn, en)
        
        # ---------------------------------------------------------------------------
        # Connectors
        # ---------------------------------------------------------------------------
        # 1) Vertical dependency flow between adjacent layers (stack centerline, x=460)
        for a, b in [("L1", "L2"), ("L2", "L3"), ("L3", "L4"), ("L4", "L5")]:
            drawer.connect(a, "bottom", b, "top", stroke=EDGE, stroke_width=1.8, marker_end="arrow")
        
        # 2) Cross-cutting dashed links: each layer's right edge -> band left edge
        for y, h, _hcn, _hen, _nid in layers:
            cy = y + h / 2
            drawer.line(SX1, cy, BX0, cy, stroke=CARD_STROKE, stroke_width=1.2,
                        register_edge=True, dashed="4,3")
        
        # ---------------------------------------------------------------------------
        # Legend
        # ---------------------------------------------------------------------------
        LY0 = 822
        drawer.rect(SX0, LY0, SW, 60, rx=8, ry=8, fill=NEU_FILL, stroke=NEU_STROKE,
                    stroke_width=1, bbox=True)
        drawer.text(SX0 + 14, LY0 + 19, "图例 Legend", F_HEAD, fill=INK, anchor="start", weight="bold")
        
        # item 1: core-module swatches
        drawer.text(SX0 + 14, LY0 + 42, "核心模块 Core Modules", F_LABEL, fill=INK, anchor="start")
        lx = SX0 + 14 + 158
        for c in (C_MEM, C_TOOL, C_EXEC, C_SAND, C_SEC):
            drawer.rect(lx, LY0 + 35, 16, 14, rx=2, ry=2, fill=c, stroke=INK, stroke_width=0.8, bbox=False)
            lx += 22
        
        # item 2: dependency arrow
        i2 = SX0 + 470
        drawer.line(i2, LY0 + 42, i2 + 34, LY0 + 42, stroke=EDGE, stroke_width=1.8, marker_end="arrow")
        drawer.text(i2 + 44, LY0 + 42, "依赖 / 控制流", F_LABEL, fill=INK, anchor="start")
        
        # item 3: dashed cross-cut
        i3 = SX0 + 680
        drawer.line(i3, LY0 + 42, i3 + 34, LY0 + 42, stroke=CARD_STROKE, stroke_width=1.2,
                    dashed="4,3")
        drawer.text(i3 + 44, LY0 + 42, "安全可观测横跨各层", F_LABEL, fill=INK, anchor="start")
        
        # ---------------------------------------------------------------------------
        # Design Brief (Step 1) — declared from input.md's intent: a 5-layer neutral
        # stack (L1 应用层 -> L5 基础设施层) read top-down, the cross-cutting 安全与可观测
        # band on the right, and the four core modules carrying distinct accent hues
        # (S2 categorical; input.md delegates exact colors to the design system, which
        # kept the stack itself neutral gray — declared here with its actual hex).
        # The dependency spine links band BORDERS across the 20px gutters, and the
        # marker-tip retraction pulls every line endpoint into the gutter, so no
        # inter-layer edge attributes to the next band: an assertable chain would
        # false-FAIL (same gutter-spine situation as the pi_agent / mlir evals).
        # ---------------------------------------------------------------------------
        BRIEF = DesignBrief(
            scheme="S2",
            layout="band",
            flow="top-down",
            palette_role={
                "L1":          ColorSpec(NEU_FILL, NEU_STROKE),
                "L2":          ColorSpec(NEU_FILL, NEU_STROKE),
                "L3":          ColorSpec(NEU_FILL, NEU_STROKE),
                "L4":          ColorSpec(NEU_FILL, NEU_STROKE),
                "L5":          ColorSpec(NEU_FILL, NEU_STROKE),
                "memory_ctx":  ColorSpec(C_MEM, INK),
                "tools_gw":    ColorSpec(C_TOOL, INK),
                "exec_engine": ColorSpec(C_EXEC, INK),
                "sandbox":     ColorSpec(C_SAND, INK),
                "SEC":         ColorSpec(C_SEC, INK),
            },
            flow_chain=("L1", "L2", "L3", "L4", "L5"),
        )
        
        # ---------------------------------------------------------------------------
        # Evaluate + save
        # ---------------------------------------------------------------------------
        score, report = evaluate_svg(drawer)
        print(f"Quality Score: {score}")
        for line in report:
            print(line)
        
        qa = run_semantic_qa(drawer, expected_size=(W, H), brief=BRIEF)
        print("Semantic QA:")
        for line in qa.report():
            print(line)
        
        svg_path = str(OUT / f"{NAME}.svg")
        png_path = str(OUT / f"{NAME}.png")
        pptx_path = str(OUT / f"{NAME}.pptx")
        save_svg(drawer.render(), svg_path)
        # Rasterize SVG -> PNG (enforces output/<task>/ convention)
        rasterize_svg(svg_path, png_path, W)
        # Export to PPTX (native editable shapes)
        from svg2pptx import svg_to_pptx
        svg_to_pptx(drawer.render(), pptx_path)
        print(f"Saved {svg_path}")
        print(f"Saved {png_path}")
        print(f"Saved {pptx_path} (editable shapes)")
        
        BRIEF.write(str(OUT / "brief.json"))
      • input.md 2.8 KB
        # Agent Infrastructure — Layered Architecture
        
        Draw a **5-layer horizontal stack** (top → bottom) representing an AI agent
        infrastructure platform, plus a **cross-cutting Security & Observability band**
        on the RIGHT that spans the full height of the stack.
        
        Title: **Agent Infra 架构图** / "Agent Infrastructure · 分层架构 Layered Architecture".
        
        ## Layers (top → bottom)
        
        Each layer is a neutral container with a bilingual CN header + EN subtitle,
        and holds component cards (or accent modules) inside.
        
        1. **应用层 / Application Layer** — end-user interfaces (3 cards):
           Web 应用 · Web App | REST / API · Gateway | CLI 工具 · CLI.
        2. **编排与治理层 / Orchestration & Governance** — agent coordination & control (4 cards):
           生命周期管理 · Lifecycle | 任务调度 · Scheduling | 多智能体协作 · Multi-Agent | 策略控制 · Policy.
        3. **核心能力层 / Core Capabilities** — two accent modules side by side:
           - **记忆与上下文 / Memory & Context**: 向量数据库·Vector DB | 知识图谱·Knowledge Graph | RAG 检索·Retrieval.
           - **工具与网关 / Tools & Gateway**: MCP 协议·MCP | API 集成·API Integration | 函数调用·Function Call.
        4. **执行与环境层 / Execution & Environment** — two accent modules side by side:
           - **执行引擎 / Execution Engine**: 高并发·Concurrency | 秒级扩容·Autoscale | 快速启动·Fast Start.
           - **环境与沙箱 / Environment & Sandbox**: 代码执行·Code Exec | Serverless·Elastic | 安全隔离·Isolation.
        5. **基础设施层 / Infrastructure** — foundation (4 cards):
           计算·Compute GPU/CPU | 存储·Storage | 网络·Network | K8s 编排·Kubernetes.
        
        ## Cross-cutting band (right side, full height)
        
        **安全与可观测 / Security & Observability** — a single tall band on the right
        spanning ALL five layers, visually distinct from the neutral stack. Contains
        6 cards: 身份认证·Authentication | 数据加密·Encryption | 行为审计·Behavior Audit |
        日志·Logging | 监控·Metrics | 链路追踪·Tracing.
        
        ## Flow & connections
        
        - **Dependency / control flow** — solid vertical arrows down the stack
          centerline linking each adjacent layer (L1→L2→L3→L4→L5).
        - **Cross-cutting span** — a dashed horizontal line from each layer's right
          edge to the band's left edge, showing security & observability cut across
          every layer.
        - A **legend** strip sits below the stack: core-module color swatches, the
          solid dependency arrow, and the dashed cross-cut line.
        
        All labels are bilingual (CN + EN). The four accent modules plus the security
        band use five distinct accent colors; everything else stays neutral. Layout,
        exact palette, typography, and all geometry are yours to design — follow the
        architecture-drawer skill's design system and let the evaluator guide
        iteration.
        
    • 20260728_203836_llm_inference_arch
      • brief.json 1 KB
        {
          "scheme": "S2",
          "layout": "band",
          "flow": "top-down",
          "flow_chain": [
            "band1",
            "band2",
            "band3",
            "band4",
            "band5",
            "band6",
            "band7"
          ],
          "palette_role": {
            "band1": {
              "fill": "#dae8fc",
              "stroke": "#2e5aac"
            },
            "band2": {
              "fill": "#dae8fc",
              "stroke": "#2e5aac"
            },
            "band3": {
              "fill": "#b2e2e2",
              "stroke": "#2e8b8b"
            },
            "band4": {
              "fill": "#b2e2e2",
              "stroke": "#2e8b8b"
            },
            "band5": {
              "fill": "#ffe6cc",
              "stroke": "#d79b00"
            },
            "band6": {
              "fill": "#e1d5e7",
              "stroke": "#9673a6"
            },
            "band7": {
              "fill": "#e1d5e7",
              "stroke": "#9673a6"
            },
            "prefill_box": {
              "fill": "#ffffff",
              "stroke": "#2e8b8b"
            },
            "decode_box": {
              "fill": "#ffffff",
              "stroke": "#2e8b8b"
            },
            "storage_box": {
              "fill": "#ffe6cc",
              "stroke": "#d79b00"
            },
            "gpu_cluster": {
              "fill": "#ffe6cc",
              "stroke": "#d79b00"
            }
          }
        }
      • gen.py 15.5 KB
        """LLM Distributed Inference Serving — 7-layer architecture diagram.
        
        Layout: vertical layered stack. A neutral downward "spine" connects the client
        (top) through seven architecture bands to the streaming output (bottom).
        Internal edges show intra-layer flows (scheduler<->KV index, prefill->decode,
        prefill cluster<->RDMA<->decode cluster).
        
        Palette is held to exactly 8 accent colors (4 light fills + 4 dark strokes),
        grouped by architectural function:
          blue   = control plane  (L1 Gateway, L2 Scheduler)
          teal   = compute        (L3 Inference Engine, L4 Parallelism)
          amber  = infra          (L5 Storage & Interconnect)
          purple = optimization   (L6 Hidden Optimizers, L7 Disaggregated)
        Op cards are neutral white + gray stroke so they never inflate the palette.
        Type scale is fixed at 4 tiers: 20 / 14 / 12 / 10.
        """
        
        import sys
        from pathlib import Path
        
        
        import os, sys
        _HERE = os.path.dirname(os.path.abspath(__file__))
        _SKILL = os.path.normpath(os.path.join(_HERE, "..", "..", "scripts"))
        if _SKILL not in sys.path:
            sys.path.insert(0, _SKILL)
        from svg_utils import SVGDrawer, save_svg, rasterize_svg
        from svg2pptx import svg_to_pptx
        from evaluator import evaluate_svg, auto_refine
        from design_brief import DesignBrief, ColorSpec
        from semantic_qa import run_semantic_qa
        
        OUT = Path(__file__).resolve().parent
        NAME = "llm_inference_arch"
        
        # ---- palette --------------------------------------------------------------
        BLUE_F, BLUE_S = "#DAE8FC", "#2E5AAC"
        TEAL_F, TEAL_S = "#B2E2E2", "#2E8B8B"
        AMBER_F, AMBER_S = "#FFE6CC", "#D79B00"
        PURP_F, PURP_S = "#E1D5E7", "#9673A6"
        CARD_F, CARD_S = "#FFFFFF", "#5A5A5A"   # neutral — not counted as accents
        INK, SUB = "#222222", "#444444"          # neutral text
        EDGE = "#6B6B6B"                          # neutral edges/arrows
        
        W, H = 1400, 1400
        CX = W // 2
        BAND_X, BAND_W = 40, 1320
        
        drawer = SVGDrawer(W, H, bg="#FFFFFF")
        drawer.arrow_head("ah", EDGE, marker_width=10, marker_height=7, ref_x=9, ref_y=3.5)
        
        # ---- band metadata: (num, name, fill, stroke, y, h) ----------------------
        BANDS = [
            (1, "\u2460 \u63a5\u5165\u4e0e\u7f51\u5173\u5c42  Gateway Governance",            BLUE_F, BLUE_S, 138, 96),
            (2, "\u2461 \u5168\u5c40\u8c03\u5ea6\u4e0e\u8def\u7531  Scheduler & Routing",     BLUE_F, BLUE_S, 256, 120),
            (3, "\u2462 \u5206\u5e03\u5f0f\u63a8\u7406\u5f15\u64ce  Inference Engine",         TEAL_F, TEAL_S, 400, 210),
            (4, "\u2463 \u6a21\u578b\u5e76\u884c\u4e0e\u52a0\u901f  Model Parallelism",        TEAL_F, TEAL_S, 634, 150),
            (5, "\u2464 \u5f02\u6784\u5b58\u50a8\u4e0e\u901a\u4fe1  Storage & Interconnect",   AMBER_F, AMBER_S, 808, 160),
            (6, "\u2465 \u9690\u6027\u4f18\u5316\u7ec4\u4ef6  Hidden Optimizers",             PURP_F, PURP_S, 992, 130),
            (7, "\u2466 \u5206\u79bb\u5f0f\u90e8\u7f72  Disaggregated Serving",               PURP_F, PURP_S, 1146, 150),
        ]
        
        
        def band_rect(n, fill, stroke, y, h):
            """Draw + register a layer band as a connectable layer node."""
            drawer.rect(BAND_X, y, BAND_W, h, rx=8, ry=8, fill=fill, stroke=stroke,
                        stroke_width=1.5, node_id=f"band{n}", node_kind="layer",
                        role="layer", bbox=False)
        
        
        def badge(n, stroke, y):
            """Light layer-number badge (decoration, not a node)."""
            drawer.rect(55, y + 13, 42, 26, rx=5, ry=5, fill="#FFFFFF", stroke=stroke,
                        stroke_width=1.5, role="decoration", bbox=False)
            drawer.text(76, y + 26, f"L{n}", 12, fill=INK, weight="bold", bbox=False)
        
        
        def band_name(name, y):
            drawer.text(108, y + 27, name, 14, fill=INK, weight="bold",
                        anchor="start", bbox=False)
        
        
        def card(x, y, w, h, nid, title, desc, title_size=12, desc_size=10):
            """Neutral op card: rect node + two text lines (title / desc)."""
            drawer.rect(x, y, w, h, rx=6, ry=6, fill=CARD_F, stroke=CARD_S,
                        stroke_width=1.2, node_id=nid, node_kind="op", role="node",
                        bbox=True)
            cx = x + w / 2
            cy = y + h / 2
            drawer.text(cx, cy - 8, title, title_size, fill=INK, weight="bold", bbox=False)
            drawer.text(cx, cy + 9, desc, desc_size, fill=SUB, bbox=False)
        
        
        # ---- title ----------------------------------------------------------------
        drawer.text(CX, 32, "\u5927\u6a21\u578b\u5206\u5e03\u5f0f\u63a8\u7406\u670d\u52a1\u67b6\u6784  LLM Distributed Inference Serving",
                    20, fill=INK, weight="bold", bbox=False)
        
        # ---- client ---------------------------------------------------------------
        drawer.rect(560, 70, 280, 46, rx=8, ry=8, fill=CARD_F, stroke=CARD_S,
                    stroke_width=1.4, node_id="client", node_kind="op", role="node",
                    bbox=True)
        drawer.text(CX, 84, "\u5ba2\u6237\u7aef Client", 12, fill=INK, weight="bold", bbox=False)
        drawer.text(CX, 100, "HTTP / gRPC \u00b7 Prompt + \u91c7\u6837\u53c2\u6570", 10, fill=SUB, bbox=False)
        
        # ---- bands (containers + headers) ----------------------------------------
        for n, name, fill, stroke, y, h in BANDS:
            band_rect(n, fill, stroke, y, h)
            badge(n, stroke, y)
            band_name(name, y)
        
        # ===== L1 Gateway: 4 cards ================================================
        l1y = 164
        for i, (nid, t, d) in enumerate([
            ("gw1", "\u8d1f\u8f7d\u5747\u8861", "Load Balancer"),
            ("gw2", "\u8ba4\u8bc1\u9274\u6743", "AuthN & AuthZ"),
            ("gw3", "\u6d41\u91cf\u63a7\u5236", "Rate Limit"),
            ("gw4", "Prompt \u8fc7\u6ee4", "Prompt Filter"),
        ]):
            card(415 + i * 215, l1y, 180, 44, nid, t, d)
        
        # ===== L2 Scheduler: resource view | scheduler | KV index =================
        drawer.rect(80, 294, 260, 58, rx=6, ry=6, fill=CARD_F, stroke=CARD_S,
                    stroke_width=1.2, node_id="resv", node_kind="op", role="node", bbox=True)
        drawer.text(210, 314, "\u96c6\u7fa4\u8d44\u6e90\u89c6\u56fe", 12, fill=INK, weight="bold", bbox=False)
        drawer.text(210, 332, "GPU \u663e\u5b58 / \u5229\u7528\u7387 / \u5065\u5eb7", 10, fill=SUB, bbox=False)
        
        drawer.rect(420, 294, 340, 58, rx=6, ry=6, fill=CARD_F, stroke=CARD_S,
                    stroke_width=1.4, node_id="sched", node_kind="op", role="node", bbox=True)
        drawer.text(590, 314, "\u5168\u5c40\u8c03\u5ea6\u5668 Scheduler", 12, fill=INK, weight="bold", bbox=False)
        drawer.text(590, 332, "Prefix Cache \u67e5\u627e \u00b7 \u8d1f\u8f7d\u5747\u8861", 10, fill=SUB, bbox=False)
        
        drawer.database(840, 294, 260, 58, fill=BLUE_F, stroke=BLUE_S, stroke_width=1.4,
                        node_id="kvid", node_kind="op", role="node", bbox=True)
        drawer.text(970, 314, "KV \u7f13\u5b58\u7d22\u5f15", 12, fill=INK, weight="bold", bbox=False)
        drawer.text(970, 332, "KV Cache Index", 10, fill=SUB, bbox=False)
        
        drawer.connect("resv", "right", "sched", "left", stroke=EDGE, stroke_width=1.5, marker_end="ah")
        drawer.connect("sched", "right", "kvid", "left", stroke=EDGE, stroke_width=1.5,
                       marker_end="ah", dashed=True)
        drawer.text(800, 310, "Prefix \u547d\u4e2d", 10, fill=SUB, bbox=False)
        
        # ===== L3 Inference Engine: prefill box -> decode box =====================
        drawer.rect(70, 444, 590, 140, rx=8, ry=8, fill="#FFFFFF", stroke=TEAL_S,
                    stroke_width=1.6, node_id="prefill_box", node_kind="layer",
                    role="layer", bbox=True)
        drawer.text(365, 466, "Prefill Worker\uff08\u8ba1\u7b97\u5bc6\u96c6\uff09", 12, fill=INK, weight="bold", bbox=False)
        for i, (nid, t) in enumerate([
            ("pf1", "Sequence \u5207\u5206"),
            ("pf2", "Attention \u5e76\u884c"),
            ("pf3", "KV \u538b\u7f29\u5199\u5165"),
        ]):
            x = 96 + i * 176
            drawer.rect(x, 498, 156, 34, rx=5, ry=5, fill=TEAL_F, stroke=TEAL_S,
                        stroke_width=1.1, node_id=nid, node_kind="op", role="node", bbox=True)
            drawer.text(x + 78, 515, t, 12, fill=INK, bbox=False)
        
        drawer.rect(720, 444, 590, 140, rx=8, ry=8, fill="#FFFFFF", stroke=TEAL_S,
                    stroke_width=1.6, node_id="decode_box", node_kind="layer",
                    role="layer", bbox=True)
        drawer.text(1015, 466, "Decode Worker\uff08\u8bbf\u5b58\u5bc6\u96c6\uff09", 12, fill=INK, weight="bold", bbox=False)
        for i, (nid, t) in enumerate([
            ("dc1", "Continuous Batch"),
            ("dc2", "\u52a8\u6001\u63d2\u5165/\u8e22\u51fa"),
            ("dc3", "\u9010 Token \u751f\u6210"),
        ]):
            x = 746 + i * 176
            drawer.rect(x, 498, 156, 34, rx=5, ry=5, fill=TEAL_F, stroke=TEAL_S,
                        stroke_width=1.1, node_id=nid, node_kind="op", role="node", bbox=True)
            drawer.text(x + 78, 515, t, 12, fill=INK, bbox=False)
        
        drawer.connect("prefill_box", "right", "decode_box", "left", stroke=EDGE,
                       stroke_width=1.6, marker_end="ah")
        drawer.text(690, 500, "KV Cache \u8f6c\u79fb", 10, fill=SUB, bbox=False)
        drawer.text(690, 528, "Continuous Batching", 10, fill=SUB, bbox=False)
        
        # ===== L4 Parallelism: TP | PP | SP =======================================
        for i, (nid, t, d) in enumerate([
            ("tp", "\u5f20\u91cf\u5e76\u884c TP", "QKV \u77e9\u9635\u5217\u5207\u5206 \u00b7 All-Reduce"),
            ("pp", "\u6d41\u6c34\u7ebf\u5e76\u884c PP", "\u6309\u5c42\u5207\u5206 \u00b7 Send / Recv"),
            ("sp", "\u5e8f\u5217\u5e76\u884c SP", "\u957f\u5e8f\u5217\u5207\u5206 \u00b7 \u5408\u5e76"),
        ]):
            card(90 + i * 420, 680, 380, 80, nid, t, d)
        
        # ===== L5 Storage & Interconnect ==========================================
        drawer.rect(70, 858, 360, 98, rx=8, ry=8, fill=AMBER_F, stroke=AMBER_S,
                    stroke_width=1.4, node_id="storage_box", role="layer", bbox=False)
        drawer.text(250, 876, "\u5b58\u50a8\u5c42\u7ea7 Storage", 12, fill=INK, weight="bold", bbox=False)
        for i, line in enumerate([
            "HBM \u663e\u5b58\uff1a\u6743\u91cd + \u6d3b\u8dc3 KV Cache",
            "CPU DRAM\uff1a\u5206\u7247\u6682\u5b58 / KV \u6362\u51fa",
            "\u5206\u5e03\u5f0f\u5b58\u50a8\uff1a\u6a21\u578b\u68c0\u67e5\u70b9",
        ]):
            drawer.text(250, 898 + i * 18, line, 10, fill=SUB, bbox=False)
        
        drawer.rect(500, 860, 300, 34, rx=5, ry=5, fill=CARD_F, stroke=CARD_S,
                    stroke_width=1.2, node_id="nvlink", node_kind="op", role="node", bbox=True)
        drawer.text(650, 877, "NVLink \u8282\u70b9\u5185\uff08TP All-Reduce\uff09", 10, fill=INK, bbox=False)
        drawer.rect(500, 912, 300, 34, rx=5, ry=5, fill=CARD_F, stroke=CARD_S,
                    stroke_width=1.2, node_id="ib", node_kind="op", role="node", bbox=True)
        drawer.text(650, 929, "InfiniBand/RoCE \u8de8\u8282\u70b9\uff08PP/RDMA\uff09", 10, fill=INK, bbox=False)
        
        drawer.rect(880, 858, 400, 98, rx=8, ry=8, fill=AMBER_F, stroke=AMBER_S,
                    stroke_width=1.4, node_id="gpu_cluster", role="layer", bbox=False)
        drawer.text(1080, 876, "GPU \u8282\u70b9\u96c6\u7fa4\uff088\u00d7A100/H100\uff09", 12, fill=INK, weight="bold", bbox=False)
        for i in range(4):
            gx = 910 + i * 90
            drawer.rect(gx, 898, 70, 30, rx=4, ry=4, fill="#FFFFFF", stroke="#999999",
                        stroke_width=1.0, role="decoration", bbox=False)
            drawer.text(gx + 35, 913, f"GPU{i}", 10, fill=SUB, bbox=False)
        
        # ===== L6 Hidden Optimizers ===============================================
        card(90, 1044, 580, 56, "opt1",
             "\u901a\u4fe1\u5ef6\u8fdf\u9690\u85cf Compute-Comm Overlap",
             "\u8ba1\u7b97/\u901a\u4fe1\u5f02\u6b65\u91cd\u53e0\uff0c\u63a9\u76d6\u8de8\u5361\u540c\u6b65\u5ef6\u8fdf")
        card(710, 1044, 580, 56, "opt2",
             "\u52a8\u6001\u663e\u5b58\u5206\u914d KV Cache Swap",
             "\u663e\u5b58\u7d27\u5f20\u65f6\u6362\u51fa\u51b7 KV \u81f3 CPU\uff0c\u6309\u9700\u6362\u56de")
        
        # ===== L7 Disaggregated Serving ===========================================
        drawer.rect(90, 1200, 360, 68, rx=6, ry=6, fill=CARD_F, stroke=CARD_S,
                    stroke_width=1.2, node_id="pre_cluster", node_kind="op", role="node", bbox=True)
        drawer.text(270, 1222, "Prefill \u96c6\u7fa4", 12, fill=INK, weight="bold", bbox=False)
        drawer.text(270, 1240, "\u957f\u4e0a\u4e0b\u6587 \u00b7 \u8ba1\u7b97\u9971\u548c", 10, fill=SUB, bbox=False)
        
        drawer.hexagon(560, 1218, 240, 34, fill=PURP_F, stroke=PURP_S, stroke_width=1.3,
                       node_id="rdma", node_kind="op", role="node", bbox=True)
        drawer.text(680, 1235, "RDMA \u9ad8\u901f\u7f51\u7edc", 10, fill=INK, bbox=False)
        
        drawer.rect(910, 1200, 360, 68, rx=6, ry=6, fill=CARD_F, stroke=CARD_S,
                    stroke_width=1.2, node_id="dec_cluster", node_kind="op", role="node", bbox=True)
        drawer.text(1090, 1222, "Decode \u96c6\u7fa4", 12, fill=INK, weight="bold", bbox=False)
        drawer.text(1090, 1240, "\u5feb\u901f\u751f\u6210 \u00b7 \u5e26\u5bbd\u9971\u548c", 10, fill=SUB, bbox=False)
        
        drawer.connect("pre_cluster", "right", "rdma", "left", stroke=EDGE,
                       stroke_width=1.5, marker_end="ah")
        drawer.connect("rdma", "right", "dec_cluster", "left", stroke=EDGE,
                       stroke_width=1.5, marker_end="ah", dashed=True)
        drawer.text(855, 1222, "\u4efb\u52a1\u961f\u5217 / \u4e2d\u95f4\u6001", 10, fill=SUB, bbox=False)
        
        # ===== streaming output ===================================================
        drawer.rect(560, 1320, 280, 46, rx=8, ry=8, fill=CARD_F, stroke=CARD_S,
                    stroke_width=1.4, node_id="output", node_kind="op", role="node", bbox=True)
        drawer.text(CX, 1334, "\u91c7\u6837 Sampling\uff08Top-P / Top-K\uff09", 12, fill=INK, weight="bold", bbox=False)
        drawer.text(CX, 1350, "\u6d41\u5f0f\u8fd4\u56de Stream Output\uff08SSE / WebSocket\uff09", 10, fill=SUB, bbox=False)
        
        # ===== downward spine: client -> bands -> output ==========================
        spine = [("client", "band1")] + [(f"band{i}", f"band{i+1}") for i in range(1, 7)] + [("band7", "output")]
        for a, b in spine:
            drawer.connect(a, "bottom", b, "top", stroke=EDGE, stroke_width=2, marker_end="ah")
        drawer.text(712, 128, "\u8bf7\u6c42 Request", 10, fill=SUB, anchor="start", bbox=False)
        
        # ---- evaluate, auto-refine, emit triplet ---------------------------------
        score, report = evaluate_svg(drawer)
        print(f"Initial score: {score}")
        for line in report:
            print(line)
        
        if score < 100:
            score, report, fixes = auto_refine(drawer, target_score=100, max_iter=3)
            print(f"\nAfter auto_refine: {score}  (fixes: {len(fixes)})")
            for f in fixes:
                print("  -", f)
            for line in report:
                print(line)
        
        # ---- design brief (Step 1) --------------------------------------------------
        # Declared from input.md's intent: seven numbered bands ①–⑦ stacked top-down
        # (gateway -> scheduler -> engine -> parallelism -> storage -> optimizers ->
        # disaggregated) on a downward request spine; palette grouped by concern
        # (blue/teal/amber/purple = S2 categorical, per the design-system budget
        # input.md delegates to). The nested ③ worker containers draw white with the
        # teal STROKE carrying the color; ⑤ repeats the amber pair on its storage /
        # GPU-cluster containers. The spine arrows link band borders across the
        # gutters and the marker-tip retraction lands every endpoint between bands,
        # so no inter-layer edge attributes to the next band — a declared chain would
        # false-FAIL (same gutter-spine situation as the pi_agent / mlir evals).
        BRIEF = DesignBrief(
            scheme="S2",
            layout="band",
            flow="top-down",
            palette_role={
                "band1":       ColorSpec(BLUE_F, BLUE_S),
                "band2":       ColorSpec(BLUE_F, BLUE_S),
                "band3":       ColorSpec(TEAL_F, TEAL_S),
                "band4":       ColorSpec(TEAL_F, TEAL_S),
                "band5":       ColorSpec(AMBER_F, AMBER_S),
                "band6":       ColorSpec(PURP_F, PURP_S),
                "band7":       ColorSpec(PURP_F, PURP_S),
                "prefill_box": ColorSpec("#FFFFFF", TEAL_S),
                "decode_box":  ColorSpec("#FFFFFF", TEAL_S),
                "storage_box": ColorSpec(AMBER_F, AMBER_S),
                "gpu_cluster": ColorSpec(AMBER_F, AMBER_S),
            },
            flow_chain=("band1", "band2", "band3", "band4", "band5", "band6", "band7"),
        )
        
        print("Quality Score: %d" % score)
        qa = run_semantic_qa(drawer, expected_size=(W, H), brief=BRIEF)
        print("Semantic QA:")
        for line in qa.report():
            print(line)
        
        svg = drawer.render()
        save_svg(svg, str(OUT / f"{NAME}.svg"))
        rasterize_svg(str(OUT / f"{NAME}.svg"), str(OUT / f"{NAME}.png"), width=W)
        svg_to_pptx(svg, str(OUT / f"{NAME}.pptx"))
        print(f"\nFinal score: {score}")
        print(f"Wrote triplet to {OUT}")
        BRIEF.write(str(OUT / "brief.json"))
        
      • input.md 3 KB
        # LLM Distributed Inference Serving — 7-Layer Architecture
        
        Draw a vertical layered stack showing a distributed LLM inference serving
        system. A neutral downward "spine" connects the client (top) through seven
        architecture bands to the streaming output (bottom).
        
        ## Layers (top → bottom)
        
        Each band is one layer, named bilingually (circled number ①–⑦ + CN + EN).
        Components inside each band are neutral op cards.
        
        1. **① 接入与网关层  Gateway Governance** — 4 op cards side-by-side:
           负载均衡 Load Balancer, 认证鉴权 AuthN & AuthZ, 流量控制 Rate Limit,
           Prompt 过滤 Prompt Filter.
        2. **② 全局调度与路由  Scheduler & Routing** — three nodes in a row:
           集群资源视图 (GPU 显存/利用率/健康) → 全局调度器 Scheduler (Prefix Cache
           查找 · 负载均衡) ⇢ KV 缓存索引 KV Cache Index (drawn as a database
           cylinder). The sched→kvid edge is dashed (cache-hit lookup).
        3. **③ 分布式推理引擎  Inference Engine** — two nested worker containers
           side-by-side: Prefill Worker(计算密集)containing Sequence 切分 /
           Attention 并行 / KV 压缩写入 chips; Decode Worker(访存密集)
           containing Continuous Batch / 动态插入踢出 / 逐 Token 生成 chips. Connected
           by a "KV Cache 转移 · Continuous Batching" arrow.
        4. **④ 模型并行与加速  Model Parallelism** — 3 wide cards:
           张量并行 TP (QKV 矩阵列分割 · All-Reduce), 流水线并行 PP (按层分割 ·
           Send/Recv), 序列并行 SP (长序列分割 · 合并).
        5. **⑤ 异构存储与通信  Storage & Interconnect** — Storage container
           (HBM 显存 / CPU DRAM / 分布式存储) + NVLink 节点内 and InfiniBand/RoCE
           跨节点 interconnect cards + GPU 节点集群 (8×A100/H100) with 4 GPU chips.
        6. **⑥ 隐性优化组件  Hidden Optimizers** — 2 wide cards:
           通信延迟隐藏 Compute-Comm Overlap (计算/通信异步重叠), 动态显存分配 KV
           Cache Swap (换出冷 KV 至 CPU).
        7. **⑦ 分离式部署  Disaggregated Serving** — Prefill 集群 (长上下文 ·
           计算饱和) → RDMA 高速网络 (drawn as a hexagon) ⇢ Decode 集群 (快速生成 ·
           带宽饱和). The rdma→dec_cluster edge is dashed (任务队列/中间态).
        
        Top anchor: 客户端 Client (HTTP / gRPC · Prompt + 采样参数). Bottom anchor:
        采样 Sampling (Top-P/Top-K) / 流式返回 Stream Output (SSE / WebSocket).
        
        ## Flow
        
        A central vertical spine (neutral gray) carries the request downward through
        all seven band nodes (client → band1 → … → band7 → output). Internal
        horizontal edges show intra-layer flows (L2 resource→scheduler→KV, L3
        prefill→decode, L7 prefill→RDMA→decode).
        
        Layer bands may share a color family per concern (gateway/scheduling blue,
        engine/parallelism teal, storage amber, optimizers/disaggregation purple) —
        keep the total accent count within the skill's design-system budget. Layout,
        exact palette, typography, and all geometry are yours to design — follow the
        architecture-drawer skill's design system and let the evaluator guide
        iteration.
        
    • 20260728_2157_satellite_arch
      • brief.json 541 B
        {
          "scheme": "S2",
          "layout": "node",
          "flow": "none",
          "flow_chain": [],
          "palette_role": {
            "comm1": {
              "fill": "#dbeafe",
              "stroke": "#2563eb"
            },
            "geo_relay": {
              "fill": "#dbeafe",
              "stroke": "#2563eb"
            },
            "meo1": {
              "fill": "#dcfce7",
              "stroke": "#16a34a"
            },
            "src": {
              "fill": "#ffedd5",
              "stroke": "#ea580c"
            },
            "station": {
              "fill": "#e2e8f0",
              "stroke": "#475569"
            },
            "gs3": {
              "fill": "#ffffff",
              "stroke": "#475569"
            }
          }
        }
      • gen.py 12 KB
        # -*- coding: utf-8 -*-
        """天地一体化卫星系统架构分层图 (Integrated Space-Ground Satellite Architecture).
        
        Bottom = ground, top = deep space. Five vertical layers + a horizontal
        functional color code (blue=comm, green=nav, orange=sensing/research,
        slate=manned/station). Concrete validated edges: LEO constellation mesh and
        the LEO->GEO-relay->ground data chain. Broadcast/conceptual flows (MEO
        coverage beams, ground uplink/downlink) are drawn as decoration.
        
        Palette idiom: pastel fill + same-family dark stroke per hue; all text and
        arrowheads neutral black so no dark/light clash appears in either channel.
        """
        import sys
        from pathlib import Path
        
        
        import os, sys
        _HERE = os.path.dirname(os.path.abspath(__file__))
        _SKILL = os.path.normpath(os.path.join(_HERE, "..", "..", "scripts"))
        if _SKILL not in sys.path:
            sys.path.insert(0, _SKILL)
        from svg_utils import SVGDrawer, save_svg, rasterize_svg
        from evaluator import evaluate_svg
        from svg2pptx import svg_to_pptx, PptxConfig
        from design_brief import DesignBrief, ColorSpec
        from semantic_qa import run_semantic_qa
        
        OUT = Path(__file__).resolve().parent
        NAME = "satellite_arch"
        
        # ---- Palette: 4 hue families (light fill + dark stroke) + neutrals -----------
        BLUE, BLUE_F = "#2563EB", "#DBEAFE"       # communication / data links
        GREEN, GREEN_F = "#16A34A", "#DCFCE7"     # navigation
        ORANGE, ORANGE_F = "#EA580C", "#FFEDD5"   # sensing / research
        SLATE, SLATE_F = "#475569", "#E2E8F0"     # manned / structural
        BLACK = "#000000"                         # all text + arrowheads (neutral)
        LINE = "#A8A8A8"                          # neutral scale / divider lines
        BAND_A, BAND_B = "#F6F6F6", "#EFEFEF"     # neutral layer bands
        EARTH_F, EARTH_S = "#E4E4E4", "#9A9A9A"   # neutral earth
        
        F_TITLE, F_HEAD, F_BODY = 22, 15, 12
        
        W, Hh = 1400, 1180
        d = SVGDrawer(W, Hh, bg="#FFFFFF")
        d.arrow_head("ah", BLACK)   # single neutral arrowhead
        
        
        def band(x, y, w, h, fill):
            d.rect(x, y, w, h, rx=10, ry=10, fill=fill, stroke="none", opacity=0.9,
                   role="background")
        
        
        def layer_header(x, y, text):
            d.text(x, y, text, F_HEAD, fill=BLACK, anchor="start", weight="bold", bbox=False)
        
        
        def sat_circle(cx, cy, r, fill, stroke, nid, sw=1.6):
            d.circle(cx, cy, r, fill=fill, stroke=stroke, stroke_width=sw,
                     node_id=nid, node_kind="op")
        
        
        def sat_square(cx, cy, s, fill, stroke, nid, sw=1.6):
            d.rect(cx - s / 2, cy - s / 2, s, s, rx=2, ry=2, fill=fill, stroke=stroke,
                   stroke_width=sw, node_id=nid, node_kind="op")
        
        
        def sat_triangle(cx, cy, r, fill, stroke, nid, sw=1.6):
            pts = f'{cx},{cy - r} {cx - r},{cy + r * 0.8} {cx + r},{cy + r * 0.8}'
            d.add_element(
                f'<polygon points="{pts}" fill="{fill}" stroke="{stroke}" '
                f'stroke-width="{sw}" stroke-linejoin="round" />', None)
            d.register_node(nid, cx - r, cy - r, 2 * r, 2 * r, kind="op", visible=True)
            d._record_color(fill, stroke)
        
        
        def lbl(cx, cy, text, anchor="middle", weight="normal"):
            d.text(cx, cy, text, F_BODY, fill=BLACK, anchor=anchor, weight=weight, bbox=False)
        
        
        # ---------------- Title + subtitle ----------------
        d.text(W / 2, 34, "天地一体化卫星系统架构分层图", F_TITLE, fill=BLACK, weight="bold")
        d.text(W / 2, 62, "涵盖轨道高度 · 卫星分类 · 数据流向", F_BODY, fill=BLACK)
        
        # ---------------- Legend (centered row below subtitle) ----------------
        LY = 88
        legend = [(BLUE, BLUE_F, "通信类(含中继)"), (GREEN, GREEN_F, "导航类"),
                  (ORANGE, ORANGE_F, "遥感 / 科研类"), (SLATE, SLATE_F, "载人 / 空间站")]
        item_w = 168
        lx0 = (W - item_w * len(legend)) / 2
        for i, (dark, light, t) in enumerate(legend):
            x = lx0 + i * item_w
            d.circle(x, LY, 7, fill=light, stroke=dark, stroke_width=1.4, role="legend")
            d.text(x + 14, LY, t, F_BODY, fill=BLACK, anchor="start", bbox=False)
        
        # ---------------- Layer bands (gapped, tracked) ----------------
        CX0, CX1 = 150, 1350
        BW = CX1 - CX0
        bands = [
            (125, 115, "⑤ 深空探测 / 拉格朗日点层", BAND_B),
            (268, 120, "④ 地球静止轨道 GEO / 倾斜同步 IGSO", BAND_A),
            (420, 125, "③ 中地球轨道 MEO · 导航定位层", BAND_B),
            (580, 210, "② 低地球轨道 LEO · 近地密集层(200–2,000 km)", BAND_A),
            (865, 80, "① 地面支撑层", BAND_B),
        ]
        for y, h, name, fill in bands:
            band(CX0, y, BW, h, fill)
            layer_header(CX0 + 14, y + 20, name)
        
        # ---------------- Left altitude scale ----------------
        SX = 108
        d.line(SX, 120, SX, 960, stroke=LINE, stroke_width=1.5, role="decoration")
        alt = [(180, ["L1 / L2 点", "百万公里级"]),
               (320, ["≈35,786 km", "GEO 同步高度"]),
               (480, ["≈20,200 km", "MEO 导航"]),
               (590, ["2,000 km"]),
               (780, ["200 km"]),
               (905, ["0 km 地面"])]
        for ty, lines in alt:
            d.line(SX - 5, ty, SX + 5, ty, stroke=LINE, stroke_width=1.5, role="decoration")
            for j, ln in enumerate(lines):
                d.text(SX - 10, ty - (len(lines) - 1) * 7 + j * 14, ln,
                       F_BODY, fill=BLACK, anchor="end", bbox=False)
        d.text(SX, 105, "轨道高度", F_BODY, fill=BLACK, weight="bold", bbox=False)
        
        # ---------------- Layer 5 — Deep space ----------------
        Y5 = 185
        for cx, t, nid in [(300, "韦伯望远镜", "ds1"), (470, "爱因斯坦探针", "ds2"),
                           (640, "太阳观测卫星", "ds3")]:
            sat_triangle(cx, Y5, 14, ORANGE_F, ORANGE, nid)
            lbl(cx, Y5 - 26, t)
        for cx, t in [(860, "L1"), (980, "L2")]:
            d.add_element(
                f'<polygon points="{cx},{Y5-9} {cx+9},{Y5} {cx},{Y5+9} {cx-9},{Y5}" '
                f'fill="{SLATE_F}" stroke="{SLATE}" stroke-width="1.4"/>', None)
            lbl(cx, Y5 - 26, t)
        lbl(1340, Y5 - 6, "大椭圆 / 闪电轨道", anchor="end")
        lbl(1340, Y5 + 14, "地日 · 地月 L1 / L2 拉格朗日点", anchor="end")
        
        # ---------------- Layer 4 — GEO / IGSO ----------------
        Y4 = 345
        d.add_element(
            f'<path d="M 175,{Y4+18} Q {(175+1145)/2},{Y4-30} 1145,{Y4+18}" '
            f'fill="none" stroke="{LINE}" stroke-width="1.6" stroke-dasharray="5,4"/>', None)
        geo = [("geo_comm1", 360, Y4 + 4, BLUE_F, BLUE, "通信广播 · 亚太"),
               ("geo_met", 540, Y4 - 6, ORANGE_F, ORANGE, "气象预警 · 风云"),
               ("geo_warn", 720, Y4 - 10, ORANGE_F, ORANGE, "战略预警 · 红外"),
               ("geo_comm2", 900, Y4 - 6, BLUE_F, BLUE, "通信广播 · 广电"),
               ("geo_relay", 1230, Y4 + 4, BLUE_F, BLUE, "数据中继 · 天链")]
        for nid, cx, cy, f, s, _ in geo:
            sat_square(cx, cy, 30, f, s, nid)
        for nid, cx, cy, f, s, t in geo:
            lbl(cx, cy - 24, t)
        
        # ---------------- Layer 3 — MEO navigation ----------------
        meo = [("meo1", 300, 490, "北斗"), ("meo2", 460, 508, "GPS"),
               ("meo3", 620, 490, "Galileo"), ("meo4", 780, 508, "北斗"),
               ("meo5", 940, 490, "GPS"), ("meo6", 1100, 508, "Galileo")]
        for nid, cx, cy, name in meo:
            sat_circle(cx, cy, 15, GREEN_F, GREEN, nid)
            lbl(cx, cy - 28, name)
        lbl(700, 435, "导航定位星座(3–6 轨道面均匀分布)")
        for nid, cx, cy, name in meo[::2]:  # top row only: coverage beams stay in band
            d.add_element(
                f'<polygon points="{cx-18},{cy+15} {cx+18},{cy+15} {cx},{cy+54}" '
                f'fill="{GREEN_F}" fill-opacity="0.55" stroke="{GREEN}" stroke-width="0.8" '
                f'stroke-dasharray="3,3"/>', None)
        lbl(300, 558, "导航覆盖波束", anchor="start")
        
        # ---------------- Layer 2 — LEO ----------------
        comm_x = [220, 350, 480, 610, 740, 870, 1000, 1130]
        Ycomm = 635
        for i, cx in enumerate(comm_x):
            sat_circle(cx, Ycomm, 12, BLUE_F, BLUE, f"comm{i+1}")
        for i in range(len(comm_x) - 1):
            d.connect(f"comm{i+1}", "right", f"comm{i+2}", "left",
                      stroke=BLUE, stroke_width=1.4, marker_end=None)
        lbl(675, Ycomm + 34, "低轨通信星座(星链 / 千帆)· 星间链路网状互联")
        
        sense = [("sense1", 300, 695, "光学遥感"), ("sense2", 540, 695, "雷达遥感"),
                 ("sense3", 790, 695, "资源勘探"), ("src", 1230, 695, None)]
        for nid, cx, cy, t in sense:
            sat_square(cx, cy, 22, ORANGE_F, ORANGE, nid)
            if t:
                lbl(cx, cy + 24, t)
        lbl(1230, 695 + 24, "遥感数据源")
        
        sci = [("sci1", 430, 752, "实践 · 科学试验"), ("sci2", 670, 752, "空间环境探测")]
        for nid, cx, cy, t in sci:
            sat_triangle(cx, cy, 13, ORANGE_F, ORANGE, nid)
            lbl(cx, cy + 26, t)
        
        sat_square(980, 740, 54, SLATE_F, SLATE, "station", sw=2.0)
        lbl(980, 740, "天宫", weight="bold")
        lbl(980, 740 + 38, "空间站(载人航天)")
        
        # ---------------- Layer 1 — Ground ----------------
        ground = [("gs1", 360, 905, "地面测控站"), ("gs2", 740, 905, "数据接收天线"),
                  ("gs3", 1130, 905, "卫星控制中心")]
        for nid, cx, cy, t in ground:
            sat_square(cx, cy, 34, "#FFFFFF", SLATE, nid, sw=1.8)
            lbl(cx, cy + 30, t, weight="bold")
            # symbolic uplink / downlink (decorative)
            d.line(cx - 16, cy - 17, cx - 16, cy - 52, stroke=BLUE, stroke_width=1.6,
                   marker_end="ah", role="decoration")
            d.line(cx + 16, cy - 52, cx + 16, cy - 17, stroke=BLUE, stroke_width=1.6,
                   marker_end="ah", dashed="5,3", role="decoration")
        lbl(360, 905 - 62, "指令↑  数据↓")
        
        # ---------------- Relay chain (concrete validated edges) ----------------
        d.connect("src", "top", "geo_relay", "bottom",
                  stroke=BLUE, stroke_width=1.8, marker_end="ah")
        d.connect("geo_relay", "bottom", "gs3", "top",
                  stroke=BLUE, stroke_width=1.8, marker_end="ah")
        
        # LEO<->MEO inter-satellite dashed link
        d.connect("comm4", "top", "meo3", "bottom",
                  stroke=BLUE, stroke_width=1.3, marker_end=None, dashed=True)
        lbl(640, 552, "星间链路", anchor="start")
        
        # ---------------- Earth horizon ----------------
        d.add_element(
            f'<path d="M 0,{Hh} Q {W/2},{1005} {W},{Hh} Z" '
            f'fill="{EARTH_F}" stroke="{EARTH_S}" stroke-width="1.6"/>', None)
        lbl(W / 2, 1050, "地面段 · 测控 / 数收 / 控制中心")
        
        # ---------------- Footer ----------------
        d.text(CX0 + 4, 965,
               "色系:  蓝=信息传输(通信/中继)   绿=导航感知   橙=探测与科研   深灰=载人/平台",
               F_BODY, fill=BLACK, anchor="start", bbox=False)
        d.text(CX0 + 4, 987,
               "数据通路:  遥感星 → 高轨中继(天链) → 地面站;星座内部及层间由星间链路互联。",
               F_BODY, fill=BLACK, anchor="start", bbox=False)
        
        # ---------------- Design Brief (Step 1) ----------------
        # Declared from input.md's intent. The orbit-altitude bands are neutral
        # background zones (gray fills, role="background"): the diagram's structural
        # color is the horizontal FUNCTIONAL code carried by the satellites
        # themselves (blue=comm/relay, green=nav, orange=sensing/research,
        # slate=manned/platform) -> node-style brief whose palette keys are primary
        # nodes: the validated relay chain 遥感数据源→GEO中继(天链)→地面控制中心
        # plus one representative of each remaining hue family (LEO comm mesh,
        # MEO navigation, 天宫 station). Flow: input.md's connectors are a link-style
        # mesh ("a mesh, not a flow") and an up-then-down relay path that is monotonic
        # on neither axis -- no dominant flow direction -> flow="none", empty chain.
        BRIEF = DesignBrief(
            scheme="S2",
            layout="node",
            flow="none",
            palette_role={
                "comm1":     ColorSpec(BLUE_F, BLUE),     # LEO comm constellation
                "geo_relay": ColorSpec(BLUE_F, BLUE),     # GEO data relay (天链)
                "meo1":      ColorSpec(GREEN_F, GREEN),   # MEO navigation
                "src":       ColorSpec(ORANGE_F, ORANGE),  # remote-sensing source
                "station":   ColorSpec(SLATE_F, SLATE),   # 天宫 manned station
                "gs3":       ColorSpec("#FFFFFF", SLATE),  # ground control center
            },
            flow_chain=(),
        )
        
        # ---------------- Evaluate + render ----------------
        score, report = evaluate_svg(d)
        print(f"Quality Score: {score}")
        for line in report:
            print(line)
        
        qa = run_semantic_qa(d, expected_size=(W, Hh), brief=BRIEF)
        print("Semantic QA:")
        for line in qa.report():
            print(line)
        
        save_svg(d.render(), str(OUT / f"{NAME}.svg"))
        rasterize_svg(str(OUT / f"{NAME}.svg"), str(OUT / f"{NAME}.png"), width=1400)
        svg_to_pptx(str(OUT / f"{NAME}.svg"), str(OUT / f"{NAME}.pptx"),
                    config=PptxConfig(slide_w=13.333, slide_h=11.25, scale=1.0))
        BRIEF.write(str(OUT / "brief.json"))
        print("Done.")
        
      • input.md 2.7 KB
        # Integrated Space-Ground Satellite System Architecture
        
        天地一体化卫星系统架构分层图 (Integrated Space-Ground Satellite Architecture) — a layered
        diagram mapping satellite subsystems across orbital-altitude bands (bottom = ground,
        top = deep space), with a horizontal functional color code distinguishing what each
        satellite does (comm / nav / sensing / manned).
        
        ## Layers (bottom → top)
        
        1. **Ground Segment (地面支撑层)** — ground stations, TT&C, mission control,
           data receiving antennas, satellite control center. White-filled nodes.
        2. **LEO Constellation (低地球轨道, the densest layer)** — three sub-rows:
           a mesh of low-orbit comm satellites (starlink/千帆 style) with
           inter-satellite links; a row of sensing satellites
           (optical/radar/resource) and a remote-sensing data source; a small
           science-probe sub-row; plus the manned 天宫 space station.
        3. **MEO Navigation (中地球轨道)** — navigation constellation (北斗/GPS/Galileo)
           arranged as a staggered two-row ring with downward coverage beams.
        4. **GEO / IGSO (地球静止轨道)** — geostationary relay, broadcast, weather,
           and strategic-warning satellites riding a single dashed orbital arc.
        5. **Deep Space (深空探测 / 拉格朗日点)** — space telescopes and probes near
           the L1/L2 Lagrange points, plus a note on highly-elliptical (闪电) orbits.
        
        The ground segment closes the diagram at the bottom with an earth-horizon
        curve and a ground-segment caption beneath it.
        
        ## Functional color code (horizontal)
        
        - **Blue** = communication payloads and data-relay links (incl. 天链 relay)
        - **Green** = navigation / positioning (北斗, GPS, Galileo)
        - **Orange** = sensing / earth observation / research probes
        - **Slate** = manned platforms / stations / structural (天宫, ground stations)
        
        Each layer contains nodes colored by their function.
        
        ## Flow & edges
        
        - **LEO comm mesh**: link-style connectors between consecutive comm
          satellites (a mesh, not a flow — no arrowheads).
        - **Validated relay chain (the concrete data path, draw these as real
          edges):** remote-sensing source → GEO relay → ground control (sensing data
          flowing up, then down).
        - **LEO↔MEO inter-satellite link:** a dashed cross-layer constellation link,
          no arrowhead.
        - Decorative uplink/downlink arrow pairs (solid up, dashed down) may sit
          beside ground stations — mark them decorative, not real edges.
        
        Node shapes distinguish subsystem kinds (circles for satellites, squares for
        ground/station nodes, apex-up triangles for probes/telescopes, diamonds for
        Lagrange markers). Layout, exact palette, typography, and all geometry are
        yours to design — follow the architecture-drawer skill's design system and
        let the evaluator guide iteration.
        
    • 20260729_llama_cpp_arch
      • brief.json 470 B
        {
          "scheme": "S2",
          "layout": "band",
          "flow": "top-down",
          "flow_chain": [
            "L_model",
            "L_exec",
            "L_graph",
            "L_backend"
          ],
          "palette_role": {
            "L_model": {
              "fill": "#faeed1",
              "stroke": "#e69f00"
            },
            "L_exec": {
              "fill": "#e1f2fb",
              "stroke": "#56b4e9"
            },
            "L_graph": {
              "fill": "#d1eee6",
              "stroke": "#009e73"
            },
            "L_backend": {
              "fill": "#d1e6f1",
              "stroke": "#0072b2"
            }
          }
        }
      • gen.py 9.3 KB
        """llama.cpp architecture diagram generator.
        
        Three-section top-to-bottom layout:
          1. Core Library  — 4 stacked layer bands (Model / Execution / Graph / Backend)
          2. Inference Execution Flow — horizontal 6-stage pipeline
          3. Server Architecture (llama-server) — routes -> queue -> context(slots) -> response
        
        Palette: S2 Categorical (Okabe-Ito, colorblind-safe). Each library layer gets a
        distinct HUE so layer boundaries read at a glance — not a single-hue ramp whose
        tiers blur together. Cards inherit their parent layer's stroke (visual grouping,
        no extra accents). Text is neutral; connectors gray. The inference/server sections
        are a different structural concern (runtime flow, not static architecture) so they
        stay neutral-gray. 8 accents total (4 tints + 4 matching strokes), within budget.
        4 font tiers (20 / 14 / 12 / 10).
        """
        import sys
        from pathlib import Path
        
        
        import os, sys
        _HERE = os.path.dirname(os.path.abspath(__file__))
        _SKILL = os.path.normpath(os.path.join(_HERE, "..", "..", "scripts"))
        if _SKILL not in sys.path:
            sys.path.insert(0, _SKILL)
        from svg_utils import SVGDrawer, save_svg, rasterize_svg
        from evaluator import evaluate_svg
        from svg2pptx import svg_to_pptx
        from design_brief import DesignBrief, ColorSpec
        from semantic_qa import run_semantic_qa
        
        OUT = Path(__file__).resolve().parent
        NAME = "llama_cpp_arch"
        
        W, H = 1400, 1060
        
        # ---- palette: S2 Categorical (Okabe-Ito) ---------------------------------
        # Each layer = (fill tint, matching stroke). Distinct hues → layer identity is
        # visible. All fills are light pastels (L>0.8); all strokes medium (0.2<L<0.8),
        # so no within-channel luminance clash. 8 accents total.
        LAYERS = [
            ("Model Layer",               "#FAEED1", "#E69F00"),  # Orange
            ("Execution Layer",           "#E1F2FB", "#56B4E9"),  # Sky Blue
            ("Computation Graph Layer",   "#D1EEE6", "#009E73"),  # Bluish-Green
            ("Backend Abstraction Layer", "#D1E6F1", "#0072B2"),  # Deep Blue
        ]
        TXT   = "#222222"   # primary text  (neutral -> not counted as accent)
        SUB   = "#555555"   # subtitle text (neutral)
        GRAY  = "#4D4D4D"   # connectors + arrowheads (neutral)
        NSTK  = "#555555"   # neutral stroke for process-section cards (neutral)
        CARD  = "#FFFFFF"
        PANEL = "#F7F7F7"   # near-white section panel (neutral)
        DIV   = "#CCCCCC"   # panel border (neutral)
        
        drawer = SVGDrawer(W, H, bg="#FFFFFF")
        drawer.arrow_head("arrow", GRAY)
        
        
        def card(x, y, w, h, nid, title, sub=None, stroke="#555555", sw=1.5):
            """White op card with optional subtitle. Registered as a node when nid given."""
            drawer.rect(x, y, w, h, rx=6, ry=6, fill=CARD, stroke=stroke, stroke_width=sw,
                        node_id=nid, node_kind="op", bbox=False)
            if sub:
                drawer.text(x + w / 2, y + h * 0.36, title, 12, fill=TXT, weight="bold", bbox=False)
                drawer.text(x + w / 2, y + h * 0.70, sub, 10, fill=SUB, bbox=False)
            else:
                drawer.text(x + w / 2, y + h / 2, title, 12, fill=TXT, weight="bold", bbox=False)
        
        
        def flow(a, b):
            drawer.connect(a, "right", b, "left", stroke=GRAY, stroke_width=1.5, marker_end="arrow")
        
        
        CLX, CLW = 40, 1320
        LH, LGAP = 120, 20
        NODE_IDS = ["L_model", "L_exec", "L_graph", "L_backend"]
        
        
        def layer_y(i):
            return 88 + i * (LH + LGAP)
        
        
        # ---- title ---------------------------------------------------------------
        drawer.text(W / 2, 36, "llama.cpp — Architecture", 20, fill=TXT, weight="bold", bbox=False)
        
        # ============================================================ Core Library
        drawer.text(CLX, 70, "Core Library", 14, fill=TXT, weight="bold", anchor="start", bbox=False)
        
        # Per-layer card contents: (x, y_off, w, h, nid, title, subtitle)
        LAYER_CARDS = [
            [  # Model Layer
                (240, 350, 66, "m_model", "llama_model", "weights & hyperparams (GGUF)"),
                (610, 350, 66, "m_vocab", "llama_vocab", "tokenizer: BPE · SPM · WPM"),
                (980, 360, 66, "m_map", "Model Arch Mapping", "llama_model_mapping() → impl"),
            ],
            [  # Execution Layer
                (240, 350, 66, "e_ctx", "llama_context", "execution state · memory"),
                (610, 350, 66, "e_batch", "llama_batch / ubatch", "token input structures"),
                (980, 360, 66, "e_kv", "llama_kv_cache", "K/V vectors · avoid recompute"),
            ],
            [  # Computation Graph Layer
                (240, 500, 66, "g_cgraph", "ggml_cgraph", "DAG of ggml_tensor ops"),
                (760, 580, 66, "g_build", "Graph Building", "build_graph(): pooling + sampling"),
            ],
            [  # Backend Abstraction Layer
                (240, 300, 66, "b_backend", "ggml-backend", "SIMD + GPU offload"),
            ],
        ]
        BACKEND_DEV = ["CPU", "CUDA", "Metal", "Vulkan", "SYCL"]
        
        for li, (label, lfill, lstroke) in enumerate(LAYERS):
            y = layer_y(li)
            drawer.rect(CLX, y, CLW, LH, rx=8, ry=8, fill=lfill, stroke=lstroke, stroke_width=1.5,
                        node_id=NODE_IDS[li], node_kind="layer", bbox=True)
            drawer.text(CLX + 18, y + 22, label, 14, fill=TXT, weight="bold", anchor="start", bbox=False)
            cy = y + 42
            for cx, cw, ch, nid, title, sub in LAYER_CARDS[li]:
                card(cx, cy, cw, ch, nid, title, sub, stroke=lstroke)
            if li == 3:  # backend devices
                for i, name in enumerate(BACKEND_DEV):
                    card(562 + i * 156, cy, 140, 66, None, name, stroke=lstroke)
        
        # layer dependency spine (top -> bottom)
        drawer.connect(NODE_IDS[0], "bottom", NODE_IDS[1], "top", stroke=GRAY, stroke_width=1.5, marker_end="arrow")
        drawer.connect(NODE_IDS[1], "bottom", NODE_IDS[2], "top", stroke=GRAY, stroke_width=1.5, marker_end="arrow")
        drawer.connect(NODE_IDS[2], "bottom", NODE_IDS[3], "top", stroke=GRAY, stroke_width=1.5, marker_end="arrow")
        
        # ============================================== Inference Execution Flow
        iy = 648
        drawer.rect(CLX, iy, CLW, 124, rx=8, ry=8, fill=PANEL, stroke=DIV, stroke_width=1,
                    bbox=True, role="background")
        drawer.text(CLX + 18, iy + 24, "Inference Execution Flow", 14, fill=TXT, weight="bold",
                    anchor="start", bbox=False)
        stages = [
            ("s1", "Token IDs", "input"),
            ("s2", "llama_batch", "batch API"),
            ("s3", "llama_ubatch", "internal rep"),
            ("s4", "build_graph", "construct DAG"),
            ("s5", "graph_compute", "backend exec"),
            ("s6", "logits / embeds", "output"),
        ]
        sx0, sw, sgap = 60, 188, 30
        for i, (nid, t, sub) in enumerate(stages):
            card(sx0 + i * (sw + sgap), iy + 46, sw, 56, nid, t, sub, stroke=NSTK)
        for i in range(len(stages) - 1):
            flow(stages[i][0], stages[i + 1][0])
        
        # ============================================== Server Architecture
        sy_c = 786
        drawer.rect(CLX, sy_c, CLW, 250, rx=8, ry=8, fill=PANEL, stroke=DIV, stroke_width=1,
                    bbox=True, role="background")
        drawer.text(CLX + 18, sy_c + 24, "Server Architecture — llama-server", 14, fill=TXT,
                    weight="bold", anchor="start", bbox=False)
        
        # server_context container (holds llama_context + active slots); center y = 912
        drawer.rect(510, 822, 520, 180, rx=8, ry=8, fill=CARD, stroke=NSTK, stroke_width=1.5,
                    node_id="sv_ctx", node_kind="block", bbox=True)
        drawer.text(770, 845, "server_context", 12, fill=TXT, weight="bold", bbox=False)
        drawer.text(770, 863, "holds llama_context + active slots", 10, fill=SUB, bbox=False)
        drawer.text(770, 885, "server_slot — parallel sequences", 10, fill=SUB, bbox=False)
        for i, x in enumerate((530, 695, 860)):
            card(x, 900, 150, 72, f"slot{i + 1}", f"slot {i + 1}", f"seq #{i + 1}", stroke=NSTK)
        
        # I/O components (centered on y=912 to align with sv_ctx)
        card(70, 867, 200, 90, "sv_routes", "server_routes", "HTTP interface · middleware", stroke=NSTK)
        card(300, 867, 180, 90, "sv_queue", "server_queue", "task submission", stroke=NSTK)
        card(1060, 867, 270, 90, "sv_resp", "server_response", "thread-safe results", stroke=NSTK)
        flow("sv_routes", "sv_queue")
        flow("sv_queue", "sv_ctx")
        flow("sv_ctx", "sv_resp")
        
        # ============================================================ Design Brief
        # Declared from input.md's intent: Section 1 defines the four stacked Core
        # Library layer bands (Model / Execution / Graph / Backend) as a top-down
        # dependency/abstraction stack ("top depends on those below", arrows linking
        # each band to the one beneath) -> band layout, vertical flow axis, palette
        # from the S2 Okabe-Ito layer tints. The chain stays EMPTY: the dependency
        # spine snaps band-border to band-border, and the arrowhead retraction leaves
        # the path end in the 20px gutter (outside the target band box), so the
        # contract checker cannot attribute those edges as inter-layer stages -- an
        # unverifiable chain is not declared. The Inference Flow and Server sections
        # are runtime-process panels (neutral gray, background role), not tinted
        # library bands, so they stay outside the band contract.
        BRIEF = DesignBrief(
            scheme="S2",
            layout="band",
            flow="top-down",
            palette_role={nid: ColorSpec(fill, stroke)
                          for nid, (_label, fill, stroke) in zip(NODE_IDS, LAYERS)},
            flow_chain=("L_model", "L_exec", "L_graph", "L_backend"),
        )
        
        # ============================================================ evaluate + save
        score, report = evaluate_svg(drawer)
        print(f"Quality Score: {score}")
        for line in report:
            print(line)
        
        qa = run_semantic_qa(drawer, expected_size=(W, H), brief=BRIEF)
        print("Semantic QA:")
        for line in qa.report():
            print(line)
        
        save_svg(drawer.render(), str(OUT / f"{NAME}.svg"))
        rasterize_svg(str(OUT / f"{NAME}.svg"), str(OUT / f"{NAME}.png"), width=W)
        svg_to_pptx(drawer.render(), OUT / f"{NAME}.pptx")
        BRIEF.write(str(OUT / "brief.json"))
        print("Saved triplet to", OUT)
        
      • input.md 1.4 KB
        # llama.cpp Architecture
        
        Draw a three-section top-to-bottom layout for the llama.cpp project.
        
        ## Section 1: Core Library
        
        Four stacked layer bands (each a container with components):
        - **Model Layer** — GGUF format, tokenizer, weight loading, quantization formats.
        - **Execution Layer** — compute graph, tensor operations, memory management.
        - **Graph Layer** — operator definitions, kernel dispatch, shape inference.
        - **Backend Layer** — CPU (BLAS), CUDA, Metal, Vulkan backends.
        
        The four layers form a dependency/abstraction stack (top depends on those
        below), conveyed by arrows linking each band to the one beneath it.
        
        ## Section 2: Inference Execution Flow
        
        A horizontal 6-stage pipeline showing the inference path:
        prompt → tokenize → embed → forward pass → sample → detokenize → output.
        Connect with solid arrows left → right.
        
        ## Section 3: Server Architecture (llama-server)
        
        Routes → request queue → context (slots) → model eval → response.
        Show how concurrent slots share the model context: a central
        `server_context` block holds the active slots, with I/O components
        (routes / queue on one side, model eval / response on the other) flanking it
        on a single horizontal flow line.
        
        Layout, palette, typography, and all geometry are yours to design — follow
        the architecture-drawer skill's design system and let the evaluator guide
        iteration.
        
    • 20260729_pi_agent_architecture
      • brief.json 575 B
        {
          "scheme": "S1",
          "layout": "band",
          "flow": "top-down",
          "flow_chain": [],
          "palette_role": {
            "interface": {
              "fill": "#d5e1eb",
              "stroke": "#1b3a5c"
            },
            "coding_agent": {
              "fill": "#bbcedf",
              "stroke": "#1b3a5c"
            },
            "agent_core": {
              "fill": "#9bb9d1",
              "stroke": "#1b3a5c"
            },
            "ai_abstraction": {
              "fill": "#769ebf",
              "stroke": "#1b3a5c"
            },
            "llm_api": {
              "fill": "#aac8de",
              "stroke": "#1b3a5c"
            },
            "event_seq": {
              "fill": "#eeeeee",
              "stroke": "#b0b0b0"
            }
          }
        }
      • gen.py 8 KB
        #!/usr/bin/env python3
        """pi agent architecture diagram — labels INSIDE components, no overflow."""
        import sys
        
        import os, sys
        _HERE = os.path.dirname(os.path.abspath(__file__))
        _SKILL = os.path.normpath(os.path.join(_HERE, "..", "..", "scripts"))
        if _SKILL not in sys.path:
            sys.path.insert(0, _SKILL)
        from svg_utils import SVGDrawer, save_svg, rasterize_svg
        from evaluator import evaluate_svg
        from svg2pptx import svg_to_pptx, PptxConfig
        from design_brief import DesignBrief, ColorSpec  # noqa: E402
        from semantic_qa import run_semantic_qa  # noqa: E402
        from pathlib import Path
        
        OUT = Path(__file__).resolve().parent
        NAME = "pi_agent_architecture"
        
        W, H = 1260, 860
        d = SVGDrawer(W, H, bg="#FFFFFF")
        d.arrow_head("dn", "#1B3A5C")
        d.arrow_head("up", "#D47130")
        d.arrow_head("tl", "#4D4D4D")
        
        # Palette (7 accents)
        SK = "#1B3A5C"; EV = "#D47130"
        LB = ["#D5E1EB","#BBCEDF","#9BB9D1","#769EBF"]
        LF = "#AAC8DE"; WH = "#FFFFFF"
        TX = ["#1A1A1A","#555555"]
        
        F = [20, 14, 12, 10]; BB = False
        
        LX, LW = 60, 730
        HH, CH, GAP = 36, 62, 18   # header, component height, gap between layers
        
        layers = [
            ("① 交互界面层 · Interface",           "",    4, 150),
            ("② 编码智能体层 · pi-coding-agent",  "@earendil-works/pi-coding-agent", 4, 152),
            ("③ 智能体核心层 · pi-agent-core",    "@earendil-works/pi-agent-core",   3, 205),
            ("④ AI 抽象层 · pi-ai",              "@earendil-works/pi-ai",          3, 206),
        ]
        # Brief palette keys for the four layer bands (identity for the contract).
        LAYER_IDS = ["interface", "coding_agent", "agent_core", "ai_abstraction"]
        
        ly = 52; all_pos = []
        for label, pkg, n, cw in layers:
            lh = HH + CH + 38
            all_pos.append((ly, lh, cw, n, label, pkg))
            ly += lh + GAP
        
        LLMY = ly + 4; LLMH = 40
        INFY = LLMY + LLMH + GAP; INFH = 76
        
        def draw_layer(y, lh, cw, ncomp, label, pkg, idx, nid=None):
            fill = LB[idx % 4]
            d.rect(LX, y, LW, lh, rx=11, fill=fill, stroke=SK, stroke_width=1.5, node_id=nid)
            d.text(LX+16, y+HH//2, label, font_size=F[1], weight="bold", fill=SK, anchor="start", bbox=BB)
            d.line(LX+12, y+HH, LX+LW-12, y+HH, stroke=SK, stroke_width=0.6)
            if pkg:
                d.text(LX+LW//2, y+lh-8, pkg, font_size=F[3], fill="#6699BB", anchor="middle", bbox=BB)
            gap = (LW - ncomp*cw) // (ncomp + 1)
            cx0 = LX + gap; cy0 = y + HH + (lh - HH - CH)//2
            for j in range(ncomp):
                cx = cx0 + j*(cw + gap)
                d.rect(cx, cy0, cw, CH, rx=7, fill=WH, stroke=SK, stroke_width=1)
        
        for idx, (y, lh, cw, ncomp, label, pkg) in enumerate(all_pos):
            draw_layer(y, lh, cw, ncomp, label, pkg, idx, LAYER_IDS[idx])
        
        # Labels INSIDE component rects
        def clab(lx, ty, title, sub=""):
            y_center = ty + CH//2
            if sub:
                d.text(lx, y_center-8, title, font_size=F[2], weight="bold", fill=TX[0], anchor="middle", bbox=BB)
                d.text(lx, y_center+10, sub, font_size=F[3], fill=TX[1], anchor="middle", bbox=BB)
            else:
                d.text(lx, y_center+1, title, font_size=F[2], weight="bold", fill=TX[0], anchor="middle", bbox=BB)
        
        # Layer 0
        y0, lh0, cw0, n0 = all_pos[0][0], all_pos[0][1], all_pos[0][2], all_pos[0][3]
        cy0 = y0 + HH + (lh0-HH-CH)//2
        gap0 = (LW - n0*cw0)//(n0+1)
        for j, (t, s) in enumerate([("TUI","交互式终端"),("RPC","JSONL 协议"),("Print","打印 / JSON"),("SDK","createAgentSession()")]):
            clab(LX+gap0+j*(cw0+gap0)+cw0//2, cy0, t, s)
        
        # Layer 1
        y1, lh1, cw1, n1 = all_pos[1][0], all_pos[1][1], all_pos[1][2], all_pos[1][3]
        cy1 = y1 + HH + (lh1-HH-CH)//2
        gap1 = (LW - n1*cw1)//(n1+1)
        for j, (t, s) in enumerate([("AgentSession","智能体协调器"),("SessionManager","持久化 · 压缩"),
                                     ("ExtensionRunner","扩展 · 自定义工具"),("ResourceLoader","技能 · 模板 · 主题")]):
            clab(LX+gap1+j*(cw1+gap1)+cw1//2, cy1, t, s)
        
        # Layer 2
        y2, lh2, cw2, n2 = all_pos[2][0], all_pos[2][1], all_pos[2][2], all_pos[2][3]
        cy2 = y2 + HH + (lh2-HH-CH)//2
        gap2 = (LW - n2*cw2)//(n2+1)
        for j, (t, s) in enumerate([("Agent / agentLoop","回合生命周期管理"),("AgentContext","systemPrompt · messages · tools"),
                                     ("AgentEvent","事件序列 · 工具执行")]):
            clab(LX+gap2+j*(cw2+gap2)+cw2//2, cy2, t, s)
        
        # Layer 3
        y3, lh3, cw3, n3 = all_pos[3][0], all_pos[3][1], all_pos[3][2], all_pos[3][3]
        cy3 = y3 + HH + (lh3-HH-CH)//2
        gap3 = (LW - n3*cw3)//(n3+1)
        for j, (t, s) in enumerate([("OpenAI","GPT-4o · o3"),("Anthropic","Claude 3.5/4"),("Google","Gemini")]):
            clab(LX+gap3+j*(cw3+gap3)+cw3//2, cy3, t, s)
        
        # LLM box
        d.rect(LX, LLMY, LW, LLMH, rx=9, fill=LF, stroke=SK, stroke_width=1.5, dashed="6,4", node_id="llm_api")
        d.text(LX+LW//2, LLMY+LLMH//2+1, "LLM API · OpenAI / Anthropic / Google 统一流式调用",
               font_size=F[2], anchor="middle", weight="bold", fill=SK, bbox=BB)
        
        # Info box
        d.rect(LX, INFY, LW, INFH, rx=9, fill="#EEEEEE", stroke="#B0B0B0", stroke_width=1, node_id="event_seq")
        d.text(LX+18, INFY+18, "事件序列示例(一次 prompt()调用):", font_size=F[2], weight="bold", fill=TX[0], anchor="start", bbox=BB)
        yy = INFY+38
        for a, b in [("agam_start → turn_start → message_start … message_end →","→ 回合开始,消息流式生成"),
                     ("tool_execution_start … tool_execution_end → turn_end → agent_end","→ 工具执行(如果有),回合收敛,会话结束")]:
            d.text(LX+18, yy, a, font_size=F[3], fill=TX[1], anchor="start", bbox=BB, font_family="Consolas,monospace")
            d.text(LX+420, yy, b, font_size=F[3], fill="#777", anchor="start", bbox=BB)
            yy += 17
        
        # Flow arrows
        FL, FR = 24, LX+LW-14
        bts = [p[0]+p[1] for p in all_pos]
        tps = [p[0] for p in all_pos]
        
        for tp, bt in [(bts[0],tps[1]),(bts[1],tps[2]),(bts[2],tps[3]),(bts[3],LLMY)]:
            d.line(FL, tp+14, FL, bt-14, stroke=SK, stroke_width=2.8, marker_end="dn")
        d.text(FL+14, (bts[0]+tps[1])//2+4, "请求下行 ↓", font_size=F[3], fill=TX[1], anchor="start", bbox=BB)
        
        # Right spine (up, AgentEvent). All segments live in the inter-band gutters;
        # the first one kisses the LLM box's TOP edge (LLMY+8) instead of starting
        # 24px INSIDE it — the old LLMY+LLMH-16 start painted the orange arrow over
        # the LLM component's interior (箭头盖在组件上).
        for y_from, y_to in [(LLMY+8, bts[3]+16), (tps[3]-16, bts[2]+16),
                             (tps[2]-16, bts[1]+16), (tps[1]-16, bts[0]+16)]:
            d.line(FR, y_from, FR, y_to, stroke=EV, stroke_width=2.8, marker_end="up")
        d.text(FR+10, (bts[0]+tps[1])//2+4, "↑ AgentEvent 事件流", font_size=F[3], fill=EV, anchor="start", bbox=BB)
        
        # Tool arrow
        ty = all_pos[2][0]+all_pos[2][1]-16
        d.line(LX+LW+8, ty, LX+LW+104, ty, stroke=TX[1], stroke_width=1.5, marker_end="tl", dashed="5,3")
        mx = LX+LW+56
        d.text(mx, ty-14, "工具执行", font_size=F[3], fill=TX[1], anchor="middle", bbox=BB)
        d.text(mx, ty+17, "Bash · 文件操作", font_size=10, fill="#888", anchor="middle", bbox=BB)
        
        # Design Brief (Step 1) — declared from input.md's band structure; the
        # contract the rendered SVG is asserted against. The four package bands plus
        # the LLM API box are palette members; event_seq is a text-only band (the
        # event-lifecycle example). Both spines are antiparallel (request down /
        # AgentEvent up) and terminate in the gutters, so no chain is declared.
        BRIEF = DesignBrief(
            scheme="S1",
            layout="band",
            flow="top-down",
            palette_role={
                "interface":      ColorSpec(LB[0], SK),
                "coding_agent":   ColorSpec(LB[1], SK),
                "agent_core":     ColorSpec(LB[2], SK),
                "ai_abstraction": ColorSpec(LB[3], SK),
                "llm_api":        ColorSpec(LF, SK),
                "event_seq":      ColorSpec("#EEEEEE", "#B0B0B0"),
            },
            flow_chain=(),
        )
        
        svg = d.render()
        score, rep = evaluate_svg(d)
        print(f"Score: {score}")
        for r in rep: print(f"  {r}")
        qa = run_semantic_qa(d, expected_size=(W, H), brief=BRIEF)
        print("Semantic QA:")
        for line in qa.report():
            print(line)
        
        sp = str(OUT/f"{NAME}.svg")
        save_svg(svg, sp)
        rasterize_svg(sp, str(OUT/f"{NAME}.png"), width=1260)
        try:
            svg_to_pptx(svg, str(OUT/f"{NAME}.pptx"), config=PptxConfig(slide_w=13.333, slide_h=7.5, scale=2.0))
        except Exception as e: print(f"[pptx: {e}]")
        BRIEF.write(str(OUT / "brief.json"))
        print(f"\n✓ {NAME}.svg / .png /.pptx")
      • input.md 2.2 KB
        # π Agent — Architecture Diagram
        
        Draw an AI agent runtime architecture with labels INSIDE component rectangles
        (no text overflow). Bilingual labels (English title + Chinese subtitle).
        
        ## Layout
        
        Left column: 4 horizontal layer bands stacked top→bottom, each a container
        holding 3–4 component cards. Below the bands sit two full-width boxes (LLM API,
        then an event-sequence info box).
        
        1. **① 交互界面层 · Interface** (no package) — TUI (交互式终端), RPC (JSONL 协议),
           Print (打印 / JSON), SDK (createAgentSession()).
        2. **② 编码智能体层 · pi-coding-agent** (`@earendil-works/pi-coding-agent`) —
           AgentSession (智能体协调器), SessionManager (持久化 · 压缩),
           ExtensionRunner (扩展 · 自定义工具), ResourceLoader (技能 · 模板 · 主题).
        3. **③ 智能体核心层 · pi-agent-core** (`@earendil-works/pi-agent-core`) —
           Agent / agentLoop (回合生命周期管理), AgentContext (systemPrompt · messages · tools),
           AgentEvent (事件序列 · 工具执行).
        4. **④ AI 抽象层 · pi-ai** (`@earendil-works/pi-ai`) — OpenAI (GPT-4o · o3),
           Anthropic (Claude 3.5/4), Google (Gemini).
        
        Below the layers:
        - **LLM API box** — dashed border, "LLM API · OpenAI / Anthropic / Google 统一流式调用".
        - **Event sequence example** — a monospace info box showing one `prompt()` call's
          event lifecycle (agent_start → turn_start → message_start … → tool_execution … →
          agent_end) with Chinese explanations per line.
        
        ## Flow
        
        - **Left spine (down)** — 请求下行 ↓: request travels top → bottom through all
          4 layers and into the LLM API box.
        - **Right spine (up)** — ↑ AgentEvent 事件流: event stream bubbles bottom → top
          back up from the LLM API box through every layer. Use a visually distinct
          color for it (e.g. an event/attention accent vs the request's primary dark).
        - **Tool execution arrow** (dashed, far right of the Agent Core band) — points
          outward to "Bash · 文件操作".
        
        Flow spines must route OUTSIDE the card area — never slice through a band's
        filled rectangle or the LLM box interior. Layout, exact palette, typography,
        and all geometry are yours to design — follow the architecture-drawer skill's
        design system and let the evaluator guide iteration.
        
    • 20260730_vllm_arch
      • brief.json 553 B
        {
          "scheme": "S1",
          "layout": "band",
          "flow": "top-down",
          "flow_chain": [
            "api_server",
            "llm_engine",
            "exec_layer"
          ],
          "palette_role": {
            "api_server": {
              "fill": "#e8eef3",
              "stroke": "#1b3a5c"
            },
            "llm_engine": {
              "fill": "#d5e1eb",
              "stroke": "#1b3a5c"
            },
            "kv_cache": {
              "fill": "#e8eef3",
              "stroke": "#1b3a5c"
            },
            "exec_layer": {
              "fill": "#b8cde0",
              "stroke": "#1b3a5c"
            },
            "optimizations": {
              "fill": "#e8eef3",
              "stroke": "#1b3a5c"
            }
          }
        }
      • gen.py 10.2 KB
        """vLLM architecture diagram generator (architecture-drawer skill).
        
        Layered top-to-bottom pipeline (request -> response) with the signature
        PagedAttention / paged KV-cache abstraction on the right.
        
        Design choices driven by the evaluator:
          * Big containers carry role="layer" (gutter-checked) but are NOT registered
            as nodes, so cross-container edges never trigger routes-through.
          * Dark blue (#1B3A5C) appears ONLY as stroke; text + arrowheads use neutral
            grays (#1A1A1A / #555555) -> no dark fill -> no luminance clash.
          * Font tiers are exactly {20, 14, 12, 10}.
        """
        import sys
        from pathlib import Path
        
        
        import os, sys
        _HERE = os.path.dirname(os.path.abspath(__file__))
        _SKILL = os.path.normpath(os.path.join(_HERE, "..", "..", "scripts"))
        if _SKILL not in sys.path:
            sys.path.insert(0, _SKILL)
        from svg_utils import SVGDrawer, save_svg, rasterize_svg  # noqa: E402
        from evaluator import evaluate_svg  # noqa: E402
        from svg2pptx import svg_to_pptx, PptxConfig  # noqa: E402
        from design_brief import DesignBrief, ColorSpec  # noqa: E402
        from semantic_qa import run_semantic_qa  # noqa: E402
        
        OUT = Path(__file__).resolve().parent
        NAME = "vllm_arch"
        
        # ---- palette (S1 Monochrome Blue) ------------------------------------------
        DARK = "#1B3A5C"   # strokes / borders / edges only
        INK = "#1A1A1A"    # primary text (neutral)
        MUTE = "#555555"   # secondary text (neutral)
        T1 = "#D5E1EB"     # engine / title bar tint
        T2 = "#E8EEF3"     # api / kv / kernels tint
        T3 = "#B8CDE0"     # exec / allocated-block tint
        
        W, H = 1240, 970
        d = SVGDrawer(W, H, bg="#FFFFFF")
        d.arrow_head("arrow", INK)  # neutral arrowhead -> not counted as a dark fill
        
        
        def node(x, y, w, h, nid, fill="white", stroke=DARK, sw=1.5, rx=6, role=None,
                 bbox=True):
            d.rect(x, y, w, h, rx=rx, ry=rx, fill=fill, stroke=stroke,
                   stroke_width=sw, node_id=nid, bbox=bbox, role=role)
        
        
        def layer(x, y, w, h, fill, role="layer", nid=None):
            d.rect(x, y, w, h, rx=8, ry=8, fill=fill, stroke=DARK, stroke_width=1.5,
                   bbox=True, role=role, node_id=nid)
        
        
        def txt(x, y, s, fs=12, fill=INK, weight="normal", anchor="middle", bbox=True):
            d.text(x, y, s, font_size=fs, fill=fill, weight=weight, anchor=anchor, bbox=bbox)
        
        
        def bullets(x, y, lines, fs=12, fill=INK, anchor="start"):
            d.multiline_text(x, y, lines, font_size=fs, fill=fill, anchor=anchor)
        
        
        def E(a, fa, b, fb, dashed=False):
            d.connect(a, fa, b, fb, stroke=DARK, stroke_width=1.5,
                      marker_end="arrow", dashed=dashed)
        
        
        # ---------------------------------------------------------------------------
        # 1. Title bar
        # ---------------------------------------------------------------------------
        layer(0, 0, W, 52, T1, role="background")
        txt(W / 2, 21, "vLLM — High-Throughput LLM Serving with PagedAttention",
            fs=20, fill=INK, weight="bold")
        txt(W / 2, 41, "PagedAttention · Continuous Batching · High Throughput",
            fs=12, fill=MUTE)
        
        # ---------------------------------------------------------------------------
        # 2. Client
        # ---------------------------------------------------------------------------
        node(510, 80, 220, 62, "client")
        txt(620, 100, "Client / Application", fs=14, weight="bold")
        txt(620, 122, "OpenAI API · HTTP / SDK", fs=12, fill=MUTE)
        
        # ---------------------------------------------------------------------------
        # 3. API Server layer
        # ---------------------------------------------------------------------------
        layer(150, 166, 940, 128, T2, nid="api_server")
        txt(168, 190, "API Server", fs=14, weight="bold", anchor="start")
        node(210, 214, 360, 60, "fastapi")
        txt(390, 234, "FastAPI / ASGI Server", fs=14, weight="bold")
        txt(390, 254, "request routing · streaming", fs=12, fill=MUTE)
        node(660, 214, 400, 60, "openai_api")
        txt(860, 234, "OpenAI-compatible API", fs=14, weight="bold")
        txt(860, 254, "/v1/completions · /v1/chat/completions", fs=12, fill=MUTE)
        
        # ---------------------------------------------------------------------------
        # 4. LLM Engine (Core) layer
        # ---------------------------------------------------------------------------
        layer(40, 318, 740, 300, T1, nid="llm_engine")
        txt(58, 340, "LLM Engine (Core)", fs=14, weight="bold", anchor="start")
        node(270, 360, 280, 46, "async_engine")
        txt(410, 383, "AsyncLLMEngine", fs=14, weight="bold")
        node(64, 436, 330, 160, "scheduler")
        txt(229, 458, "Scheduler", fs=14, weight="bold")
        bullets(86, 482, [
            "• FCFS + priority scheduling",
            "• Continuous batching",
            "• Preemption on KV-cache OOM",
            "• Decode-step orchestration",
        ])
        node(426, 436, 334, 160, "blockmgr")
        txt(593, 458, "BlockManager", fs=14, weight="bold")
        bullets(448, 482, [
            "• Logical ↔ physical blocks",
            "• Block tables (paging)",
            "• Copy-on-write fork",
            "• Reference counting",
        ])
        
        # ---------------------------------------------------------------------------
        # 5. Paged KV Cache layer (right)
        # ---------------------------------------------------------------------------
        layer(800, 318, 400, 300, T2, nid="kv_cache")
        txt(818, 340, "Paged KV Cache (GPU Memory)", fs=14, weight="bold", anchor="start")
        
        # ① logical blocks (decoration)
        txt(818, 366, "① Logical blocks / sequence", fs=12, weight="bold", anchor="start")
        for i, lx in enumerate((818, 874, 930)):
            d.rect(lx, 378, 52, 30, rx=4, ry=4, fill="white", stroke=DARK,
                   stroke_width=1, role="decoration", bbox=False)
            txt(lx + 26, 393, "L%d" % i, fs=12)
        
        # ② block table
        txt(818, 428, "② Block table (logical → physical)", fs=12, weight="bold",
            anchor="start")
        for label, lx in (("L0 → P3", 818), ("L1 → P0", 918), ("L2 → P6", 1018)):
            txt(lx, 448, label, fs=12, anchor="start")
        
        # ③ physical blocks node (connectable anchor for management / KV access)
        node(820, 474, 360, 124, "phys_blocks", fill="white")
        txt(1000, 490, "Physical KV Cache Blocks", fs=12, weight="bold")
        alloc = {"P0", "P3", "P6"}
        grid_x = (903, 953, 1003, 1053)
        for row, gy in enumerate((504, 536)):
            for col, gx in enumerate(grid_x):
                idx = row * 4 + col
                name = "P%d" % idx
                d.rect(gx, gy, 44, 26, rx=3, ry=3,
                       fill=(T3 if name in alloc else "white"), stroke=DARK,
                       stroke_width=1, role="decoration", bbox=False)
                txt(gx + 22, gy + 13, name, fs=12)
        txt(1000, 582, "allocated blocks need not be contiguous → low fragmentation",
            fs=10, fill=MUTE)
        
        # ---------------------------------------------------------------------------
        # 6. Execution layer (GPU workers)
        # ---------------------------------------------------------------------------
        layer(40, 664, 1160, 236, T3, nid="exec_layer")
        txt(58, 686, "Execution Layer", fs=14, weight="bold", anchor="start")
        node(90, 708, 240, 58, "worker")
        txt(210, 730, "Worker", fs=14, weight="bold")
        txt(210, 750, "cache · device mgmt", fs=12, fill=MUTE)
        node(360, 708, 300, 58, "modelrunner")
        txt(510, 730, "ModelRunner", fs=14, weight="bold")
        txt(510, 750, "forward pass · sampling", fs=12, fill=MUTE)
        node(690, 708, 300, 58, "pagedattn")
        txt(840, 730, "PagedAttention Kernel", fs=14, weight="bold")
        txt(840, 750, "blocked KV · flash attn", fs=12, fill=MUTE)
        
        d.rect(90, 786, 1090, 94, rx=6, ry=6, fill=T2, stroke=DARK, stroke_width=1.5,
               bbox=True, role="layer", node_id="optimizations")
        txt(110, 808, "Optimizations & CUDA Kernels", fs=14, weight="bold", anchor="start")
        bullets(110, 832, [
            "• Continuous batching (iteration-level)    • Prefix caching    • Chunked prefill    • Speculative decoding",
            "• Quantization: AWQ · GPTQ · FP8    • Tensor / pipeline parallelism    • LoRA multi-adapter    • Prefix-aware scheduling",
        ])
        
        # ---------------------------------------------------------------------------
        # 7. Edges (request flow solid; cache/block management dashed)
        # ---------------------------------------------------------------------------
        E("client", "bottom", "openai_api", "top")            # request in
        E("fastapi", "right", "openai_api", "left")           # routing
        E("openai_api", "bottom", "async_engine", "top")      # enqueue
        E("async_engine", "bottom", "scheduler", "top")       # schedule
        E("scheduler", "right", "blockmgr", "left", dashed=True)   # alloc / free
        E("scheduler", "bottom", "worker", "top")             # scheduled batch -> GPU
        E("blockmgr", "right", "phys_blocks", "left", dashed=True)  # manage physical
        E("worker", "right", "modelrunner", "left")
        E("modelrunner", "right", "pagedattn", "left")
        E("pagedattn", "top", "phys_blocks", "bottom")        # KV read / write
        
        # ---------------------------------------------------------------------------
        # 8. Legend
        # ---------------------------------------------------------------------------
        d.rect(40, 916, 560, 40, rx=6, ry=6, fill="white", stroke=DARK,
               stroke_width=1.2, bbox=False, role="legend")
        d.line(60, 936, 100, 936, stroke=DARK, stroke_width=1.5, role="legend")
        txt(110, 936, "data / request flow", fs=12, anchor="start", bbox=False)
        d.line(300, 936, 340, 936, stroke=DARK, stroke_width=1.5, role="legend",
               dashed="6,3")
        txt(352, 936, "cache / block management", fs=12, anchor="start", bbox=False)
        
        # ---------------------------------------------------------------------------
        # 9. Design Brief (Step 1) — declared from input.md's layer list; the
        #    contract the rendered SVG is asserted against. kv_cache is a SIDE band
        #    (memory column) and optimizations a text-only band: palette members,
        #    not chain stages. The chain is input.md's request flow.
        # ---------------------------------------------------------------------------
        BRIEF = DesignBrief(
            scheme="S1",
            layout="band",
            flow="top-down",
            palette_role={
                "api_server":    ColorSpec(T2, DARK),
                "llm_engine":    ColorSpec(T1, DARK),
                "kv_cache":      ColorSpec(T2, DARK),
                "exec_layer":    ColorSpec(T3, DARK),
                "optimizations": ColorSpec(T2, DARK),
            },
            flow_chain=("api_server", "llm_engine", "exec_layer"),
        )
        
        score, report = evaluate_svg(d)
        print("Quality Score: %d" % score)
        for line in report:
            print(line)
        
        qa = run_semantic_qa(d, expected_size=(W, H), brief=BRIEF)
        print("Semantic QA:")
        for line in qa.report():
            print(line)
        
        svg = d.render()
        save_svg(svg, str(OUT / (NAME + ".svg")))
        rasterize_svg(str(OUT / (NAME + ".svg")), str(OUT / (NAME + ".png")), width=W)
        svg_to_pptx(svg, str(OUT / (NAME + ".pptx")),
                    config=PptxConfig(slide_w=13.333, slide_h=10.41, scale=1.0))
        BRIEF.write(str(OUT / "brief.json"))
        print("DONE")
        
      • input.md 2.2 KB
        # vLLM — High-Throughput LLM Serving with PagedAttention
        
        Draw a layered top-to-bottom pipeline (request → response) for the vLLM
        inference serving stack, with the PagedAttention / paged KV-cache abstraction
        on the right.
        
        ## Layers (top → bottom)
        
        1. **Title bar** — "vLLM — High-Throughput LLM Serving with PagedAttention",
           subtitle "PagedAttention · Continuous Batching · High Throughput".
        2. **Client** — single node: "Client / Application" (OpenAI API · HTTP / SDK).
        3. **API Server** — container with two nodes: "FastAPI / ASGI Server" (request
           routing · streaming) and "OpenAI-compatible API"
           (/v1/completions · /v1/chat/completions).
        4. **LLM Engine (Core)** — left container with three nodes:
           - AsyncLLMEngine (top center)
           - Scheduler (left, bullets: FCFS + priority scheduling · continuous batching ·
             preemption on KV-cache OOM · decode-step orchestration)
           - BlockManager (right, bullets: logical ↔ physical blocks · block tables
             (paging) · copy-on-write fork · reference counting)
        5. **Paged KV Cache (GPU Memory)** — right container: logical blocks row,
           block table mapping (L0→P3 etc.), and a physical KV cache blocks grid
           showing allocated vs free blocks (allocated need not be contiguous → low
           fragmentation).
        6. **Execution Layer (GPU workers)** — bottom container: Worker → ModelRunner →
           PagedAttention Kernel, plus an Optimizations & CUDA Kernels band
           (continuous batching · prefix caching · chunked prefill · speculative
           decoding; quantization AWQ/GPTQ/FP8 · tensor/pipeline parallelism · LoRA
           multi-adapter · prefix-aware scheduling).
        
        A small legend at the bottom distinguishes the two edge kinds.
        
        ## Edges
        
        - **Solid** (data / request flow): client → openai_api; fastapi → openai_api;
          openai_api → async_engine → scheduler → worker → modelrunner → pagedattn →
          phys_blocks.
        - **Dashed** (cache / block management): scheduler ↔ blockmanager (alloc /
          free); blockmanager ↔ phys_blocks (manage physical).
        
        English only. Layout, palette, typography, and all geometry are yours to
        design — follow the architecture-drawer skill's design system and let the
        evaluator guide iteration.
        
    • 20260802_100000_cicd_pipeline_flow
      • brief.json 1.3 KB
        {
          "scheme": "flowchart-roles",
          "layout": "node",
          "flow": "top-down",
          "flow_chain": [],
          "palette_role": {
            "start": {
              "fill": "#d5e8d4",
              "stroke": "#82b366"
            },
            "hook": {
              "fill": "#ffe6cc",
              "stroke": "#d79b00"
            },
            "checkout": {
              "fill": "#e1d5e7",
              "stroke": "#9673a6"
            },
            "build_ok": {
              "fill": "#fff2cc",
              "stroke": "#d6b656"
            },
            "lint": {
              "fill": "#e1d5e7",
              "stroke": "#9673a6"
            },
            "lint_ok": {
              "fill": "#fff2cc",
              "stroke": "#d6b656"
            },
            "tests": {
              "fill": "#e1d5e7",
              "stroke": "#9673a6"
            },
            "test_ok": {
              "fill": "#fff2cc",
              "stroke": "#d6b656"
            },
            "staging": {
              "fill": "#dae8fc",
              "stroke": "#6c8ebf"
            },
            "smoke": {
              "fill": "#dae8fc",
              "stroke": "#6c8ebf"
            },
            "smoke_ok": {
              "fill": "#fff2cc",
              "stroke": "#d6b656"
            },
            "deploy": {
              "fill": "#dae8fc",
              "stroke": "#6c8ebf"
            },
            "released": {
              "fill": "#d5e8d4",
              "stroke": "#82b366"
            },
            "notify": {
              "fill": "#dae8fc",
              "stroke": "#6c8ebf"
            },
            "m1": {
              "fill": "#b0b0b0",
              "stroke": "#666666"
            },
            "m2": {
              "fill": "#b0b0b0",
              "stroke": "#666666"
            },
            "failed": {
              "fill": "#d5e8d4",
              "stroke": "#82b366"
            }
          }
        }
      • gen.py 12.2 KB
        #!/usr/bin/env python3
        """CI/CD deployment pipeline — a process FLOWCHART (role palette, all roles).
        
        The first eval case that is not an architecture diagram. Exercises primitives
        and the documented flowchart role palette that no other case touches:
          - circle()  as green start/end terminators + gray junction merge points
          - decision()  as yellow branch diamonds (zero usages elsewhere in evals/)
          - hexagon()  as orange I/O (parallelogram substitute)
          - rect()     as blue process steps
          - rect() + inset rect() as purple double-border subprocess
        Four quality-gate decisions branch "No" to a shared failure column that
        converges via junction merge points on a single Failed terminator.
        """
        import os
        import sys
        
        _HERE = os.path.dirname(os.path.abspath(__file__))
        _SKILL = os.path.normpath(os.path.join(_HERE, "..", "..", "scripts"))
        if _SKILL not in sys.path:
            sys.path.insert(0, _SKILL)
        
        from svg_utils import SVGDrawer, save_svg, rasterize_svg
        from svg2pptx import svg_to_pptx, PptxConfig
        from evaluator import evaluate_svg
        from design_brief import DesignBrief, ColorSpec
        from semantic_qa import run_semantic_qa
        
        from pathlib import Path
        
        OUT = Path(__file__).resolve().parent
        NAME = "cicd_pipeline_flow"
        
        # Compact TB layout: ~105px center-to-center (well above the evaluator's
        # ≥14px min gap — the diamond's 84px height needs ≥84px c2c). Render the PNG
        # at 2× width for crisp display on high-DPI screens (SVG is vector, so the
        # upscale is lossless).
        W, H = 1000, 1520
        PNG_W = W * 2          # 2× rasterize for a sharp showcase PNG
        CX = 500               # center spine x
        BX = 210               # left failure-column x
        
        # --- flowchart role palette (color = role) -------------------------------
        GRN_F, GRN_S = "#D5E8D4", "#82B366"   # start / end terminator
        BLU_F, BLU_S = "#DAE8FC", "#6C8EBF"   # process
        YEL_F, YEL_S = "#FFF2CC", "#D6B656"   # decision
        ORG_F, ORG_S = "#FFE6CC", "#D79B00"   # I/O (hexagon)
        PUR_F, PUR_S = "#E1D5E7", "#9673A6"   # subprocess (double border)
        JCT_F, JCT_S = "#B0B0B0", "#666666"   # junction merge point (neutral gray)
        EDGE = "#4D4D4D"                      # all edges gray
        INK = "#1A1A1A"
        SUB = "#555555"
        
        F = [20, 14, 12, 10]          # title / node-label / subtitle / sub-label
        BB = False                    # text stays out of the collision registry
        
        d = SVGDrawer(W, H, bg="#FFFFFF")
        d.arrow_head("ah", EDGE)
        
        # --- title ----------------------------------------------------------------
        d.text(CX, 38, "CI/CD 部署流水线", font_size=F[0], weight="bold",
               fill=INK, anchor="middle", bbox=BB)
        d.text(CX, 62, "Continuous Integration & Continuous Deployment Pipeline",
               font_size=F[2], fill=SUB, anchor="middle", bbox=BB)
        
        # --- geometry helpers -----------------------------------------------------
        # decision(x,y,w,h) & rect/hexagon are corner-anchored; circle is centered.
        DW, DH = 170, 84             # decision diamond
        RW, RH = 220, 56             # process rect (wide enough for tool names)
        HW, HH = 240, 56             # I/O hexagon
        RR = 30                      # terminator radius
        JR = 6                       # junction radius
        
        
        def term(cx, cy, nid):
            """Green terminator circle. Label is placed separately, OFF the circle."""
            d.circle(cx, cy, RR, fill=GRN_F, stroke=GRN_S, stroke_width=1.8,
                     node_id=nid, node_kind="op", bbox=True)
        
        
        def junction(cx, cy, nid):
            """Small gray merge point on the failure column."""
            d.circle(cx, cy, JR, fill=JCT_F, stroke=JCT_S, stroke_width=1.2,
                     node_id=nid, node_kind="junction", bbox=True)
        
        
        def io_hex(cx, cy, nid, title, sub=""):
            d.hexagon(cx - HW / 2, cy - HH / 2, HW, HH, fill=ORG_F, stroke=ORG_S,
                      stroke_width=1.4, node_id=nid, node_kind="op", bbox=True)
            d.text(cx, cy - (4 if sub else 0), title, font_size=F[1], weight="bold",
                   fill=INK, anchor="middle", bbox=BB)
            if sub:
                d.text(cx, cy + 14, sub, font_size=F[3], fill=SUB, anchor="middle",
                       bbox=BB)
        
        
        def proc(cx, cy, nid, title, sub=""):
            d.rect(cx - RW / 2, cy - RH / 2, RW, RH, rx=7, fill=BLU_F, stroke=BLU_S,
                   stroke_width=1.4, node_id=nid, node_kind="op", bbox=True)
            d.text(cx, cy - (4 if sub else 0), title, font_size=F[1], weight="bold",
                   fill=INK, anchor="middle", bbox=BB)
            if sub:
                d.text(cx, cy + 14, sub, font_size=F[3], fill=SUB, anchor="middle",
                       bbox=BB)
        
        
        def diamond(cx, cy, nid, label, sub_label):
            d.decision(cx - DW / 2, cy - DH / 2, DW, DH, fill=YEL_F, stroke=YEL_S,
                       stroke_width=1.6, node_id=nid, node_kind="op", bbox=True)
            d.text(cx, cy - 6, label, font_size=F[1], weight="bold", fill=INK,
                   anchor="middle", bbox=BB)
            d.text(cx, cy + 14, sub_label, font_size=F[3], fill=SUB,
                   anchor="middle", bbox=BB)
        
        
        def subprocess_box(cx, cy, nid, title, sub=""):
            """Purple process with an inset second border (double-border subprocess)."""
            d.rect(cx - RW / 2, cy - RH / 2, RW, RH, rx=7, fill=PUR_F, stroke=PUR_S,
                   stroke_width=1.4, node_id=nid, node_kind="op", bbox=True)
            # decorative inset border — NOT a node (no collision/edge role).
            d.rect(cx - RW / 2 + 4, cy - RH / 2 + 4, RW - 8, RH - 8, rx=5,
                   fill="none", stroke=PUR_S, stroke_width=1.0, role="decoration")
            d.text(cx, cy - (4 if sub else 0), title, font_size=F[1], weight="bold",
                   fill=INK, anchor="middle", bbox=BB)
            if sub:
                d.text(cx, cy + 14, sub, font_size=F[3], fill=SUB, anchor="middle",
                       bbox=BB)
        
        
        def yes_no(start, end, txt):
            """Place a 是/否 flag CLEAR of its edge: perpendicular offset picked from
            the edge's dominant axis, so the label never sits on the line."""
            mx, my = (start[0] + end[0]) / 2, (start[1] + end[1]) / 2
            dx, dy = abs(end[0] - start[0]), abs(end[1] - start[1])
            if dy >= dx:  # vertical spine -> label to the right of the line
                d.text(mx + 32, my + 4, txt, font_size=F[3], weight="bold",
                       fill=SUB, anchor="middle", bbox=BB)
            else:         # horizontal branch -> label above the line
                d.text(mx, my - 16, txt, font_size=F[3], weight="bold",
                       fill=SUB, anchor="middle", bbox=BB)
        
        
        # --- nodes: center spine (compact ~105px c2c) ----------------------------
        term(CX, 105, "start")
        io_hex(CX, 207, "hook", "Webhook", "push · PR merge")
        subprocess_box(CX, 312, "checkout", "检出 & 构建", "git clone · npm ci · compile")
        diamond(CX, 430, "build_ok", "构建成功?", "Build OK?")
        subprocess_box(CX, 548, "lint", "代码检查", "ESLint · MyPy · SAST scan")
        diamond(CX, 666, "lint_ok", "检查通过?", "Lint OK?")
        subprocess_box(CX, 784, "tests", "测试套件", "unit · integration · e2e")
        diamond(CX, 902, "test_ok", "测试通过?", "Tests Pass?")
        proc(CX, 1010, "staging", "部署预发布", "Deploy Staging · kubectl rolling")
        proc(CX, 1115, "smoke", "冒烟测试", "Smoke Test · health · contract")
        diamond(CX, 1233, "smoke_ok", "冒烟通过?", "Smoke OK?")
        proc(CX, 1351, "deploy", "部署生产", "Deploy Prod · canary → blue-green")
        term(CX, 1456, "released")
        
        # --- nodes: failure column (shares spine y at each decision) -------------
        proc(BX, 430, "notify", "通知失败", "Notify · Slack · Email")
        junction(BX, 666, "m1")
        junction(BX, 902, "m2")
        term(BX, 1233, "failed")
        
        # --- terminator labels (off-circle, +4 from center) ----------------------
        d.text(CX + 46, 109, "开始", font_size=F[1], weight="bold", fill=INK,
               anchor="middle", bbox=BB)
        d.text(CX + 50, 1460, "已发布", font_size=F[1], weight="bold", fill=INK,
               anchor="middle", bbox=BB)
        d.text(BX - 46, 1237, "失败", font_size=F[1], weight="bold", fill=INK,
               anchor="middle", bbox=BB)
        
        # --- edges: spine (down) -------------------------------------------------
        d.connect("start", "bottom", "hook", "top", stroke=EDGE,
                  stroke_width=1.8, marker_end="ah")
        d.connect("hook", "bottom", "checkout", "top", stroke=EDGE,
                  stroke_width=1.8, marker_end="ah")
        d.connect("checkout", "bottom", "build_ok", "top", stroke=EDGE,
                  stroke_width=1.8, marker_end="ah")
        
        e = d.connect("build_ok", "bottom", "lint", "top", stroke=EDGE,
                      stroke_width=1.8, marker_end="ah")
        yes_no(e[0], e[1], "是 Yes")
        d.connect("lint", "bottom", "lint_ok", "top", stroke=EDGE,
                  stroke_width=1.8, marker_end="ah")
        
        e = d.connect("lint_ok", "bottom", "tests", "top", stroke=EDGE,
                      stroke_width=1.8, marker_end="ah")
        yes_no(e[0], e[1], "是 Yes")
        d.connect("tests", "bottom", "test_ok", "top", stroke=EDGE,
                  stroke_width=1.8, marker_end="ah")
        
        e = d.connect("test_ok", "bottom", "staging", "top", stroke=EDGE,
                      stroke_width=1.8, marker_end="ah")
        yes_no(e[0], e[1], "是 Yes")
        d.connect("staging", "bottom", "smoke", "top", stroke=EDGE,
                  stroke_width=1.8, marker_end="ah")
        d.connect("smoke", "bottom", "smoke_ok", "top", stroke=EDGE,
                  stroke_width=1.8, marker_end="ah")
        
        e = d.connect("smoke_ok", "bottom", "deploy", "top", stroke=EDGE,
                      stroke_width=1.8, marker_end="ah")
        yes_no(e[0], e[1], "是 Yes")
        d.connect("deploy", "bottom", "released", "top", stroke=EDGE,
                  stroke_width=1.8, marker_end="ah")
        
        # --- edges: failure column (convergence via junctions) -------------------
        # D1 No → Notify → (down) → m1;  D2 No → m1 → (down) → m2;
        # D3 No → m2 → (down) → Failed;  D4 No → Failed.
        e = d.connect("build_ok", "left", "notify", "right", stroke=EDGE,
                      stroke_width=1.8, marker_end="ah")
        yes_no(e[0], e[1], "否 No")
        d.connect("notify", "bottom", "m1", "top", stroke=EDGE,
                  stroke_width=1.8, marker_end="ah")
        
        e = d.connect("lint_ok", "left", "m1", "right", stroke=EDGE,
                      stroke_width=1.8, marker_end="ah")
        yes_no(e[0], e[1], "否 No")
        d.connect("m1", "bottom", "m2", "top", stroke=EDGE,
                  stroke_width=1.8, marker_end="ah")
        
        e = d.connect("test_ok", "left", "m2", "right", stroke=EDGE,
                      stroke_width=1.8, marker_end="ah")
        yes_no(e[0], e[1], "否 No")
        d.connect("m2", "bottom", "failed", "top", stroke=EDGE,
                  stroke_width=1.8, marker_end="ah")
        
        e = d.connect("smoke_ok", "left", "failed", "right", stroke=EDGE,
                      stroke_width=1.8, marker_end="ah")
        yes_no(e[0], e[1], "否 No")
        
        # --- Design Brief (Step 1) — declared from input.md's flowchart intent ----
        # Node-style: every stage is a tinted flowchart-role shape (no layer
        # containers). Palette keys = the primary node ids on the center spine
        # (start -> released, input.md's numbered stage list) plus the failure
        # column (notify + the gray junction merges + failed). flow = top-down per
        # input.md's "top-to-bottom process flowchart" spine; node-style briefs
        # carry no chain (check C asserts whole-edge direction dominance).
        BRIEF = DesignBrief(
            scheme="flowchart-roles",
            layout="node",
            flow="top-down",
            palette_role={
                "start":     ColorSpec(GRN_F, GRN_S),   # green terminator
                "hook":      ColorSpec(ORG_F, ORG_S),   # orange I/O hexagon
                "checkout":  ColorSpec(PUR_F, PUR_S),   # purple subprocess
                "build_ok":  ColorSpec(YEL_F, YEL_S),   # yellow decision
                "lint":      ColorSpec(PUR_F, PUR_S),
                "lint_ok":   ColorSpec(YEL_F, YEL_S),
                "tests":     ColorSpec(PUR_F, PUR_S),
                "test_ok":   ColorSpec(YEL_F, YEL_S),
                "staging":   ColorSpec(BLU_F, BLU_S),   # blue process
                "smoke":     ColorSpec(BLU_F, BLU_S),
                "smoke_ok":  ColorSpec(YEL_F, YEL_S),
                "deploy":    ColorSpec(BLU_F, BLU_S),
                "released":  ColorSpec(GRN_F, GRN_S),
                "notify":    ColorSpec(BLU_F, BLU_S),   # failure column
                "m1":        ColorSpec(JCT_F, JCT_S),   # gray junction merges
                "m2":        ColorSpec(JCT_F, JCT_S),
                "failed":    ColorSpec(GRN_F, GRN_S),
            },
            flow_chain=(),
        )
        
        # --- score + artifact triplet -------------------------------------------
        svg = d.render()
        score, rep = evaluate_svg(d)
        print(f"Score: {score}")
        for r in rep:
            print(f"  {r}")
        qa = run_semantic_qa(d, expected_size=(W, H), brief=BRIEF)
        print("Semantic QA:")
        for line in qa.report():
            print(line)
        sp = str(OUT / f"{NAME}.svg")
        save_svg(svg, sp)
        rasterize_svg(sp, str(OUT / f"{NAME}.png"), width=PNG_W)
        try:
            svg_to_pptx(svg, str(OUT / f"{NAME}.pptx"),
                        config=PptxConfig(slide_w=13.333, slide_h=7.5, scale=2.0))
        except Exception as e:
            print(f"[pptx: {e}]")
        BRIEF.write(str(OUT / "brief.json"))
        print(f"\n✓ {NAME}.svg / .png / .pptx")
        
      • input.md 2.5 KB
        # CI/CD 部署流水线 — Deployment Pipeline Flowchart
        
        A top-to-bottom **process flowchart** for a realistic CI/CD pipeline with four
        quality-gate decisions. Color encodes the standard flowchart role vocabulary:
        green = start/end terminator, blue = process, yellow = decision, orange = I/O,
        purple = subprocess (double border). Gray junction circles mark merge points
        where multiple failure branches converge.
        
        ## Pipeline stages & flow
        
        ### Trigger
        1. **Start** (green terminator) — a commit is pushed to `main` or a PR merged.
        2. **Webhook** (orange hexagon, I/O input) — push event received.
        
        ### Build
        3. **Checkout & Build** (purple subprocess) — `git clone`, dependency install
           (`npm ci`), compile / bundle the artifact.
        4. **Build OK?** (yellow decision):
           - **No** → enters the failure column (left).
           - **Yes** → continues down the spine.
        
        ### Quality gates
        5. **Lint & Security Scan** (purple subprocess) — ESLint, MyPy type check, SAST
           vulnerability scan.
        6. **Lint OK?** (yellow decision):
           - **No** → failure column.
           - **Yes** → continues.
        7. **Test Suite** (purple subprocess) — unit, integration, and end-to-end tests.
        8. **Tests Pass?** (yellow decision):
           - **No** → failure column.
           - **Yes** → continues.
        
        ### Staging & production
        9. **Deploy to Staging** (blue process) — `kubectl apply`, rolling update on the
           staging cluster.
        10. **Smoke Tests** (blue process) — health probes, API contract checks.
        11. **Smoke OK?** (yellow decision):
            - **No** → failure column.
            - **Yes** → continues.
        12. **Deploy to Prod** (blue process) — canary (10 % traffic) → blue-green
            promotion.
        13. **Released** (green terminator) — happy-path end.
        
        ### Failure convergence (left column)
        All four decisions branch **No** to the left. The first failure route hits
        **Notify Failure** (blue process — Slack / Email alert); subsequent failure
        routes merge via gray junction circles and all converge on a single **Failed**
        green terminator. The **Yes** paths stay on the vertical center spine —
        failure handling never crosses the spine.
        
        ## Conventions
        
        - Every edge carries an arrowhead and a perpendicular-offset 是/No branch flag.
        - Every node carries a Chinese main label + an English/tool-name sub-label
          (e.g. "检出 & 构建" / "git clone · npm ci · compile"); terminators are
          Chinese-only (开始 / 已发布 / 失败).
        - Layout, exact palette, typography, and all geometry are yours to design —
          follow the architecture-drawer skill's design system (its flowchart role
          presets) and let the evaluator guide iteration.
        
  • references
    • api_quickref.md 2.6 KB
      # API Quickref — SVGDrawer & layout helpers
      
      One-page signatures + the known traps. Read this instead of grepping
      `scripts/svg_utils.py` for signatures (each source grep costs a model turn;
      this page answers the same questions without one).
      
      ## Layout helpers (pure geometry — they compute, you draw)
      
      ```python
      from svg_utils import layout_grid, layout_row, layout_band, layout_radial
      
      layout_grid(n, x0, y0, cols, w, h, gx, gy) -> [(x, y), ...]
          # n cells, cols per row, left-to-right then down; (w+gx)/(h+gy) pitch.
          # The card-array primitive: chips in a band, station rows, pipeline stages.
      
      layout_row(items, x0, y0, gx) -> [(x, y0, w), ...]
          # items = list of widths. Variable-width flow: source -> queue -> engine.
      
      layout_band(title, x, y, w, h, pad=24, title_h=28) -> (bx, by, bw, bh)
          # Drawable interior of a titled container — contents placed inside
          # never collide with the title strip or band edges.
      
          # ZERO crossings by construction. positions: {id: (x,y,w,h)};
          # sides: {nid: (neighbor_side, hub_side)} ready for connect().
      ```
      
      State the array, not the coordinates: `for (x, y) in layout_grid(6, bx, by, 3, 120, 40, 30, 24)` replaces
      six hand-computed positions and the gutter/slope arithmetic that places them.
      
      ## Drawing (register node_id so connections validate)
      
      ```python
      d.rect(x, y, w, h, rx=5, fill=, stroke=, node_id=, node_kind="op", role=, bbox=)
      d.circle(cx, cy, r, fill=, node_id=, node_kind="junction")
      d.text(x, y, s, font_size=, anchor="middle", fill=, weight=)   # y is the CENTER line
      d.line(x1, y1, x2, y2, stroke=, marker_end=, role="decoration")
      d.connect(from_id, from_side, to_id, to_side, stroke=, dashed=, marker_end="arrowhead")
      ```
      
      ## Traps (each has cost a replay session a repair round)
      
      - **`connect()` draws `marker_end="arrowhead"` by default** — pass `marker_end=None` explicitly for a plain
        connector. (`d.line` defaults to no arrow; ask only if you want one.)
      - **Container cards**: give them `node_kind="layer"` (or `role="layer"`) when they are pure containers; use
        `node_kind="op"` only when something must `connect()` to them. Contained chips are exempt from the
        spacing check; equal-pitch arrays are protected from `auto_refine`.
      - **`d.text` y is the CENTER line** (`dominant-baseline`), not the top — `y = box_y + h/2` for in-box labels.
      - **Deep literals (>3 nesting) belong in named constants** — inline nested tuples/tuples-in-lists cause
        bracket-mismatch edits (observed: 3 wasted edit rounds counting parens).
      - **`auto_refine(drawer)`** fixes gutter + spacing; it refuses containment pairs and grid arrays on
        purpose. Route/dangle/crossing fixes are always manual.
      
    • checks_cheatsheet.md 7.1 KB
      # Evaluator Cheatsheet — 16 checks × threshold × trigger
      
      Single-page reference for the quality gate in `scripts/evaluator.py`
      (`evaluate_svg`). **Read this instead of the evaluator source code**: every
      number below is the exact default the shipped evaluator enforces, and the
      repair column is what the `[FAIL]`/`[WARN]` report line expects you to do.
      All checks are **render-then-parse** — they re-read the actual SVG markup, so
      `add_element`/`bbox=False` content is measured too.
      
      **Do not pre-verify these by hand.** `evaluate_svg` runs in under a second on
      a finished `gen.py`; it is the cheap oracle. Sketch an approximate layout,
      run the script, read the report lines, and fix exactly what they name. The
      table exists so a report line can be mapped to a threshold and a repair
      without reading the check's implementation.
      
      ## Score model
      
      Start at 100. Each failing check subtracts its penalty (see table; multiple
      issues in one check multiply per-issue cost up to that check's cap). Score
      floors at 0. `[FAIL]` vs `[WARN]`: both cost points; `[FAIL]` classes are
      defects that must be fixed (ship gate: no `[FAIL]`), `[WARN]` classes are
      quality polish — fix when cheap, never suppress.
      
      ## The 16 checks
      
      | 1 | Collisions | Any two registered bboxes overlap | 10/issue | Move one node clear of the other; never layer business shapes |
      | 2 | Canvas boundary | Any bbox outside `0,0–W,H` | 15/issue | Pull coordinates inside; enlarge canvas only via the brief |
      | 3 | Text overflow (canvas) | `<text>` bbox outside canvas (pad 2) | 6/issue, cap 24 | Shorten the label or move the text inside |
      | 4 | Text overflow (container) | `<text>` bbox outside its nearest containing rect (pad 6) | 3/issue, cap 18 | Widen the node/container or shorten the label |
      | 5 | Text overlaps | `<text>` bbox hits a visible shape or another `<text>` (+pad 1) | 4/issue, cap 24 | Nudge the label or the shape; never stack labels |
      | 6 | Coverage | Union of bboxes <5% or >60% of canvas | −20 / −10 | Repack to fill the canvas better (denser or sparser) |
      | 7 | Dangling / degenerate edges | Edge endpoint >12px from any node border, or length <4px | 8/issue, cap 40 | Re-anchor with `connect(a, side, b, side)` — auto-snap |
      | 8 | Duplicate edges | Same endpoint pair within 6px | 8/issue (with #7, cap 40) | Delete the duplicate; or offset via port spread (automatic when ≥2 edges share a side) |
      | 9 | Phantom anchors | Invisible node (no fill/stroke/size) used as edge endpoint | 15/issue, cap 45 | Give the anchor a visible shape, or connect to a real node |
      | 10 | Route-through | Edge polyline passes through an unrelated node interior (+3px margin) | 10/issue, cap 40 | Reroute: connect side-to-side so the segment misses nodes |
      | 11 | Edge crossings | Two edge polylines cross in their interiors | 8/pair, cap 40 | Reroute one edge; a hub junction node often kills crossings |
      | 12 | Composition budget | >2 bends, >1.35× route stretch, gutter <20px, segment <16px | 2/issue (WARN), cap 12 | Straighten the route; widen the container gutter |
      | 12b | Edge-through-text | Edge segment passes through a `<text>` bbox | 8/issue, cap 24 | Reroute the edge around the label |
      | 13 | Same-kind spacing | Two `op`/`junction` nodes <14px apart (Euclidean gap); a chip fully inside its card is exempt (gutter rule owns it) | 4/issue, cap 20 | Spread nodes; `auto_refine` fixes this automatically |
      | 14 | Peer alignment | Same-sized same-kind peers in a row/column sharing no edge/center line (5px / 15%) | 3/issue, cap 15 | Snap the row/column to shared edges |
      | 15 | Type scale | >4 distinct font sizes, or adjacent tiers <1.15× apart | 4/issue, cap 8 | Use 3–4 tiers (e.g. 20/14/12/10); merge near-duplicate sizes |
      | 16 | Palette | >8 accents (hard 12); zero chromatic accents; gray-dominance (<35% business elements chromatic AND <15% painted area) | 4/issue; FAIL beyond hard cap / colorless / gray-dominant | Use a preset from `design_specs.md`; color must own bands or nodes, not just chips |
      | 17 | Contrast | `<text>` on an accent fill <3:1 FAIL (<4.5:1 WARN; large text ≥24px or bold ≥18.5px → 3:1) | 6/issue, cap 18; WARN 3/issue cap 12 | Darken text on light tints / lighten on dark fills (preset tiers are pre-verified) |
      
      > Rows 15–17 involve the semantic layer (palette / type scale / contrast are
      > asserted in the brief contract too): a preset from `design_specs.md` plus
      > brief palette_role keys keeps all three green without measurement.
      
      ## Report line → repair mapping (quick decode)
      
      ```
      "[FAIL] N element collisions"                       → check 1: separate the shapes
      "[FAIL] N elements exceed canvas boundaries"        → check 2: pull inside / resize canvas
      "[FAIL] N text element(s) overflow the canvas"      → check 3: shorten/move text
      "[FAIL] N edge(s) route through unrelated node"     → check 10: reroute
      "[FAIL] N text overlap(s) with shapes/other text"   → check 5: nudge labels
      "[WARN] Canvas coverage very low/high"              → check 6: repack density
      "[FAIL] Connection check … dangling … duplicate"    → check 7/8: re-anchor / dedupe
      "[FAIL] N phantom anchor(s)"                        → check 9: visible anchor
      "[FAIL] N edge pair(s) cross"                       → check 11: reroute / hub
      "[FAIL] N edge(s) pass through text"                → check 12b: route around text
      "[WARN] composition budget violation(s)"            → check 12: straighten/gutter
      "[WARN] N same-kind node pair(s) too close"         → check 13: spread / auto_refine
      "[WARN] N misaligned same-kind peer pair(s)"        → check 14: snap to shared edge
      "[WARN/FAIL] Typography …"                          → check 15: 3–4 tiers, ≥1.15×
      "[WARN/FAIL] Palette …"                             → check 16: preset + brief keys
      "[FAIL] N text element(s) below 3:1 contrast"       → check 17: contrast-safe text
      ```
      
      ## Anti-simulation guidance
      
      The two most expensive mistakes an agent makes with this skill (measured:
      ~84% of wall-clock on a clean run): reading the evaluator source to
      understand every threshold, then mentally verifying the layout against all
      16 checks before writing anything. Both are wasted effort:
      
      - The evaluator **is** the oracle and runs in <1s. Write an approximate
        layout, run, read report lines, fix what they name. Two bounded rounds
        converge on every shipped eval.
      - This cheatsheet replaces source-reading for check semantics. If a report
        line's vocabulary is unclear, read this table's row, not the check's
        implementation.
      
      ## Notes on auto-fix vs manual
      
      `auto_refine(drawer)` (in `evaluator.py`) handles checks 12 (gutter part),
      13, and the spacing part of the budget automatically — call it before any
      manual repair. It deliberately refuses two cases: containment pairs (a chip
      inside its card is the gutter rule's business) and equal-pitch grid arrays
      (>=3 aligned nodes — move the whole array instead; centering one member
      breaks the alignment). Checks 7/10/11/12b (dangles, route-through, crossings,
      edge-through-text) need manual rerouting: `connect()` side-to-side snapping
      plus junction nodes for fan-outs. Differently-sized peers are exempt from
      check 14; legend/background shapes and intentional in-box labels are exempt
      from check 5.
      
    • design_specs.md 6.6 KB
      # SVG Architecture Design Specifications
      
      ## Color Palette Library
      
      Color encodes **information**, not decoration. This skill ships multiple preset palettes — **pick by diagram type** (see the decision guide), not by aesthetic preference. Each scheme is pre-verified against the evaluator (accent count ≤ 12, no luminance clash) and tuned for a specific visual task. Categorical palettes (Okabe-Ito etc.) are optimized for class separation in scatter/line charts and look chaotic as large fills — don't use them where a monochrome or semantic scheme fits.
      
      ### How to choose
      
      Pick by **information need** first — color must earn its place by encoding a class, a type, or a focus. Reach for S1 only when none of those apply, not as a reflex. For a per-diagram-type shortcut, see `references/diagram_types.md`.
      
      | Information need | Scheme | Rationale |
      | :--- | :--- | :--- |
      | Distinguish 2–4 classes / branches / roles / lanes | **S2 Categorical** | Colorblind-safe hue separation — use whenever the eye must group by category |
      | Component **type** has a fixed meaning (cloud / network device) | **S3 Semantic** | One color per type, consistent across every diagram |
      | One focal element (innovation / core / hero module) | **S4 Duotone** | Cool body + single warm accent that pops |
      | Pure layering / pipeline, **no** class or type distinction (or ≥4 same-tier modules) | S1 Monochrome Blue | Single-hue luminance tiers — uniform, grayscale-safe |
      | Grayscale print / technical report | S1 | Pure luminance ramp is inherently gray-safe |
      
      **S1 is the fallback, not the default.** Match an information need above first; only fall back to S1 when the diagram is genuinely just layered flow with no categorical / semantic / focal role for color. Defaulting everything to blue wastes the palette and makes every diagram look the same.
      
      ### Scheme 1: Monochrome Blue (fallback)
      
      Single hue (Nature Blue), four luminance tiers, **unified dark stroke**. Layers are distinguished by fill lightness (ΔE 8–12, spread 3.6 — uniform), not by hue. Op cards stay white so they don't inflate the accent budget. The most克制/uniform scheme; ideal when modules ≥ 4.
      
      | Tier | Fill | Stroke (unified) | Suggested use |
      | :--- | :--- | :--- | :--- |
      | L1 (lightest) | `#D5E1EB` | `#1B3A5C` | Input / frontend |
      | L2 | `#BBCEDF` | `#1B3A5C` | Processing |
      | L3 | `#9BB9D1` | `#1B3A5C` | I/O / transport |
      | L4 (darkest) | `#769EBF` | `#1B3A5C` | Output / storage |
      
      *Evaluator: 5 accents, no clash. ΔE(adj fill) = 8.3 / 9.7 / 11.9.*
      
      ### Scheme 2: Categorical (colorblind-safe)
      
      Okabe-Ito (Nature Methods 2011, Bang Wong), the de-facto standard for accessible categorical figures. Use **only when distinct classes need hue separation** (2–4 groups); do not use for ≥5 side-by-side layers (switch to S1). Two fill modes: colored tint (richer, 8 accents) or white (most克制, 4 accents — color lives in the border only).
      
      | Class | Fill (tint) | Fill (white mode) | Stroke |
      | :--- | :--- | :--- | :--- |
      | Orange | `#FAEED1` | `#FFFFFF` | `#E69F00` |
      | Sky Blue | `#E1F2FB` | `#FFFFFF` | `#56B4E9` |
      | Bluish-Green | `#D1EEE6` | `#FFFFFF` | `#009E73` |
      | Deep Blue | `#D1E6F1` | `#FFFFFF` | `#0072B2` |
      
      *Evaluator: 8 accents (tint) / 4 accents (white), no clash.*
      
      ### Scheme 3: Semantic (cloud / system architecture)
      
      Component **type** → fixed color, consistent across every diagram (ArchiMate / AWS-diagrams convention). Op cards are white-filled; the color lives entirely in the stroke + a small type label. This keeps the accent count low even with many components.
      
      | Component type | Stroke | Fill |
      | :--- | :--- | :--- |
      | Network (LB, gateway) | `#3498DB` | `#FFFFFF` |
      | Compute (app, instance) | `#E67E22` | `#FFFFFF` |
      | Storage | `#27AE60` | `#FFFFFF` |
      | Security | `#E74C3C` | `#FFFFFF` |
      | Database | `#9B59B6` | `#FFFFFF` |
      
      *Evaluator: 5 accents, no clash. To group components into a cluster, wrap them in a near-white container (`#F7F7F7`) with a `#CCCCCC` border — neutrals, not counted.*
      
      ### Scheme 4: Duotone (focal highlight)
      
      A tight cool family (3 hues) for the body, plus **one warm focal accent** for the single most important element. The warm color pops against the cool background without redesigning the palette. Use when the diagram has a clear "hero" (innovation point, loss module, key output).
      
      | Role | Fill | Stroke |
      | :--- | :--- | :--- |
      | Cool 1 (Indigo) | `#CDD2F6` | `#5B6EE1` |
      | Cool 2 (Cyan) | `#BFDFED` | `#3D9FC7` |
      | Cool 3 (Teal) | `#B0DDD9` | `#2EA69A` |
      | **Focal (Amber)** | `#F6D0B4` | `#E8833A` |
      
      *Evaluator: 8 accents, no clash. Reserve the amber for exactly one element; using it on multiple nodes defeats the focal intent.*
      
      ### Structural neutrals (all schemes, not counted as accents)
      
      | Element | Hex | Usage |
      | :--- | :--- | :--- |
      | Background | `#FFFFFF` | Canvas (white is the default) |
      | Container / Divider | `#D5D5D5` or `#CCCCCC` | Layer frames, cluster wrappers |
      | Cluster fill | `#F7F7F7` | Near-white grouping background |
      | Text (Primary) | `#000000` | Main labels and headers |
      | Text (Secondary) | `#333333` | Sub-labels and formulas |
      | Connectors | `#4D4D4D` or `#666666` | Arrows / lines (gray, not colored) |
      
      ### Design principles (apply to all schemes)
      
      1. **White-dominant** — ≥70% of the canvas should be white/near-white. Color is a border/label language, not a fill-everything language.
      2. **Op cards stay white** — only layer/cluster containers take colored fills; individual operation cards are `fill="white"` so they don't bloat the accent count.
      3. **≤3 chromatic families visible at once** (S1 has 1, S3 has up to 5 but only as strokes). If a diagram needs more, it's too complex — split it.
      4. **Connectors are gray** (`#4D4D4D`), never colored. Color on a line implies data-flow semantics that belong to the node, not the edge.
      5. **One focal accent max** — if highlighting, pick exactly one element (S4 amber, or a saturated stroke swap in S1). Multiple "highlights" = no highlight.
      
      ## Layout Principles
      
      1.  **Grid-Based**: Use a 1200x800 base canvas.
      2.  **Layering**: Use vertical sections (Layers) and horizontal stages (Stages).
      3.  **Spacing**:
          *   Margin: 20px
          *   Section Padding: 10px
          *   Component Gap: 15px
      4.  **Typography**:
          *   Main Header: 18px, Bold
          *   Sub-header: 14px, Bold
          *   Body Text: 12px, Regular
          *   Math/Formula: 13px, Monospace/Italic
      
      ## Reusable Components
      
      ### 1. Rounded Container
      ```svg
      <rect x="x" y="y" width="w" height="h" rx="10" ry="10" fill="#HEX" stroke="#333" stroke-width="1" />
      ```
      
      ### 2. Arrow Marker
      ```svg
      <defs>
        <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
          <polygon points="0 0, 10 3.5, 0 7" fill="#333" />
        </marker>
      </defs>
      ```
      
    • diagram_types.md 12.9 KB
      # Diagram Type Presets — Shapes & Layout
      
      Companion to `design_specs.md`. That file owns **color** (S1–S4 palettes) and global layout rules; this file owns **shape vocabulary and per-type layout conventions** — i.e. *what shape each element becomes* and *how the type is conventionally arranged*. Read both when the user names a diagram type.
      
      Each preset maps a semantic role to one of the drawer's primitives (`rect` / `circle` / `database` / `decision` / `hexagon` / `component` / `cloud` / `line` / `connect` / `text` / `multiline_text` / `formula`) plus a palette tier. Coordinates are still hand-placed (this skill has no Graphviz); the layout rows below give the spacing/direction defaults that consistently pass the evaluator.
      
      ## How to choose
      
      | Diagram type | Typical prompt keywords | Palette | Direction |
      | :--- | :--- | :--- | :--- |
      | Architecture (system / cloud / service) | 架构 / architecture / 微服务 / topology | S1 (≥4 same-tier modules) or S3 (component-type semantics) | TB; **≥4 tiers → TB**, else LR |
      | Flowchart (process / decision logic) | 流程 / flow / pipeline / 审批 | **Role palette** (process/decision/IO hues below) — S1 only if purely linear | TB |
      | ML / DL model | 模型 / network / Transformer / CNN / encoder-decoder | **Role palette** by layer type (below), or S4 for one focal layer | TB |
      | ER (database schema) | ER / 表结构 / schema / 数据库设计 | S1 (or S2 if multiple entity domains) | TB |
      | Sequence (interaction / 时序) | 时序 / sequence / 交互 / 协议流 | S1 (or S2 to color by actor) | LR (lifelines) × TB (time) |
      | Swimlane (cross-functional / 跨职能) | 泳道 / 谁做什么 / 跨部门 | **S2** (one hue per lane) | LR inside lanes |
      | Network topology | 网络 / topology / LAN / 部署拓扑 | **S3** (device-type semantics) | TB by tier |
      
      Palette is picked by **information need**, not habit — see `design_specs.md`. When the request is genuinely ambiguous about type, fall back to Architecture + S1.
      
      ## Universal shape vocabulary (role → primitive)
      
      Use this as the canonical mapping. A node drawn with `node_id=` is connectable; `role="layer"|"background"` marks a container that the evaluator **excludes from spacing/collision checks**. Palette checks are role-agnostic — they ignore only *neutral* fills/strokes (near-white / gray), so keep container fills near-white (`#F7F7F7`) if you don't want them counted toward the accent budget.
      
      | Role | Primitive | Notes |
      | :--- | :--- | :--- |
      | Layer / tier / container / cluster | `rect(role="layer", fill=near-white, stroke=neutral)` | Dashed border for logical grouping; solid for physical |
      | Service / process / module / op | `rect(node_id=…, fill=S-tier)` | The default connectable box |
      | Database / persistent store | `database(node_id=…, fill=S-tier)` | Cylinder |
      | Decision / branch | `decision(node_id=…, fill=S-tier)` | Diamond |
      | Gateway / broker / bus (hub) | `hexagon(node_id=…, fill=S-tier)` | 6-sided; place centrally |
      | External system / 3rd-party | `component(node_id=…, fill=S-tier, extra='stroke-dasharray="6,3"')` | Tabbed box; dashed border = outside boundary |
      | Internet / WAN / cloud service | `cloud(node_id=…, fill=S-tier)` | Multi-lobe cloud |
      | Start / End terminator | `circle(node_id=…, r=…)` or `rect(rx=h/2)` (stadium) | Small circle for start/end; stadium for labels |
      | Junction / merge point | `circle(node_id=…, node_kind="junction", r=4–6)` | Tiny; auto-snaps edges |
      | I/O (input/output data) | `hexagon(...)` *(no native parallelogram)* | Hexagon is the closest semantic match |
      | Connector | `connect(from, side, to, side, …)` or `line(…, register_edge=True)` | Gray stroke, never colored |
      | Label only (no node) | `text(...)` / `multiline_text(...)` | `role="label"` |
      
      > **Gaps vs. draw.io**: there is no native parallelogram, ellipse, ER `table` container, or UML lifeline shape. Approximate per the notes above (I/O → hexagon; terminator → circle/stadium; ER table → `rect` container with child `rect` rows; lifeline → `rect` header + dashed `line`). Keep approximations consistent *within* a diagram.
      
      ---
      
      ## Architecture (system / cloud / service)
      
      The default type and the one this skill is tuned for (see `evals/`).
      
      | Element | Primitive | Notes |
      | :--- | :--- | :--- |
      | Tier / layer (Client / API / Service / Data) | `rect(role="layer", fill="#F7F7F7", stroke="#CCCCCC")` | Full-width band; gutter ≥20px inside |
      | Service / module | `rect(node_id=…, fill=S-tier, stroke=unified)` | Op cards stay white in S1 |
      | Database | `database(node_id=…, fill=S-tier)` | Green tier in S3 |
      | Queue / bus / message broker (hub) | `hexagon(node_id=…, fill="#FFF2CC", stroke="#D6B656")` | **Place at the geometric center** of its clients |
      | Gateway / load balancer | `hexagon(node_id=…, fill=S-tier)` | Orange tier in S3 |
      | External system / 3rd-party API | `component(node_id=…, extra='stroke-dasharray="6,3"', fill="#F5F5F5", stroke="#666666")` | Dashed border = outside your boundary |
      | Sync call | `connect(a, side, b, side, marker_end="arrowhead")` | Solid |
      | Async / event | `connect(…, dashed=True)` | Dashed |
      
      **Layout**: TB by default; switch to LR only when there are ≤3 tiers and the flow reads left→right. Hub nodes (queue/gateway) sit on the center column; clients radiate symmetrically so edges enter from different sides (zero crossings). Tier gap ≥40px; same-tier node gap ≥30px. Wrap each tier in a layer rect.
      
      ## Flowchart
      
      > **Role palette**: the fills below are a self-contained, evaluator-verified
      > palette where **color = flowchart role** (green = start/end, blue = process,
      > yellow = decision, orange = I/O, purple = subprocess). It is an alternative
      > to S1–S4, NOT a tier of them — pick it *or* an S-scheme and stay in one.
      
      | Element | Primitive | Notes |
      | :--- | :--- | :--- |
      | Start / End | `circle(node_id=…, r=20, fill="#D5E8D4", stroke="#82B366")` | Green terminator |
      | Process / step | `rect(node_id=…, fill="#DAE8FC", stroke="#6C8EBF")` | Blue rectangle |
      | Decision | `decision(node_id=…, fill="#FFF2CC", stroke="#D6B656")` | Yellow diamond |
      | I/O (data in/out) | `hexagon(node_id=…, fill="#FFE6CC", stroke="#D79B00")` | Orange (parallelogram substitute) |
      | Subprocess | `rect(node_id=…, fill="#E1D5E7", stroke="#9673A6")` + double border (draw a 2nd inset rect) | Purple |
      | Yes / No branch labels | `text(...)` on the decision edges | Always label both branches |
      
      **Layout**: TB; ~200px vertical gap between steps. Decision branches go LR, then merge back to the center column. Keep the main spine on a single x; branches are short detours, not parallel columns.
      
      ## ML / Deep Learning model
      
      Ideal for paper figures (NeurIPS/ICML style). Leverages `formula()` for tensor shapes.
      
      > **Role palette**: the fills below are a self-contained, evaluator-verified
      > palette where **color = layer type** (green = I/O, blue = conv/pool, purple =
      > attention, yellow = recurrent, orange = linear, red = loss/activation). It
      > is an alternative to S1–S4, NOT a tier of them — pick it *or* an S-scheme
      > (e.g. S4 when one layer is the focal "hero") and stay in one.
      
      | Element | Primitive | Fill (by layer type) |
      | :--- | :--- | :--- |
      | Layer block | `rect(node_id=…)` | Input/Output → `#D5E8D4`/`#82B366`; Conv/Pool → `#DAE8FC`/`#6C8EBF`; Attention/Transformer → `#E1D5E7`/`#9673A6`; RNN/LSTM/GRU → `#FFF2CC`/`#D6B656`; FC/Linear → `#FFE6CC`/`#D79B00`; Loss/Activation → `#F8CECC`/`#B85450` |
      | Tensor shape annotation | `multiline_text(...)` 2nd line, or `formula(...)` | `(B, C, H, W)` or `(B, T, D)` as the label's 2nd line |
      | Skip / residual connection | `connect(…, as_curve=True, dashed=True)` | Curved dashed arrow bypassing layers |
      | Encoder / Decoder group | `rect(role="layer", …)` | Swimlane-style container around each stack |
      
      **Layout**: TB (data flows top→bottom); ~150px between layers. Stack layers on the center x; skip connections curve out to the side and back. Group encoder/decoder in layer rects. Annotate every layer with its tensor shape — this is the whole point of an ML diagram.
      
      ## ER (Entity-Relationship)
      
      | Element | Primitive | Notes |
      | :--- | :--- | :--- |
      | Table (entity) | `rect(role="layer", node_id=…, fill="#D5E1EB", stroke="#1B3A5C")` | Container (S1 L1 blue); header row is the table name |
      | Column row | `rect(...)` child inside the table | One per column; PK row in `weight="bold"` |
      | PK marker | `text("PK", weight="bold")` prefix or `text("🔑")` | Prefix the column label |
      | FK relationship | `connect(…, dashed=True)` | Dashed; label with the FK column |
      
      **Layout**: TB; ~300px between tables. Vertically stack related tables (parent above children) so FK edges read top→bottom and don't cross. No native crow's-foot — use a plain dashed arrow with the FK column as the label.
      
      ## Sequence (interaction)
      
      The hardest to hand-place; prefer this only when the interaction *order* is the message.
      
      | Element | Primitive | Notes |
      | :--- | :--- | :--- |
      | Actor / participant (lifeline header) | `rect(node_id=…, fill=S-tier)` at top | Box at the top of each column |
      | Lifeline (dashed vertical) | `line(…, dashed=True, role="decoration")` | From header straight down |
      | Activation bar | `rect(fill=S-tier, role="decoration")` | Narrow rect overlaid on the lifeline |
      | Sync message | `connect(a, "bottom"/side, b, …, marker_end="arrowhead")` | Solid arrow between lifelines |
      | Async message | `connect(…, dashed=True)` | Dashed |
      | Return message | `connect(…, dashed=True, stroke="#999999")` | Grey dashed |
      
      **Layout**: participants on a horizontal row, ~200px apart (LR). Time flows top→bottom; each message sits on its own y row, ~50px apart. Activation bars span the rows where that participant is "active". Messages are short horizontal segments between adjacent lifelines — avoid long diagonals.
      
      ## Swimlane (cross-functional)
      
      | Element | Primitive | Notes |
      | :--- | :--- | :--- |
      | Pool (whole process) | `rect(role="layer", fill="#F7F7F7", stroke="#CCCCCC")` | Outer container |
      | Lane (one role / team) | `rect(role="layer", fill=lane-tint, dashed=False)` | Child of pool; one per actor |
      | Steps | Flowchart primitives (Start/Process/Decision/IO) | Each step's center sits inside its lane |
      | Handoff (cross-lane edge) | `connect(…)` | Edges crossing a lane boundary *are* the handoffs — the diagram's point |
      
      **Layout**: LR flow inside horizontal lanes; ≥160px horizontal step gap. Each lane is a full-height vertical slice of the pool; keep every step inside its actor's lane. Time flows left→right.
      
      ## Network topology
      
      | Element | Primitive | Notes |
      | :--- | :--- | :--- |
      | Router / Switch / Firewall / LB | `component(node_id=…, fill=S3-tier)` | Tabbed box; type in the label |
      | Server / compute | `rect(node_id=…, fill=S3-compute)` | |
      | Storage / NAS | `database(node_id=…, fill=S3-storage)` | |
      | Internet / WAN | `cloud(node_id=…, fill="#FFFFFF", stroke="#6881B3")` | |
      | Zone (subnet / VLAN / DMZ) | `rect(role="layer", dashed=True, fill="#F5F5F5", stroke="#666666")` | Container; label = CIDR / zone name |
      | Physical link | `connect(…, stroke_width=2)` | Plain; label = interface / VLAN |
      | Logical / VPN link | `connect(…, dashed=True)` | Dashed |
      
      **Layout**: TB by tier — Internet → edge (router/firewall) → distribution (switch/LB) → access (servers/clients). Wrap each subnet in a dashed zone container labelled with its CIDR. Label links with port/VLAN so the topology is self-documenting.
      
      ---
      
      ## Layout value cheatsheet (all types, evaluator-tuned)
      
      These are the per-type spacing defaults. They all satisfy the evaluator floors (container gutter ≥20px, same-kind node spacing ≥14px); use them unless the diagram is crowded, then spread further.
      
      | Type | Direction | Node gap (same row) | Tier/step gap (between rows) | Hub placement |
      | :--- | :--- | :--- | :--- | :--- |
      | Architecture | TB (≥4 tiers) / LR | 30px | 40px | center column, clients symmetric |
      | Flowchart | TB | 40px | 200px | — |
      | ML / DL | TB | 30px | 150px | — |
      | ER | TB | — | 300px (table→table) | — |
      | Sequence | LR × TB(time) | 200px (lifeline→lifeline) | 50px (msg→msg) | — |
      | Swimlane | LR | 160px (step→step) | lane height | — |
      | Network | TB | 30px | 40px | Internet at top center |
      
      ## Evaluator constraints to keep in mind
      
      - **Font tiers**: 3–4 per diagram, adjacent tiers ≥1.15× apart (e.g. 20 / 14 / 12 / 10). Header / node-label / annotation / tensor-shape is a natural 4-tier split.
      - **Palette**: ≤8 accents. Pick one palette — an S1–S4 scheme OR a type's self-contained role palette (see Flowchart / ML above) — and stay in it; don't mix colors from different palettes.
      - **Containers**: nodes fully inside a layer rect need ≥20px gutter on every side.
      - **Edges**: gray (`#4D4D4D`), never colored; arrowheads via `marker_end="arrowhead"`.
      - **Hubs** (queue/gateway/broker): if multiple clients connect to one, fan them around it so edges enter from different sides — a single-side fan stack triggers the evaluator's bend/stretch warnings.
      
    • gen_template.md 3.1 KB
      # gen.py Skeleton — copy this shape, fill in your architecture
      
      The fixed parts (path resolution, brief, evaluation, export) never change;
      only the constants block and the drawing section are yours. Following this
      shape avoids re-deriving the boilerplate and the known first-run failures
      (missing brief, wrong save order, deep-literal bracket slips).
      
      ```python
      #!/usr/bin/env python3
      """<Arch name> — <one-line summary of the design>."""
      import os, sys
      
      _HERE = os.path.dirname(os.path.abspath(__file__))
      _SKILL = os.path.normpath(os.path.join(_HERE, ".pi/skills/architecture-drawer/scripts"))
      if _SKILL not in sys.path:
          sys.path.insert(0, _SKILL)
      
      from svg_utils import SVGDrawer, save_svg, rasterize_svg, layout_grid, layout_row, layout_band
      from design_brief import DesignBrief
      from evaluator import evaluate_svg, auto_refine
      from semantic_qa import run_semantic_qa
      from svg2pptx import svg_to_pptx
      
      # ---- Step 1: design brief tokens (named constants, never deep literals) ----
      W, H = 1280, 860
      TITLE = "<Arch name>"
      NODE_IDS = ["src", "queue", "engine", "sink"]      # primary nodes
      BANDS = [("layer_a", "Layer A", 120), ("layer_b", "Layer B", 300)]  # (id, label, y)
      FLOW = ("src", "queue", "engine", "sink")           # flow_chain stages
      PALETTE_ROLE = {                                    # data-node-id -> (fill, stroke)
          "layer_a": ("#D5E1EB", "#1B3A5C"),
      }
      BRIEF = DesignBrief(scheme="S1 monochrome", layout="band", flow="left-right",
                          palette_role=PALETTE_ROLE, flow_chain=FLOW)
      
      d = SVGDrawer(width=W, height=H)
      # title
      d.text(W / 2, 36, TITLE, font_size=20, weight="bold")
      
      # ---- Step 2: draw (state the array; helpers do the arithmetic) ----
      for bid, label, by in BANDS:
          d.rect(60, by, W - 120, 160, fill="#F5F7FA", stroke="#1B3A5C",
                 node_id=bid, node_kind="layer", role="layer")
          d.text(76, by + 24, label, font_size=14, weight="bold")
          bx, byy, bw, bh = layout_band(label, 60, by, W - 120, 160)
          for (x, y), nid in zip(layout_grid(4, bx, byy, 4, 120, 40, 24, 0), NODE_IDS):
              d.rect(x, y, 120, 40, fill="white", stroke="#1B3A5C", node_id=nid)
      
      for a, b in zip(FLOW, FLOW[1:]):
          d.connect(a, "right", b, "left", stroke="#1B3A5C", marker_end="arrowhead")
      
      # ---- Step 3: evaluate -> bounded repair -> export (fixed shape) ----
      score, report = evaluate_svg(d)
      print(f"Score: {score}")
      if score < 100:
          score2, report2, fixes = auto_refine(d)
          print(f"After auto_refine: {score2}  (fixes: {fixes})")
      
      qa = run_semantic_qa(d, expected_size=(W, H), brief=BRIEF)
      save_svg(d.render(), "diagram.svg")
      rasterize_svg("diagram.svg", "diagram.png", W)
      svg_to_pptx("diagram.svg", "diagram.pptx")
      BRIEF.write("brief.json")
      ```
      
      Notes:
      - The `BANDS` / `NODE_IDS` / `FLOW` constants pattern replaces inline nested
        literals — bracket mismatches in 5-deep tuples cost real repair rounds.
      - Run `python3 gen.py` after every drawing change; read the report lines and
        fix exactly what they name (see `checks_cheatsheet.md`).
      - Canvas/positions are approximate at first write — the evaluator is the
        oracle; do not hand-verify all 16 checks before the first run.
      
  • scripts
    • design_brief.py 7.2 KB
      """Design Brief contract — the declared design intent of a diagram (Step 1).
      
      A DesignBrief is the machine-readable form of the Step-1 design brief: what
      palette / layout / flow the author *declared* before drawing.  The contract
      checker (semantic_qa.check_design_brief) then asserts the RENDERED SVG against
      this declaration.
      
      Capability boundary (deliberate): this layer verifies
      **rendering <-> self-declared contract** consistency.  It does NOT verify
      "contract <-> user's true intent" — the brief and the gen.py are usually
      written by the same agent in the same round, so a coherently-wrong brief
      passes.  Spec-entity coverage (check_text_semantics) and human review of the
      brief remain the guards for intent.
      
      Schema rules (inconsistent states are unconstructible):
        - ``palette_role`` is the SINGLE source of declared identity: its keys are
          the data-node-id values the checker looks for in the SVG.  Key semantics
          are bound to ``layout``: band -> layer-container ids, node -> primary
          node ids.  There is no separate container list to drift out of sync.
        - ``flow_chain`` is the ordered pipeline chain (a SUBSET of palette keys —
          validated at construction).  Side bands (memory/cache columns) and
          text-only bands are palette members but not chain stages; membership is
          not derivable from paint, so it is declared explicitly.
        - tint/plain membership is DERIVED from ``ColorSpec.fill`` (white == plain).
        - All colors are canonicalized through ``svg_utils.normalize_color`` to
          lowercase #rrggbb ("white" -> "#ffffff", named colors like "gray" ->
          "#808080") — the same normalizer the checker applies to rendered SVG
          fills, so hex case/alias/named tokens never produce noise failures.
      """
      import json
      from dataclasses import dataclass, field
      from typing import Mapping, Tuple
      
      try:
          from svg_utils import normalize_color
      except ImportError:  # standalone use without the skill on sys.path
          def normalize_color(value):  # type: ignore[misc]
              return None
      
      _WHITE = {"white", "#fff", "#ffffff"}
      _LAYOUTS = ("band", "node")
      _FLOWS = ("top-down", "left-right", "none")
      
      
      def norm_hex(color: str) -> str:
          """Normalize a color token to lowercase #rrggbb (3-digit expanded,
          'white' family collapsed to #ffffff). Unknown tokens pass through
          lowercased so mismatches still compare deterministically."""
          c = (color or "").strip().lower()
          if c in _WHITE:
              return "#ffffff"
          if c.startswith("#") and len(c) == 4:
              return "#" + "".join(ch * 2 for ch in c[1:])
          return c
      
      
      def canon_hex(color: str) -> str:
          """Canonical #rrggbb via svg_utils.normalize_color — the SAME
          normalization the checker applies to rendered fills, so a brief and the
          SVG compare on identical tokens (named colors like 'gray' resolve to hex
          on both sides). Tokens normalize_color cannot parse ('none', '',
          unknown) keep norm_hex's lowercase passthrough so is_plain still
          recognizes them."""
          c = norm_hex(color)
          return normalize_color(c) or c
      
      
      def is_plain(fill: str) -> bool:
          """A plain fill reads as white/neutral canvas (not a tinted structure)."""
          return norm_hex(fill) in _WHITE or norm_hex(fill) in ("none", "")
      
      
      @dataclass(frozen=True)
      class ColorSpec:
          """A declared (fill, stroke) pair — the tint+accent pairing that the
          contrast check (WCAG) relies on; declaring one hex is not enough."""
          fill: str
          stroke: str
      
          def __post_init__(self):
              object.__setattr__(self, "fill", canon_hex(self.fill))
              object.__setattr__(self, "stroke", canon_hex(self.stroke))
      
          def as_pair(self) -> Tuple[str, str]:
              return (self.fill, self.stroke)
      
          @classmethod
          def from_json(cls, d) -> "ColorSpec":
              return cls(d["fill"], d["stroke"])
      
      
      @dataclass(frozen=True)
      class DesignBrief:
          scheme: str = "S1"            # preset id from references/design_specs.md
          layout: str = "band"          # band | node  (key semantics, see module doc)
          flow: str = "top-down"        # top-down | left-right | none (no dominant axis)
          # key = data-node-id (band: layer container; node: primary node)
          palette_role: Mapping[str, ColorSpec] = field(default_factory=dict)
          # ordered pipeline chain (first -> last along the flow axis): a SUBSET of
          # palette keys; side bands / text-only bands stay out of the chain.
          flow_chain: Tuple[str, ...] = ()
      
          def __post_init__(self):
              if self.layout not in _LAYOUTS:
                  raise ValueError(
                      f"layout must be one of {_LAYOUTS}, got {self.layout!r}")
              if self.flow not in _FLOWS:
                  raise ValueError(
                      f"flow must be one of {_FLOWS}, got {self.flow!r}")
              object.__setattr__(self, "palette_role", dict(self.palette_role))
              unknown = [k for k in self.flow_chain if k not in self.palette_role]
              if unknown:
                  raise ValueError(
                      f"flow_chain keys not declared in palette_role: {unknown}")
      
          # -- derived views (single source of truth: palette_role) ---------------
          @property
          def tint_keys(self) -> Tuple[str, ...]:
              """Declared keys whose fill carries structure color (tinted)."""
              return tuple(k for k, s in self.palette_role.items()
                           if not is_plain(s.fill))
      
          @property
          def plain_keys(self) -> Tuple[str, ...]:
              """Declared keys that stay white-bottomed (op cards)."""
              return tuple(k for k, s in self.palette_role.items()
                           if is_plain(s.fill))
      
          @property
          def layers(self) -> Tuple[str, ...]:
              """Ordered chain stages (first -> last along the flow axis): the
              declared flow_chain. Empty for node-style briefs / no chain."""
              return tuple(self.flow_chain)
      
          # -- serialization (brief.json next to the artifact triplet) -----------
          def to_dict(self) -> dict:
              return {
                  "scheme": self.scheme,
                  "layout": self.layout,
                  "flow": self.flow,
                  "flow_chain": list(self.flow_chain),
                  "palette_role": {k: {"fill": s.fill, "stroke": s.stroke}
                                   for k, s in self.palette_role.items()},
              }
      
          def to_json(self) -> str:
              return json.dumps(self.to_dict(), indent=2, ensure_ascii=False)
      
          @classmethod
          def from_dict(cls, d) -> "DesignBrief":
              return cls(
                  scheme=d.get("scheme", "S1"),
                  layout=d.get("layout", "band"),
                  flow=d.get("flow", "top-down"),
                  flow_chain=tuple(d.get("flow_chain", ())),
                  palette_role={k: ColorSpec.from_json(v)
                                for k, v in d.get("palette_role", {}).items()},
              )
      
          @classmethod
          def from_json(cls, text: str) -> "DesignBrief":
              return cls.from_dict(json.loads(text))
      
          @classmethod
          def load(cls, path) -> "DesignBrief":
              with open(path, "r", encoding="utf-8") as fh:
                  return cls.from_json(fh.read())
      
          def write(self, path) -> None:
              with open(path, "w", encoding="utf-8") as fh:
                  fh.write(self.to_json())
      
      
      def load_brief_file(path) -> "DesignBrief":
          """Convenience loader used by gen.py / tests; returns None when absent."""
          try:
              return DesignBrief.load(path)
          except FileNotFoundError:
              return None
      
    • evaluator.py 72 KB
      import sys
      import os
      import math
      import re as _re
      import html
      
      # Add the scripts directory to path to import BBox. insert(0) so this dir wins
      # over any same-named module elsewhere on sys.path (matches eval-gen convention).
      sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
      from svg_utils import BBox
      
      class Issue(str):
          """A diagnostic line that is ALSO machine-readable.
      
          Renders byte-identically to the plain report strings every existing
          consumer (gen.py prints, regression score parser, agent prompts) expects,
          while carrying structured fields for programmatic repair:
            code     — stable rule id ("spacing/too-close", "composition/gutter");
            subject  — the node/edge id the finding is about;
            evidence — measured numbers the fix should use ({gap, min_gap, ...}).
          auto_refine dispatches on `code` and sizes its fixes from `evidence`
          instead of regexing the human sentence (archify's structured-diagnostics
          pattern, adapted to the string-report contract).
          """
          __slots__ = ("code", "subject", "evidence")
      
          def __new__(cls, text, code="", subject=None, evidence=None):
              obj = super().__new__(cls, text)
              obj.code = code
              obj.subject = subject
              obj.evidence = dict(evidence or {})
              return obj
      
      
      def _issue(text, code="", subject=None, **evidence):
          """Shorthand: build an Issue carrying its rule code and measured evidence."""
          return Issue(text, code=code, subject=subject, evidence=evidence)
      
      
      def bbox_union_area(bboxes):
          """Area of the union of rectangles (scanline / sweep algorithm).
      
          Unlike summing individual areas, nested/overlapping bboxes are counted once,
          so a parent container + its children don't inflate coverage. Ported concept
          from standard rectangle-union sweep: collect x-edges, for each strip sum the
          active y-coverage.
          """
          if not bboxes:
              return 0.0
          xs = sorted(set(b.x for b in bboxes) | set(b.x + b.w for b in bboxes))
          total = 0.0
          for i in range(len(xs) - 1):
              x0, x1 = xs[i], xs[i + 1]
              width = x1 - x0
              if width <= 0:
                  continue
              # active intervals on y for bboxes spanning this x-strip
              intervals = sorted((b.y, b.y + b.h) for b in bboxes if b.x <= x0 and b.x + b.w >= x1)
              merged = 0.0
              cur_start = None
              cur_end = None
              for lo, hi in intervals:
                  if cur_end is None:
                      cur_start, cur_end = lo, hi
                  elif lo <= cur_end:
                      cur_end = max(cur_end, hi)
                  else:
                      merged += cur_end - cur_start
                      cur_start, cur_end = lo, hi
              if cur_end is not None:
                  merged += cur_end - cur_start
              total += width * merged
          return total
      
      
      def _estimate_text_width(content, font_size, bold=False):
          """Approximate rendered text width in px (Arial-like metric).
      
          ASCII glyphs average ~0.55em (bold ~0.62em); CJK/full-width glyphs occupy
          ~1.0em. Independent of the API's own estimate so it works on raw <text>.
      
          Strips SVG/HTML markup (e.g. <tspan ...>..</tspan>) so inline formatting
          tags — standard SVG for subscripts/superscripts — are not counted as
          visible glyphs (which would massively inflate the estimate). Stripping
          happens BEFORE unescaping entities (&amp; -> &): the reverse order would
          turn a user's literal '<b>' (emitted as '&lt;b&gt;') back into a real tag
          and swallow it, undercounting the width. The input is parsed from the
          *rendered* SVG, where svg_utils.text() has already html.escape()d content.
          """
          visible = html.unescape(_re.sub(r'<[^>]+>', '', content))
          coef = 0.62 if bold else 0.55
          return sum(font_size * (1.0 if ord(ch) > 0x2E80 else coef) for ch in visible)
      
      
      def check_text_overflow(drawer, canvas_pad=2, container_pad=6):
          """Detect <text> elements that overflow the canvas or their container rect.
      
          Parses the rendered SVG (not the registry) so raw add_element text is caught.
          Two failure modes:
            (a) canvas overflow — text bbox exceeds the canvas (FAIL-grade);
            (b) container overflow — text center lies inside a <rect> but the text is
                wider than (rect.width - container_pad), i.e. it spills past the box
                that visually owns it (WARN-grade). The most specific (smallest-area)
                containing rect is chosen, so a card inside a band is judged against
                the card, not the band.
          """
          issues_fail, issues_warn = [], []
          svg = drawer.render()
          W, H = drawer.width, drawer.height
          # collect rects with role
          rects = []
          for attrs in _re.findall(r'<rect ([^>]*)/>', svg):
              p = dict(_re.findall(r'([\w-]+)="([^"]*)"', attrs))
              try:
                  rx, ry = float(p['x']), float(p['y'])
                  rw, rh = float(p['width']), float(p['height'])
              except (KeyError, ValueError):
                  continue
              rects.append((rx, ry, rw, rh, p.get('data-graph-role', '')))
          # collect texts
          for attrs, content in _re.findall(r'<text ([^>]*)>(.*?)</text>', svg, _re.DOTALL):
              p = dict(_re.findall(r'([\w-]+)="([^"]*)"', attrs))
              try:
                  tx, ty = float(p['x']), float(p['y'])
              except (KeyError, ValueError):
                  continue
              if not content.strip():
                  continue
              fs = float(p.get('font-size', '12'))
              anchor = p.get('text-anchor', 'start')
              bold = 'bold' in p.get('font-weight', 'normal')
              w = _estimate_text_width(content, fs, bold)
              if anchor == 'middle':
                  lx = tx - w / 2
              elif anchor == 'end':
                  lx = tx - w
              else:
                  lx = tx
              rx = lx + w
              asc, desc = ty - fs * 0.5, ty + fs * 0.5  # dominant-baseline="central" -> (x,y) is the vertical center
              snippet = content.strip()[:32]
              # (a) canvas overflow
              if lx < -canvas_pad or rx > W + canvas_pad or asc < -canvas_pad or desc > H + canvas_pad:
                  issues_fail.append(
                      f"[text] '{snippet}' overflows canvas (x {lx:.0f}-{rx:.0f}, y {asc:.0f}-{desc:.0f})."
                  )
                  continue
              # (b) container overflow vs the smallest rect containing the text anchor.
              # Use the anchor point (tx, baseline ty) — the text's (x,y) normally sits
              # inside its owning box even when the rendered glyphs spill past the edge.
              cx, cy = tx, ty
              containing = [(a, b, c, e, role) for (a, b, c, e, role) in rects
                            if c >= 4 and e >= 4 and a <= cx <= a + c and b <= cy <= b + e]
              if containing:
                  a, b, c, e, role = min(containing, key=lambda r: r[2] * r[3])
                  if lx < a + container_pad or rx > a + c - container_pad:
                      issues_warn.append(
                          f"[text] '{snippet}' overflows container "
                          f"({c:.0f}x{e:.0f}, role={role or 'node'}): text w={w:.0f}."
                      )
          return issues_fail, issues_warn
      
      
      def _seg_rect_intersect(x1, y1, x2, y2, rx, ry, rw, rh):
          """Liang-Barsky: does segment (x1,y1)-(x2,y2) meet rect [rx,ry,rw,rh]?"""
          dx, dy = x2 - x1, y2 - y1
          t0, t1 = 0.0, 1.0
          for p, q in ((-dx, x1 - rx), (dx, rx + rw - x1), (-dy, y1 - ry), (dy, ry + rh - y1)):
              if p == 0:
                  if q < 0:
                      return False
              else:
                  r = q / p
                  if p < 0:
                      if r > t1:
                          return False
                      if r > t0:
                          t0 = r
                  else:
                      if r < t0:
                          return False
                      if r < t1:
                          t1 = r
          return t0 <= t1
      
      
      def _point_in_poly(px, py, pts):
          """Ray-casting point-in-polygon test (pts = list of (x, y))."""
          n = len(pts)
          inside = False
          j = n - 1
          for i in range(n):
              xi, yi = pts[i]
              xj, yj = pts[j]
              if ((yi > py) != (yj > py)) and (px < (xj - xi) * (py - yi) / (yj - yi + 1e-12) + xi):
                  inside = not inside
              j = i
          return inside
      
      
      def _rects_overlap(a, b):
          """AABB overlap test for (x, y, w, h) tuples; touching edges do not count."""
          return not (a[0] + a[2] <= b[0] or b[0] + b[2] <= a[0]
                      or a[1] + a[3] <= b[1] or b[1] + b[3] <= a[1])
      
      
      def check_text_overlaps(drawer, pad=1.0):
          """Detect <text> elements overlapping visible shapes OR other text.
      
          Parses the rendered SVG (registry-blind) — closes the gap left by
          check_collisions() (which only sees bbox-registered elements) and
          check_text_overflow() (which only compares text vs <rect> containers).
          Two failure modes:
            (a) text-vs-shape: a <text> bbox intersects a visible circle/rect/
                polygon/line/path. Legend/background shapes and rects fully
                containing the text (intentional in-box labels) are exempt.
            (b) text-vs-text: two <text> bboxes intersect.
      
          Text bbox uses the center model (dominant-baseline="central"): (x,y) is
          the vertical center and height = font_size. Shapes drawn inside a
          <g transform> (database/cloud/component/...) are in local coords here;
          those are already covered by check_collisions() via the node registry
          (register_node maps group-local coords to absolute). This check targets
          raw add_element shapes and text that bypass the registry — the documented
          blind spot where text/labels drawn with bbox=False are invisible to the
          collision registry.
          """
          issues = []
          svg = drawer.render()
          W, H = drawer.width, drawer.height
      
          # ---- collect text bboxes (center model) ----
          texts = []  # (x, y, w, h, content)
          for attrs, content in _re.findall(r'<text ([^>]*)>(.*?)</text>', svg, _re.DOTALL):
              p = dict(_re.findall(r'([\w-]+)="([^"]*)"', attrs))
              try:
                  tx, ty = float(p['x']), float(p['y'])
              except (KeyError, ValueError):
                  continue
              if not content.strip():
                  continue
              fs = float(p.get('font-size', '12'))
              bold = 'bold' in p.get('font-weight', 'normal')
              w = _estimate_text_width(content, fs, bold)
              anchor = p.get('text-anchor', 'start')
              lx = tx - w / 2 if anchor == 'middle' else (tx - w if anchor == 'end' else tx)
              texts.append((lx, ty - fs / 2, w, fs, content.strip()))
      
          # ---- collect visible shapes (skip role=legend/background for shapes) ----
          def _visible(p):
              return not (p.get('fill', 'none') == 'none' and p.get('stroke', 'none') == 'none')
      
          circles = []
          for attrs in _re.findall(r'<circle ([^>]*)/>', svg):
              p = dict(_re.findall(r'([\w-]+)="([^"]*)"', attrs))
              if not _visible(p):
                  continue
              try:
                  circles.append((float(p['cx']), float(p['cy']), float(p['r']), p.get('data-graph-role', '')))
              except (KeyError, ValueError):
                  pass
          rects = []
          for attrs in _re.findall(r'<rect ([^>]*)/>', svg):
              p = dict(_re.findall(r'([\w-]+)="([^"]*)"', attrs))
              if not _visible(p):
                  continue
              try:
                  rects.append((float(p['x']), float(p['y']), float(p['width']), float(p['height']), p.get('data-graph-role', '')))
              except (KeyError, ValueError):
                  pass
          polys = []
          for attrs in _re.findall(r'<polygon ([^>]*)/>', svg):
              p = dict(_re.findall(r'([\w-]+)="([^"]*)"', attrs))
              if not _visible(p):
                  continue
              try:
                  pts = [tuple(map(float, c.split(','))) for c in p['points'].split()]
                  polys.append((pts, p.get('data-graph-role', '')))
              except (KeyError, ValueError):
                  pass
          lines = []
          for attrs in _re.findall(r'<line ([^>]*)/>', svg):
              p = dict(_re.findall(r'([\w-]+)="([^"]*)"', attrs))
              try:
                  lines.append((float(p['x1']), float(p['y1']), float(p['x2']), float(p['y2']), p.get('data-graph-role', '')))
              except (KeyError, ValueError):
                  pass
          paths = []
          for m in _re.findall(r'<path d="([^"]*)"[^>]*/>', svg):
              sampled = sample_path(m)
              if sampled:
                  paths.append(sampled)
      
          def _shape_hits(tx, ty, tw, th):
              """Yield (kind, desc) for each visible shape overlapping the text bbox."""
              tb = (tx, ty, tw, th)
              for cx, cy, r, role in circles:
                  if role in ('legend',):
                      continue
                  nx = max(tx, min(cx, tx + tw))
                  ny = max(ty, min(cy, ty + th))
                  if (cx - nx) ** 2 + (cy - ny) ** 2 < (r - pad) ** 2:
                      yield ('circle', 'circle (%.0f,%.0f) r=%.0f' % (cx, cy, r))
              for rx, ry, rw, rh, role in rects:
                  # skip legend/background shapes and the full-canvas bg rect
                  if role in ('legend', 'background'):
                      continue
                  if rx <= 1 and ry <= 1 and rx + rw >= W - 1 and ry + rh >= H - 1:
                      continue
                  if _rects_overlap(tb, (rx, ry, rw, rh)):
                      # exempt: rect fully contains the text (intentional in-box label)
                      if rx <= tx and ty >= ry and tx + tw <= rx + rw and ty + th <= ry + rh:
                          continue
                      yield ('rect', 'rect (%.0f,%.0f,%.0f,%.0f)' % (rx, ry, rw, rh))
              for pts, role in polys:
                  if role in ('legend', 'background'):
                      continue
                  pxs = [pt[0] for pt in pts]
                  pys = [pt[1] for pt in pts]
                  pb = (min(pxs), min(pys), max(pxs) - min(pxs), max(pys) - min(pys))
                  if not _rects_overlap(tb, pb):
                      continue
                  samples = [(tx, ty), (tx + tw, ty), (tx, ty + th), (tx + tw, ty + th), (tx + tw / 2, ty + th / 2)]
                  if any(_point_in_poly(sx, sy, pts) for sx, sy in samples):
                      yield ('polygon', 'polygon near (%.0f,%.0f)' % (tx + tw / 2, ty + th / 2))
              for x1, y1, x2, y2, role in lines:
                  if role in ('legend',):
                      continue
                  if _seg_rect_intersect(x1, y1, x2, y2, tx - pad, ty - pad, tw + 2 * pad, th + 2 * pad):
                      yield ('line', 'line (%.0f,%.0f)-(%.0f,%.0f)' % (x1, y1, x2, y2))
              for sp in paths:
                  hit = False
                  for k in range(len(sp) - 1):
                      if _seg_rect_intersect(sp[k][0], sp[k][1], sp[k + 1][0], sp[k + 1][1],
                                        tx - pad, ty - pad, tw + 2 * pad, th + 2 * pad):
                          hit = True
                          break
                  if hit:
                      yield ('path', 'path near (%.0f,%.0f)' % (tx + tw / 2, ty + th / 2))
      
          # (a) text vs shape
          for tx, ty, tw, th, content in texts:
              snippet = content[:32]
              for kind, desc in _shape_hits(tx, ty, tw, th):
                  issues.append("[text] '%s' overlaps %s (%s)." % (snippet, kind, desc))
      
          # (b) text vs text
          for i in range(len(texts)):
              for j in range(i + 1, len(texts)):
                  a = texts[i]
                  b = texts[j]
                  if _rects_overlap(a[:4], b[:4]):
                      issues.append("[text] '%s' overlaps text '%s'." % (a[4][:32], b[4][:32]))
          return issues
      
      
      def check_connections(drawer, tolerance=12.0, min_length=4.0):
          """Validate that every registered Edge actually lands on a Node border.
      
          Returns a list of human-readable issue strings. An edge endpoint is
          "dangling" when its nearest registered node border is farther than
          `tolerance` pixels away. Edges shorter than `min_length` are degenerate.
      
          Args:
              drawer: SVGDrawer with .nodes and .edges populated.
              tolerance: max allowed distance (px) from an endpoint to a node border.
              min_length: edges shorter than this (px) are flagged as degenerate.
          """
          issues = []
          if not drawer.nodes:
              # Nothing to connect to; skip silently.
              return issues
      
          for edge in drawer.edges:
              # Degenerate / zero-length edges.
              if edge.length < min_length:
                  issues.append(
                      f"[edge:{edge.id}] Degenerate edge length {edge.length:.1f}px "
                      f"(< {min_length}); from {edge.start} to {edge.end}."
                  )
                  continue
      
              for ep_name, pt in (("start", edge.start), ("end", edge.end)):
                  node, dist = drawer.nearest_node(pt[0], pt[1])
                  if node is None:
                      issues.append(
                          f"[edge:{edge.id}] {ep_name} at ({pt[0]:.0f},{pt[1]:.0f}) "
                          f"has no registered node to connect to."
                      )
                  elif dist > tolerance:
                      issues.append(
                          f"[edge:{edge.id}] {ep_name} at ({pt[0]:.0f},{pt[1]:.0f}) "
                          f"dangles: nearest node '{node.id}' is {dist:.1f}px away "
                          f"(tol={tolerance:.0f})."
                      )
          return issues
      
      
      def check_duplicate_edges(drawer, tol=6.0):
          """Flag near-duplicate edges (same endpoints within `tol` px)."""
          issues = []
          edges = drawer.edges
          for i in range(len(edges)):
              for j in range(i + 1, len(edges)):
                  a, b = edges[i], edges[j]
                  d_start = math.hypot(a.start[0] - b.start[0], a.start[1] - b.start[1])
                  d_end = math.hypot(a.end[0] - b.end[0], a.end[1] - b.end[1])
                  if d_start < tol and d_end < tol:
                      issues.append(
                          f"[edge:{a.id}] overlaps [edge:{b.id}] "
                          f"(start Δ{d_start:.1f}px, end Δ{d_end:.1f}px)."
                      )
          return issues
      
      
      PATH_TOKEN_RE = _re.compile(r"[AaCcHhLlMmQqSsTtVvZz]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?")
      
      
      def _sample_cubic(p0, p1, p2, p3, steps=16):
          pts = []
          for i in range(1, steps + 1):
              t = i / steps
              mt = 1 - t
              x = mt**3 * p0[0] + 3 * mt**2 * t * p1[0] + 3 * mt * t**2 * p2[0] + t**3 * p3[0]
              y = mt**3 * p0[1] + 3 * mt**2 * t * p1[1] + 3 * mt * t**2 * p2[1] + t**3 * p3[1]
              pts.append((x, y))
          return pts
      
      
      def _sample_quadratic(p0, p1, p2, steps=12):
          pts = []
          for i in range(1, steps + 1):
              t = i / steps
              mt = 1 - t
              x = mt**2 * p0[0] + 2 * mt * t * p1[0] + t**2 * p2[0]
              y = mt**2 * p0[1] + 2 * mt * t * p1[1] + t**2 * p2[1]
              pts.append((x, y))
          return pts
      
      
      def sample_path(path_d):
          """Flatten an SVG path `d` into a polyline of (x, y) points.
      
          Supports M/L/H/V/C/S/Q/T/Z (absolute & relative). Arcs (A) are approximated
          by their chord (endpoints only) — adequate for collision/crossing checks.
          Ported from fireworks-tech-graph path_routes.
          """
          if not path_d:
              return []
          tokens = PATH_TOKEN_RE.findall(path_d)
          routes = []
          points = []
          idx = 0
          cmd = ""
          cur = (0.0, 0.0)
          start = cur
          prev_c = None
          prev_q = None
      
          def read(n):
              nonlocal idx
              if idx + n > len(tokens) or any(_re.fullmatch(r"[A-Za-z]", t) for t in tokens[idx:idx + n]):
                  return None
              vals = [float(t) for t in tokens[idx:idx + n]]
              idx += n
              return vals
      
          def abspt(x, y, rel):
              return (cur[0] + x, cur[1] + y) if rel else (x, y)
      
          while idx < len(tokens):
              if _re.fullmatch(r"[A-Za-z]", tokens[idx]):
                  cmd = tokens[idx]; idx += 1
              if not cmd:
                  return []
              rel = cmd.islower()
              op = cmd.upper()
              if op == "Z":
                  if cur != start:
                      points.append(start)
                  cur = start; prev_c = prev_q = None; cmd = ""
                  continue
              count = {"M": 2, "L": 2, "H": 1, "V": 1, "C": 6, "S": 4, "Q": 4, "T": 2, "A": 7}.get(op)
              if count is None:
                  return []
              vals = read(count)
              if vals is None:
                  return []
              if op == "M":
                  if points:
                      routes.append(points)
                  cur = abspt(vals[0], vals[1], rel); start = cur; points = [cur]
                  cmd = "l" if rel else "L"
              elif op == "L":
                  cur = abspt(vals[0], vals[1], rel); points.append(cur)
              elif op == "H":
                  cur = (cur[0] + vals[0], cur[1]) if rel else (vals[0], cur[1]); points.append(cur)
              elif op == "V":
                  cur = (cur[0], cur[1] + vals[0]) if rel else (cur[0], vals[0]); points.append(cur)
              elif op == "C":
                  c1 = abspt(vals[0], vals[1], rel); c2 = abspt(vals[2], vals[3], rel); e = abspt(vals[4], vals[5], rel)
                  points.extend(_sample_cubic(cur, c1, c2, e))
                  cur, prev_c = e, c2; prev_q = None
              elif op == "S":
                  c1 = (2 * cur[0] - prev_c[0], 2 * cur[1] - prev_c[1]) if prev_c else cur
                  c2 = abspt(vals[0], vals[1], rel); e = abspt(vals[2], vals[3], rel)
                  points.extend(_sample_cubic(cur, c1, c2, e))
                  cur, prev_c = e, c2; prev_q = None
              elif op == "Q":
                  c = abspt(vals[0], vals[1], rel); e = abspt(vals[2], vals[3], rel)
                  points.extend(_sample_quadratic(cur, c, e))
                  cur, prev_q = e, c; prev_c = None
              elif op == "T":
                  c = (2 * cur[0] - prev_q[0], 2 * cur[1] - prev_q[1]) if prev_q else cur
                  e = abspt(vals[0], vals[1], rel)
                  points.extend(_sample_quadratic(cur, c, e))
                  cur, prev_q = e, c; prev_c = None
              elif op == "A":
                  e = abspt(vals[5], vals[6], rel)
                  points.append(e)  # chord approximation
                  cur = e; prev_c = prev_q = None
              if op not in {"C", "S", "Q", "T"}:
                  prev_c = prev_q = None
          if points:
              routes.append(points)
          return routes[0] if len(routes) == 1 else [p for r in routes for p in r]
      
      
      def edge_polyline(edge):
          """Return the edge as a list of (x,y) vertices, sampling curves if present.
      
          Falls back to [start, end] for straight-line edges (no path_d).
          """
          if getattr(edge, "path_d", None):
              sampled = sample_path(edge.path_d)
              if sampled:
                  return sampled
          return [edge.start, edge.end]
      
      def _segment_core_hit(p1, p2, rect, samples=24):
          """True if the segment p1->p2 passes through the *core* of `rect`.
      
          `rect` is a (x, y, w, h) tuple already shrunk to the "interior core" (a
          margin inset from the true border), so lines that merely graze the edge do
          not register. Straight-line sampling is sufficient for connect() straight
          edges; curved edges are approximated by their chord (under-detects).
          """
          x0, y0, x1, y1 = p1[0], p1[1], p2[0], p2[1]
          rx, ry, rw, rh = rect
          for i in range(samples + 1):
              t = i / samples
              px = x0 + (x1 - x0) * t
              py = y0 + (y1 - y0) * t
              if rx < px < rx + rw and ry < py < ry + rh:
                  return True
          return False
      
      
      def check_edge_node_collisions(drawer, interior_margin=3.0, conn_tolerance=12.0):
          """Detect edges that cut through nodes they are not connected to.
      
          Each edge is flattened to a polyline (curves sampled via sample_path) and
          every segment is tested against each registered node whose border is NOT
          the edge's own endpoint owner. A hit means the line crosses the node's
          interior core (inset by `interior_margin`), i.e. it routes *through* an
          unrelated component. Inspired by ink-graph pitfall #3 and fireworks-tech-graph
          find_collisions + segment_hits_bounds.
          """
          issues = []
          if not drawer.nodes or not drawer.edges:
              return issues
          for edge in drawer.edges:
              if getattr(edge, "role", "edge") != "edge":
                  continue  # decorative edges (rail casings) don't count
              owner_start, _ = drawer.nearest_node(*edge.start)
              owner_end, _ = drawer.nearest_node(*edge.end)
              owners = {n.id for n in (owner_start, owner_end) if n is not None}
              poly = edge_polyline(edge)
              for nid, node in drawer.nodes.items():
                  if nid in owners or getattr(node, "role", "node") != "node":
                      continue  # decorative/legend nodes aren't routing obstacles
                  core = (node.x + interior_margin, node.y + interior_margin,
                          max(node.w - 2 * interior_margin, 1.0),
                          max(node.h - 2 * interior_margin, 1.0))
                  if any(_segment_core_hit(a, b, core) for a, b in zip(poly, poly[1:])):
                      issues.append(
                          f"[edge:{edge.id}] routes through node '{nid}' interior "
                          f"({edge.start}->{edge.end})."
                      )
          return issues
      
      
      def check_spacing(drawer, min_gap=14.0, kinds=("op", "junction")):
          """Flag same-kind nodes closer than `min_gap` px (Euclidean gap).
      
          Geometric containment pairs (a chip fully inside its card) are exempt:
          their clearance is the container-gutter rule's job (check_composition),
          not a same-kind sibling-spacing bug.
          """
          issues = []
          nodes = [n for n in drawer.nodes.values() if n.kind in kinds and getattr(n, "role", "node") == "node"]
          def _contains(big, small):
              """True when *small*'s four edges all lie inside *big* — the same
              containment test check_composition's gutter rule uses. A chip fully
              inside its card is a legal (and common) layout, not a spacing bug:
              its distance to the container is judged by the gutter rule instead."""
              return (big.x <= small.x and small.x + small.w <= big.x + big.w
                      and big.y <= small.y and small.y + small.h <= big.y + big.h)
          for i in range(len(nodes)):
              for j in range(i + 1, len(nodes)):
                  a, b = nodes[i], nodes[j]
                  if _contains(a, b) or _contains(b, a):
                      continue  # containment pair: gutter rule owns it, not spacing
                  dx = max(a.x - (b.x + b.w), b.x - (a.x + a.w), 0.0)
                  dy = max(a.y - (b.y + b.h), b.y - (a.y + a.h), 0.0)
                  gap = math.hypot(dx, dy)
                  if gap < min_gap:
                      issues.append(_issue(
                          f"[spacing] '{a.id}' and '{b.id}' only {gap:.1f}px apart "
                          f"(< {min_gap}).",
                          code="spacing/too-close", subject=b.id,
                          a=a.id, gap=round(gap, 1), min_gap=min_gap,
                          axis="x" if dx > dy else "y",
                      ))
          return issues
      
      
      def _overlap_len(a0, a1, b0, b1):
          """Length of the overlap of intervals [a0,a1] and [b0,b1]; 0 if disjoint."""
          return max(0.0, min(a1, b1) - max(a0, b0))
      
      
      def check_alignment(drawer, edge_tol=5.0, center_frac=0.15,
                          overlap_frac=0.5, size_tol=6.0, kinds=("op",)):
          """Flag same-kind nodes that read as a row/column yet share no edge.
      
          Two same-kind visible nodes are "row peers" when their vertical extents
          overlap strongly (>= overlap_frac of the shorter height) while their
          horizontal extents do not overlap (side-by-side) — the eye then expects a
          shared top, bottom, or vertical-center line. "Column peers" mirror this on
          the other axis. A pair is flagged only when it shares NEITHER an edge
          (within edge_tol) NOR a center line (within center_frac * the shorter
          side), which keeps false positives low. Only SAME-SIZED peers (w/h within
          size_tol) are compared: a row/column of differently-sized components
          legitimately staggers, and "should align" only applies to peer modules of
          the same footprint. Encodes the "align to shared edges" layout principle.
          """
          issues = []
          nodes = [n for n in drawer.nodes.values()
                   if n.kind in kinds and getattr(n, "role", "node") == "node" and n.visible]
          for i in range(len(nodes)):
              a = nodes[i]
              ax0, ay0, ax1, ay1 = a.x, a.y, a.x + a.w, a.y + a.h
              for j in range(i + 1, len(nodes)):
                  b = nodes[j]
                  bx0, by0, bx1, by1 = b.x, b.y, b.x + b.w, b.y + b.h
                  # Only same-sized peers are expected to share an alignment edge;
                  # a row/column of differently-sized components legitimately staggers.
                  if abs(a.w - b.w) > size_tol or abs(a.h - b.h) > size_tol:
                      continue
                  vov = _overlap_len(ay0, ay1, by0, by1)
                  hov = _overlap_len(ax0, ax1, bx0, bx1)
                  # row peers: strong vertical overlap, side by side (no h-overlap)
                  min_h = min(a.h, b.h)
                  if min_h > 0 and vov >= overlap_frac * min_h and hov <= 0:
                      acy, bcy = (ay0 + ay1) * 0.5, (by0 + by1) * 0.5
                      if not (abs(ay0 - by0) <= edge_tol            # top edge
                              or abs(ay1 - by1) <= edge_tol          # bottom edge
                              or abs(acy - bcy) <= center_frac * min_h):
                          issues.append(
                              f"[alignment] row peers '{a.id}' and '{b.id}' share no "
                              f"top/bottom/center line (top Δ{abs(ay0-by0):.0f}px, "
                              f"bottom Δ{abs(ay1-by1):.0f}px)."
                          )
                      continue
                  # column peers: strong horizontal overlap, stacked (no v-overlap)
                  min_w = min(a.w, b.w)
                  if min_w > 0 and hov >= overlap_frac * min_w and vov <= 0:
                      acx, bcx = (ax0 + ax1) * 0.5, (bx0 + bx1) * 0.5
                      if not (abs(ax0 - bx0) <= edge_tol            # left edge
                              or abs(ax1 - bx1) <= edge_tol          # right edge
                              or abs(acx - bcx) <= center_frac * min_w):
                          issues.append(
                              f"[alignment] column peers '{a.id}' and '{b.id}' share "
                              f"no left/right/center line (left Δ{abs(ax0-bx0):.0f}px, "
                              f"right Δ{abs(ax1-bx1):.0f}px)."
                          )
          return issues
      
      def check_phantom_anchors(drawer):
          """Detect invisible nodes used as edge endpoints (phantom anchors).
      
          A node registered with fill=none + stroke=none (or opacity=0 / zero size)
          renders nothing, so any edge that lands on it is visually dangling even
          though the geometric connection check passes. This catches the "add an
          invisible box to fool the validator" anti-pattern. Only nodes that are
          actually referenced by at least one edge are reported.
          """
          issues = []
          if not drawer.edges:
              return issues
          referenced = set()
          for edge in drawer.edges:
              for pt in (edge.start, edge.end):
                  node, _ = drawer.nearest_node(*pt)
                  if node is not None:
                      referenced.add(node.id)
          for nid in sorted(referenced):
              node = drawer.nodes.get(nid)
              if node is not None and not node.visible:
                  issues.append(
                      f"[phantom] node '{nid}' is invisible (no fill/stroke or zero "
                      f"opacity/size) but is used as an edge endpoint."
                  )
          return issues
      
      
      def _orient(a, b, c):
          """Sign of the cross product (b-a) x (c-a): >0 ccw, <0 cw, 0 collinear."""
          return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
      
      
      def _segments_properly_cross(p1, p2, p3, p4):
          """True iff segment p1p2 and p3p4 cross strictly in their interiors.
      
          Excludes touching endpoints/collinear overlap, so edges sharing a node
          (fan-out) are not flagged. Uses the standard orientation test.
          """
          d1 = _orient(p3, p4, p1)
          d2 = _orient(p3, p4, p2)
          d3 = _orient(p1, p2, p3)
          d4 = _orient(p1, p2, p4)
          return (d1 * d2 < 0) and (d3 * d4 < 0)
      
      
      def check_edge_crossings(drawer):
          """Detect pairs of edges whose polylines cross in their interiors.
      
          A zero-crossing budget is a standard diagram quality gate (see
          fireworks-tech-graph composition contract). Edges are flattened to
          polylines (curves sampled via sample_path) and every segment pair is tested
          with the orientation test; touching endpoints/collinear overlap are excluded
          so edges sharing a node (fan-out) are not flagged.
          """
          issues = []
          edges = [e for e in drawer.edges if getattr(e, "role", "edge") == "edge"]
          polylines = [edge_polyline(e) for e in edges]
          for i in range(len(edges)):
              for j in range(i + 1, len(edges)):
                  pa, pb = polylines[i], polylines[j]
                  crossed = False
                  for a1, a2 in zip(pa, pa[1:]):
                      if crossed:
                          break
                      for b1, b2 in zip(pb, pb[1:]):
                          if _segments_properly_cross(a1, a2, b1, b2):
                              crossed = True
                              break
                  if crossed:
                      issues.append(f"[cross] edge '{edges[i].id}' crosses edge '{edges[j].id}'.")
          return issues
      
      
      
      
      def _text_bboxes(svg):
          """Parse all <text> elements into (lx, ty, rx, by) bboxes (registry-blind).
      
          Used so text counts as a measurable obstacle for edge/label collision —
          fireworks contract rule 7: "every title/label/legend is an obstacle with
          measurable bounds". Width via the same font-metric estimate as
          check_text_overflow.
          """
          out = []
          for attrs, content in _re.findall(r'<text ([^>]*)>(.*?)</text>', svg, _re.DOTALL):
              p = dict(_re.findall(r'([\w-]+)="([^"]*)"', attrs))
              try:
                  tx, ty = float(p['x']), float(p['y'])
              except (KeyError, ValueError):
                  continue
              if not content.strip():
                  continue
              fs = float(p.get('font-size', '12'))
              anchor = p.get('text-anchor', 'start')
              bold = 'bold' in p.get('font-weight', 'normal')
              w = _estimate_text_width(content, fs, bold)
              if anchor == 'middle':
                  lx = tx - w / 2
              elif anchor == 'end':
                  lx = tx - w
              else:
                  lx = tx
              out.append((lx, ty - fs * 0.5, lx + w, ty + fs * 0.5))
          return out
      
      
      def _seg_hits_rect(p1, p2, rect, samples=12):
          """True if segment p1->p2 passes through rect interior (sampled)."""
          x0, y0, x1, y1 = p1[0], p1[1], p2[0], p2[1]
          rx, ry, rw, rh = rect
          for i in range(samples + 1):
              t = i / samples
              if rx < x0 + (x1 - x0) * t < rx + rw and ry < y0 + (y1 - y0) * t < ry + rh:
                  return True
          return False
      
      
      def check_composition(drawer, max_bends=2, max_route_stretch=1.35,
                            min_gutter=20.0, min_segment=16.0):
          """Composition-quality budget (ported from fireworks assess_composition).
      
          Quantifies "looks clean" into enforceable thresholds on edges and layout:
            - bends per edge (orthogonal turns in the sampled polyline);
            - route stretch (path length / manhattan distance start->end);
            - shortest segment (micro-segments look like rendering bugs);
            - container gutter (node floating inside its container too close to edge);
            - text as obstacle (edge segment passes through a <text> bbox — rule 7).
          Returns (fail_issues, warn_issues) lists.
          """
          fail, warn = [], []
          edges = [e for e in drawer.edges if getattr(e, "role", "edge") == "edge"]
          for edge in edges:
              poly = edge_polyline(edge)
              eid = edge.id or "edge"
              length = sum(math.hypot(poly[k+1][0]-poly[k][0], poly[k+1][1]-poly[k][1])
                           for k in range(len(poly)-1))
              direct = math.hypot(poly[-1][0]-poly[0][0], poly[-1][1]-poly[0][1])
              stretch = length / direct if direct > 1e-9 else 1.0
              if stretch > max_route_stretch + 1e-6:
                  warn.append(f"[composition] edge '{eid}' stretch {stretch:.2f} > {max_route_stretch}.")
              # Curves (path_d with a curve command C/S/Q/T/A) are continuous-curvature
              # by design — sampling one into a polyline yields many tiny non-collinear
              # segments that are NOT orthogonal bends, and the micro-segments are
              # tessellation artifacts, not layout bugs. So bend/shortest-segment checks
              # apply to straight-line edges (raw line() or M/L/H/V-only paths) only.
              # A curve that loops too far is still caught by the stretch check above.
              # (A polyline path_d with only M/L/H/V/Z is straight-line routing and
              # MUST still be checked — it has genuine orthogonal bends.)
              pd = getattr(edge, "path_d", None)
              is_curve = bool(pd) and bool(_re.search(r"[cCqQtTaAsS]", pd))
              if is_curve:
                  continue
              # Straight edge: count discrete turns. A real bend is a direction change
              # above an angle threshold (>15 deg), robust to sub-pixel jitter that
              # made the old exact `cross != 0` test fire on every sample.
              bends = 0
              BEND_ANGLE = math.radians(15)
              for i in range(1, len(poly) - 1):
                  dx1, dy1 = poly[i][0] - poly[i-1][0], poly[i][1] - poly[i-1][1]
                  dx2, dy2 = poly[i+1][0] - poly[i][0], poly[i+1][1] - poly[i][1]
                  if (abs(dx1) > 0.5 or abs(dy1) > 0.5) and (abs(dx2) > 0.5 or abs(dy2) > 0.5):
                      cross = dx1 * dy2 - dy1 * dx2
                      dot = dx1 * dx2 + dy1 * dy2
                      if abs(math.atan2(abs(cross), dot)) > BEND_ANGLE:
                          bends += 1
              if bends > max_bends:
                  warn.append(f"[composition] edge '{eid}' has {bends} bends (limit {max_bends}).")
              segs = [math.hypot(poly[k+1][0]-poly[k][0], poly[k+1][1]-poly[k][1])
                      for k in range(len(poly)-1)]
              shortest = min(segs) if segs else None
              if shortest is not None and shortest < min_segment:
                  warn.append(f"[composition] edge '{eid}' shortest segment {shortest:.1f}px < {min_segment}.")
      
          # container gutter: node vs its smallest containing rect (role=background/layer)
          svg = drawer.render()
          rects = []
          for attrs in _re.findall(r'<rect ([^>]*)/>', svg):
              p = dict(_re.findall(r'([\w-]+)="([^"]*)"', attrs))
              try:
                  rx, ry = float(p['x']), float(p['y'])
                  rw, rh = float(p['width']), float(p['height'])
              except (KeyError, ValueError):
                  continue
              role = p.get('data-graph-role', '')
              if role in ('background', 'layer') and rw > 10 and rh > 10:
                  rects.append((rx, ry, rw, rh, role))
          for nid, node in drawer.nodes.items():
              if getattr(node, "role", "node") != "node":
                  continue
              # Only judge gutter when the node is FULLY inside the container
              # (all four edges within) — center-only containment misflags legitimate
              # cross-band nodes that intentionally straddle a boundary.
              nx0, ny0, nx1, ny1 = node.x, node.y, node.x + node.w, node.y + node.h
              containing = [r for r in rects
                            if r[0] <= nx0 and nx1 <= r[0]+r[2] and r[1] <= ny0 and ny1 <= r[1]+r[3]]
              if not containing:
                  continue
              c = min(containing, key=lambda r: r[2]*r[3])
              gutter = min(node.x - c[0], c[0]+c[2]-(node.x+node.w),
                           node.y - c[1], c[1]+c[3]-(node.y+node.h))
              if gutter < min_gutter:
                  warn.append(_issue(
                      f"[composition] node '{nid}' gutter {gutter:.1f}px < {min_gutter} in container.",
                      code="composition/gutter", subject=nid,
                      gutter=round(gutter, 1), min_gutter=min_gutter,
                      container=(c[0], c[1], c[2], c[3]),
                  ))
      
          # text as obstacle: any edge segment passes through a <text> bbox
          tboxes = _text_bboxes(svg)
          if tboxes and edges:
              for edge in edges:
                  poly = edge_polyline(edge)
                  for a, b in zip(poly, poly[1:]):
                      for (tlx, tty, trx, tby) in tboxes:
                          if _seg_hits_rect(a, b, (tlx, tty, trx-tlx, tby-tty)):
                              eid = edge.id or "edge"
                              fail.append(f"[composition] edge '{eid}' passes through text bbox.")
                              break
                      else:
                          continue
                      break
          return fail, warn
      
      def _extract_font_sizes(svg):
          """Primary font-size values used in the rendered SVG (float list).
      
          Counts font-size on <text> elements only — subscript/superscript
          <tspan> modifiers are derivative of their parent text size and are NOT
          independent typographic tiers, so they are excluded from the tier count.
          """
          sizes = []
          for attrs in _re.findall(r'<text\s+([^>]*)>', svg):
              p = dict(_re.findall(r'([\w-]+)="([^"]*)"', attrs))
              val = p.get('font-size')
              if val is None:
                  continue
              try:
                  sizes.append(float(val))
              except ValueError:
                  pass
          return sizes
      
      
      def _extract_colors(svg):
          """All non-neutral accent fill/stroke colors in the rendered SVG (#rrggbb set)."""
          from svg_utils import normalize_color, is_neutral
          accents = set()
          for raw in _re.findall(r'(?:fill|stroke)="([^"]+)"', svg):
              norm = normalize_color(raw)
              if norm is not None and not is_neutral(norm):
                  accents.add(norm)
          return accents
      
      
      def _extract_fill_stroke(svg):
          """Return (fills, strokes) sets of non-neutral accent colors.
      
          Needed because luminance clash should compare within a channel: a pastel
          fill (L>0.8) paired with a same-family dark stroke (L<0.2) is intentional
          contrast, NOT a clash. Only a dark fill AND a light fill (or dark stroke
          AND light stroke) is a genuine inconsistency.
          """
          from svg_utils import normalize_color, is_neutral
          fills, strokes = set(), set()
          for raw in _re.findall(r'fill="([^"]+)"', svg):
              norm = normalize_color(raw)
              if norm is not None and not is_neutral(norm):
                  fills.add(norm)
          for raw in _re.findall(r'stroke="([^"]+)"', svg):
              norm = normalize_color(raw)
              if norm is not None and not is_neutral(norm):
                  strokes.add(norm)
          return fills, strokes
      
      
      def _extract_background(svg, width, height):
          """Fill of a full-canvas rect at (0,0), else None."""
          from svg_utils import normalize_color
          pat = (r'<rect[^>]*\bx="0"[^>]*\by="0"[^>]*\bwidth="%d"[^>]*\bheight="%d"[^>]*\bfill="([^"]+)"'
                 % (width, height))
          m = _re.search(pat, svg)
          if m:
              return normalize_color(m.group(1))
          return None
      
      
      def check_font_scale(drawer, max_sizes=4, min_step=1.15):
          """Enforce a limited, well-separated type scale (parses the rendered SVG).
      
          A diagram should use a small number of font sizes (professional styles
          empirically use 3-4: title / body / caption). Two failure modes:
            - too many distinct sizes (chaotic typography);
            - near-duplicate sizes (e.g. 11/12/13/14) that should be consolidated
              into one — adjacent sizes should differ by >= min_step ratio (a
              modular type scale; 1.15 ~ major-second/minor-third).
          Parses the actual SVG so it works regardless of how text was drawn.
          """
          issues = []
          sizes = sorted(set(_extract_font_sizes(drawer.render())))
          if len(sizes) > max_sizes:
              issues.append(
                  f"[font] {len(sizes)} distinct font sizes used ({sizes}); cap is "
                  f"{max_sizes}. Consolidate into a title/body/caption scale."
              )
          near_dup = []
          for a, b in zip(sizes, sizes[1:]):
              if a > 0 and b / a < min_step:
                  near_dup.append(f"{a}/{b} (ratio {b/a:.2f} < {min_step})")
          if near_dup:
              issues.append(
                  f"[font] near-duplicate font sizes: {'; '.join(near_dup)}. Merge "
                  f"them (adjacent sizes should differ by >= {min_step}x)."
              )
      
      
      def _is_chromatic(color):
          """True when a color carries a usable hue (HSL saturation >= 0.25).
      
          Desaturated blue-grays (slate tones like #546E7A, S≈0.18) pass
          ``is_neutral``'s R==G==B filter yet read as colorless; pastel tints
          (#DAE8FC, S≈0.85) read as colored despite their lightness. The palette
          floor below keys on this distinction, not on the neutral filter.
          """
          h = color.lstrip('#')
          if len(h) == 3:
              h = ''.join(ch * 2 for ch in h)
          if len(h) != 6:
              return False
          try:
              r, g, b = (int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4))
          except ValueError:
              return False
          mx, mn = max(r, g, b), min(r, g, b)
          l = (mx + mn) / 2.0
          if mx == mn:
              return False                       # pure gray
          s = (mx - mn) / (2 - 2 * l) if l > 0.5 else (mx - mn) / (2 * l)
          return s >= 0.25
      
      
      def _chromatic_shares(svg):
          """(element_share, area_share) %% of chromatic business shapes.
      
          A shape is chromatic when its fill carries a hue, or it is white/unfilled
          with a chromatic stroke (outline-colored chips). Background/legend/
          decoration roles, the full-canvas rect, and specks under 400 px^2 are
          excluded. Element share catches node-style diagrams (color lives in many
          small nodes — constellation/flowchart); area share catches band-style
          diagrams (color lives in large tinted containers). Gray-dominance is low
          on BOTH axes — one strong axis is a legitimate scheme.
          """
          import xml.etree.ElementTree as _ET
          try:
              root = _ET.fromstring(svg)
          except _ET.ParseError:
              return 100.0, 100.0
          m = _re.search(r'<svg[^>]*\bwidth="([\d.]+)"[^>]*\bheight="([\d.]+)"', svg)
          cw, chh = (float(m.group(1)), float(m.group(2))) if m else (0.0, 0.0)
          num = r'-?\d+(?:\.\d+)?'
          chrom_e = neutr_e = 0
          chrom_a = neutr_a = 0.0
          for el in root.iter():
              tag = el.tag.rsplit('}', 1)[-1]
              if tag not in ("rect", "circle", "ellipse", "polygon"):
                  continue
              a = el.attrib
              if a.get("data-graph-role") in ("background", "legend", "decoration"):
                  continue
              fill = (a.get("fill") or "").strip().lower()
              stroke = (a.get("stroke") or "").strip().lower()
              is_chrom = _is_chromatic(fill) or (
                  fill in ("", "none", "#fff", "#ffffff") and _is_chromatic(stroke))
              try:
                  if tag == "rect":
                      fw = float(a.get("width", 0) or 0)
                      fh = float(a.get("height", 0) or 0)
                      area = fw * fh
                      if cw and fw >= cw - 2 and fh >= chh - 2:
                          continue                      # full-canvas background
                  elif tag == "circle":
                      area = 3.14159 * float(a.get("r", 0) or 0) ** 2
                  elif tag == "ellipse":
                      area = 3.14159 * (float(a.get("rx", 0) or 0)
                                        * float(a.get("ry", 0) or 0))
                  else:
                      pts = [float(v) for v in _re.findall(num, a.get("points", ""))]
                      if len(pts) < 4:
                          continue
                      xs, ys = pts[0::2], pts[1::2]
                      area = (max(xs) - min(xs)) * (max(ys) - min(ys))
              except ValueError:
                  continue
              if area < 400:
                  continue
              if is_chrom:
                  chrom_e += 1
                  chrom_a += area
              else:
                  neutr_e += 1
                  neutr_a += area
          te, ta = chrom_e + neutr_e, chrom_a + neutr_a
          return (100.0 * chrom_e / te if te else 100.0,
                  100.0 * chrom_a / ta if ta else 100.0)
      
      
      def check_palette(drawer, max_colors=8, hard_max=12):
          """Enforce a constrained, coherent color palette (parses the rendered SVG).
      
          Counts accent colors (non-neutral fills/strokes) actually drawn. Flags:
            - too many accents (cluttered; warn > max_colors, fail > hard_max);
            - extreme luminance clash WITHIN a channel: very dark (L<0.2) and very
              light (L>0.8) accents coexist among fills, or among strokes. A pastel
              fill paired with a same-family dark stroke is intentional contrast and
              is NOT flagged — only a dark+light mix in the same channel is chaos;
            - non-light background without an explicit dark-theme opt-in.
          Neutral colors (white/black/grays) are structural and excluded from count.
          """
          from svg_utils import relative_luminance
          issues = []
          svg = drawer.render()
          bg = _extract_background(svg, drawer.width, drawer.height)
          accents = sorted(_extract_colors(svg))
          if bg:
              accents = [c for c in accents if c != bg]  # bg is judged separately
      
          # Count.
          if len(accents) > hard_max:
              issues.append(
                  f"[palette] {len(accents)} accent colors used ({accents}); hard cap "
                  f"is {hard_max}. Reduce the palette."
              )
          elif len(accents) > max_colors:
              issues.append(
                  f"[palette] {len(accents)} accent colors used ({accents}); recommend "
                  f"<= {max_colors} for a coherent look."
              )
      
          # Chromatic floor (无配色): at least one accent must carry a real hue.
          # The observed end-state of "fix contrast by de-coloring" is a diagram
          # whose only accents are desaturated slate tones (#546E7A et al.) or none
          # at all — technically not neutral, visually colorless. That is a defect,
          # not a safe palette: the cap above is meaningless without a floor.
          chroma = [c for c in accents if _is_chromatic(c)]
          if not chroma:
              shown = sorted(accents) if accents else ["(none)"]
              issues.append(
                  f"[palette] no chromatic accent color (无配色): accents {shown} "
                  f"carry no readable hue - the diagram is effectively colorless. "
                  f"Pick a preset scheme from references/design_specs.md "
                  f"(S1-S4) and put color into tinted layer fills + accent strokes. "
                  f"Do NOT fix text contrast by de-coloring: pair a light tint fill "
                  f"with its dark accent stroke instead (clears WCAG AA)."
              )
      
          # Gray-dominance (灰色主导): color present but marginal. Calibrated on all
          # 8 goldens (each clears at least one axis by >= 2x margin — node-style
          # goldens ride the element axis, band-style goldens the area axis) and on
          # the agent_infra replay artifact that triggered the complaint: 11%
          # elements / 2.3% area — every band and card neutral, color confined to a
          # few small chips. Low on BOTH axes is a defect; one strong axis is not.
          if chroma:
              elem_share, area_share = _chromatic_shares(svg)
              if elem_share < 35.0 and area_share < 15.0:
                  issues.append(
                      f"[palette] gray-dominant (灰色主导): chromatic color covers "
                      f"only {elem_share:.0f}% of business elements and "
                      f"{area_share:.0f}% of painted area - the structure (bands, "
                      f"containers, cards) reads gray with color confined to small "
                      f"chips. Tint the band/container fills from the scheme "
                      f"(band-style) or color the primary nodes (node-style) so "
                      f"color owns the skeleton, not the decoration."
                  )
      
          # Extreme luminance clash, compared WITHIN each channel (fill vs fill,
          # stroke vs stroke). A pastel fill paired with a same-family dark stroke is
          # intentional contrast, not a clash — only a dark+light mix in the SAME
          # channel signals a neon/pastel inconsistency.
          fills, strokes = _extract_fill_stroke(svg)
          if bg:
              fills = {c for c in fills if c != bg}
          for channel, colors in (("fill", fills), ("stroke", strokes)):
              if len(colors) < 2:
                  continue
              lums = {c: relative_luminance(c) for c in colors}
              dark = sorted(c for c, l in lums.items() if l < 0.2)
              light = sorted(c for c, l in lums.items() if l > 0.8)
              if dark and light:
                  issues.append(
                      f"[palette] extreme luminance clash in {channel}s: very dark "
                      f"{dark} and very light {light} coexist; pick one brightness family."
                  )
          # Background: light is the default for technical diagrams.
          bg = bg or getattr(drawer, "background", "#ffffff")
          if relative_luminance(bg) < 0.3:
              issues.append(
                  f"[palette] background '{bg}' is dark; light backgrounds are the "
                  f"default. Use set_background()/bg= only for an intended dark theme."
              )
          return issues
      
      
      def _contrast_ratio(fg, bg):
          """WCAG 2 contrast ratio (>=1.0) between two '#rrggbb' colors."""
          from svg_utils import relative_luminance
          lf, lb = relative_luminance(fg), relative_luminance(bg)
          hi, lo = max(lf, lb), min(lf, lb)
          return (hi + 0.05) / (lo + 0.05)
      
      
      def check_contrast(drawer, normal_ratio=4.5, large_ratio=3.0,
                         large_px=24.0, large_bold_px=18.5):
          """WCAG 2 text-on-fill contrast (parses the rendered SVG).
      
          Pairs each <text> with the fill of the smallest <rect> containing its
          anchor point, falling back to the canvas background when no rect owns it,
          and measures the WCAG 2 contrast ratio against the text's own fill.
          Thresholds track WCAG 2 AA: 4.5:1 for normal text, 3:1 for large text
          (>=24px, or >=18.5px bold). Only text sitting on a non-neutral (accent)
          fill is measured: the defect this catches is a label that doesn't read on
          its colored card. Accent-colored text on a white/neutral canvas (category
          labels, muted captions) is a typographic choice, not a fill-contrast
          defect, and is skipped. Stroke-only (fill=none) containers are also
          skipped so the text is judged against the concrete fill painted behind it.
          Returns (fail_issues, warn_issues):
            - FAIL: below the large-text floor (large_ratio) — effectively unreadable;
            - WARN: large_ratio..normal_ratio — readable but below AA for labels.
          """
          from svg_utils import normalize_color, is_neutral
          fail, warn = [], []
          svg = drawer.render()
          W, H = drawer.width, drawer.height
      
          # rects that actually paint a background (skip fill=none containers)
          rects = []
          for attrs in _re.findall(r'<rect ([^>]*)/>', svg):
              p = dict(_re.findall(r'([\w-]+)="([^"]*)"', attrs))
              try:
                  rx, ry = float(p['x']), float(p['y'])
                  rw, rh = float(p['width']), float(p['height'])
              except (KeyError, ValueError):
                  continue
              fill = normalize_color(p.get('fill', ''))
              if fill is None:
                  continue
              rects.append((rx, ry, rw, rh, fill))
      
          bg = _extract_background(svg, W, H) or getattr(drawer, "background", "#ffffff")
          bg = normalize_color(bg) or "#ffffff"
      
          for attrs, content in _re.findall(r'<text ([^>]*)>(.*?)</text>', svg, _re.DOTALL):
              p = dict(_re.findall(r'([\w-]+)="([^"]*)"', attrs))
              try:
                  tx, ty = float(p['x']), float(p['y'])
              except (KeyError, ValueError):
                  continue
              if not content.strip():
                  continue
              fg = normalize_color(p.get('fill', 'black'))
              if fg is None:
                  continue  # gradient/none text color — can't measure
              fs = float(p.get('font-size', '12'))
              bold = 'bold' in p.get('font-weight', 'normal')
              # background = smallest containing rect with a concrete fill, else canvas
              owners = [r for r in rects if r[0] <= tx <= r[0] + r[2] and r[1] <= ty <= r[1] + r[3]]
              owner_fill = min(owners, key=lambda r: r[2] * r[3])[4] if owners else bg
              if owner_fill == fg or is_neutral(owner_fill):
                  continue  # identical fill, or neutral bg (canvas/white card) — the
                            # text color there is a typographic choice, not a fill defect
              ratio = _contrast_ratio(fg, owner_fill)
              threshold = large_ratio if (fs >= large_px or (bold and fs >= large_bold_px)) else normal_ratio
              snippet = content.strip()[:32]
              if ratio < large_ratio:
                  fail.append(
                      f"[contrast] '{snippet}' {ratio:.2f}:1 < {large_ratio}:1 "
                      f"(text {fg} on {owner_fill}, {fs:.0f}px)."
                  )
              elif ratio < threshold:
                  warn.append(
                      f"[contrast] '{snippet}' {ratio:.2f}:1 < {threshold}:1 "
                      f"(text {fg} on {owner_fill}, {fs:.0f}px)."
                  )
          return fail, warn
      
      def evaluate_svg(drawer, conn_tolerance=12.0):
          report = []
          score = 100
      
          # 1. Collision Check (rect-level bounding-box overlap)
          collisions = drawer.check_collisions()
          if collisions:
              collision_penalty = len(collisions) * 10
              score -= collision_penalty
              report.append(f"[FAIL] Detected {len(collisions)} element collisions. Penalty: -{collision_penalty}")
          else:
              report.append("[PASS] No element collisions detected.")
      
          # 2. Boundary Check
          overflow_count = 0
          for bbox in drawer.bboxes:
              if bbox.x < 0 or bbox.y < 0 or bbox.x + bbox.w > drawer.width or bbox.y + bbox.h > drawer.height:
                  overflow_count += 1
      
          if overflow_count > 0:
              overflow_penalty = overflow_count * 15
              score -= overflow_penalty
              report.append(f"[FAIL] {overflow_count} elements exceed canvas boundaries. Penalty: -{overflow_penalty}")
          else:
              report.append("[PASS] All elements are within canvas boundaries.")
          # 2b. Text overflow check — parses <text> geometry (registry-blind otherwise).
          t_fail, t_warn = check_text_overflow(drawer)
          if t_fail:
              score -= min(len(t_fail) * 6, 24)
              report.append(f"[FAIL] {len(t_fail)} text element(s) overflow the canvas. Penalty: -{min(len(t_fail) * 6, 24)}")
              for line in t_fail[:8]:
                  report.append(f"        - {line}")
          if t_warn:
              score -= min(len(t_warn) * 3, 18)
              report.append(f"[WARN] {len(t_warn)} text element(s) overflow their container. Penalty: -{min(len(t_warn) * 3, 18)}")
              for line in t_warn[:8]:
                  report.append(f"        - {line}")
          if not t_fail and not t_warn:
              report.append("[PASS] All text fits within canvas and containers.")
      
          # 2c. Text overlap check — text vs shapes AND text vs text (parses the
          #     rendered SVG, registry-blind). Closes the gap left by bbox-registered
          #     check_collisions for elements drawn with bbox=False / add_element.
          overlap_issues = check_text_overlaps(drawer)
          if overlap_issues:
              penalty = min(len(overlap_issues) * 4, 24)
              score -= penalty
              report.append(f"[FAIL] {len(overlap_issues)} text overlap(s) with shapes/other text. Penalty: -{penalty}")
              for line in overlap_issues[:12]:
                  report.append(f"        - {line}")
              if len(overlap_issues) > 12:
                  report.append(f"        - ... and {len(overlap_issues) - 12} more.")
          else:
              report.append("[PASS] No text overlaps shapes or other text.")
      
          # 3. Coverage Analysis
          total_area = drawer.width * drawer.height
          occupied_area = bbox_union_area(drawer.bboxes)
          coverage = (occupied_area / total_area) * 100 if total_area else 0
      
          if coverage < 5:
              score -= 20
              report.append(f"[WARN] Canvas coverage is very low ({coverage:.2f}%). The diagram might look empty.")
          elif coverage > 60:
              score -= 10
              report.append(f"[WARN] Canvas coverage is very high ({coverage:.2f}%). The diagram might look cluttered.")
          else:
              report.append(f"[PASS] Canvas coverage is optimal ({coverage:.2f}%).")
      
          # 4. Connection / Arrow Check (NEW)
          conn_issues = check_connections(drawer, tolerance=conn_tolerance)
          dup_issues = check_duplicate_edges(drawer)
          total_conn = len(conn_issues) + len(dup_issues)
          if total_conn:
              # Cap the penalty so a busy diagram isn't catastrophically punished,
              # but each issue still hurts.
              penalty = min(total_conn * 8, 40)
              score -= penalty
              report.append(
                  f"[FAIL] Connection check found {len(conn_issues)} dangling/degenerate "
                  f"endpoint(s) and {len(dup_issues)} duplicate edge(s). Penalty: -{penalty}"
              )
              for line in (conn_issues + dup_issues)[:12]:
                  report.append(f"        - {line}")
              if total_conn > 12:
                  report.append(f"        - ... and {total_conn - 12} more.")
          elif drawer.edges:
              n_checked = len(drawer.edges)
              report.append(f"[PASS] All {n_checked} connection(s) land on node borders (tol={conn_tolerance}px).")
          else:
              report.append("[INFO] No registered edges; connection check skipped.")
          # 4b. Phantom-anchor check: invisible nodes used as edge endpoints.
          phantom_issues = check_phantom_anchors(drawer)
          if phantom_issues:
              penalty = min(len(phantom_issues) * 15, 45)
              score -= penalty
              report.append(
                  f"[FAIL] {len(phantom_issues)} phantom anchor(s): invisible no
    • semantic_qa.py 55.3 KB
      """semantic_qa.py — semantic smoke-check layered on top of the geometry evaluator.
      
      The geometric evaluator (evaluator.evaluate_svg) answers "does the picture render
      correctly?" — overlaps, boundary overflow, edge endpoints landing on node borders.
      It deliberately does NOT answer "does the picture mean what it should?".
      
      This module answers the second question by parsing the *rendered SVG string* (the
      same "evaluate, don't assert" philosophy) and checking semantic wiring that a
      bounding-box evaluator structurally cannot see:
      
        1.  marker 缺省陷阱  (dangling marker references)
            connect() defaults to marker_end="arrowhead"; a generator that registers
            arrow_head("arrow", ...) but calls connect(..., marker_end="arrow") only in
            *some* places leaves other connections referencing url(#arrowhead) which is
            undefined. SVG silently renders a plain line — no arrowhead, no error. The
            geometry evaluator still sees a valid line segment, so it passes.
        2.  FIGS 尺寸漂移  (declared vs. actual figure size)
            The declared <svg width height> must actually enclose the content bbox; a
            canvas far larger than its content (or content that overflows) is a sizing
            drift the per-element boundary check won't surface holistically.
        3.  标签错位  (label / container mismatch)
            text-anchor=middle labels must sit on their containing node's horizontal
            center; every business node rect should carry a label inside it. A label
            placed inside the *wrong* box, or a node with no label, passes geometry
            checks (it is inside *some* box) yet is semantically wrong.
      
      API
      ---
          run_semantic_qa(drawer_or_svg, expected_size=None) -> SemanticResult
      
          drawable may be an SVGDrawer (calls .render()) or a raw SVG string.
          expected_size may be (w, h) from the design spec; used for the size-drift
          check when the author knows the intended canvas dimensions.
      
          SemanticResult is a dataclass with: issues (list[Issue]), score (int),
          ok (bool). Use .report() to get human-readable lines mirroring evaluator's
          "[FAIL]/[WARN]" format.
      """
      
      from __future__ import annotations
      
      import html
      import math
      import re
      from dataclasses import dataclass, field
      from typing import Optional
      
      try:
          from svg_utils import BBox
      except ImportError:  # allow running the module standalone for tests/debug
          class BBox:  # minimal fallback mirroring svg_utils.BBox
              def __init__(self, x, y, w, h):
                  self.x, self.y, self.w, self.h = x, y, w, h
      
              @property
              def cx(self):
                  return self.x + self.w / 2.0
      
              @property
              def cy(self):
                  return self.y + self.h / 2.0
      
              def contains(self, other):
                  return (self.x <= other.x <= other.x + other.w <= self.x + self.w and
                          self.y <= other.y <= other.y + other.h <= self.y + self.h)
      
      
      # ---------------------------------------------------------------------------
      # Issue + result containers
      # ---------------------------------------------------------------------------
      @dataclass
      class Issue:
          severity: str            # "fail" | "warn"
          code: str                # e.g. "marker-dangling", "size-drift", "label-offcenter"
          message: str
          element: str = ""        # optional svg element snippet for debugging
      
          def render(self) -> str:
              tag = "[FAIL]" if self.severity == "fail" else "[WARN]"
              suffix = f"  ({self.element})" if self.element else ""
              return f"{tag} [semantic:{self.code}] {self.message}{suffix}"
      
      
      @dataclass
      class SemanticResult:
          issues: list = field(default_factory=list)
      
          @property
          def score(self) -> int:
              score = 100
              fails = sum(1 for i in self.issues if i.severity == "fail")
              warns = sum(1 for i in self.issues if i.severity == "warn")
              score -= min(fails * 15, 60)
              score -= min(warns * 3, 15)
              return max(score, 0)
      
          @property
          def ok(self) -> bool:
              return all(i.severity != "fail" for i in self.issues)
      
          @property
          def has_fail(self) -> bool:
              return any(i.severity == "fail" for i in self.issues)
      
          def report(self) -> list[str]:
              if not self.issues:
                  return ["[PASS] semantic QA: marker refs resolve, size coherent, labels aligned."]
              return [i.render() for i in self.issues]
      
      
      # ---------------------------------------------------------------------------
      # Parsing helpers
      # ---------------------------------------------------------------------------
      import xml.etree.ElementTree as _ET
      
      try:
          from svg_utils import (BBox, multiply_matrix, parse_transform,
                                 transform_point, is_neutral, normalize_color)
          from design_brief import is_plain
      except ImportError:  # standalone use without the skill on sys.path
          _I = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
      
          def multiply_matrix(m1, m2):
              return m1 if m2 == _I else m2
      
          def parse_transform(value):
              return _I
      
          def transform_point(m, p):
              return p
      
          def is_neutral(hex_color):
              return False
      
          def normalize_color(value):
              return None
      
          def is_plain(fill):
              return fill in ("", "none", "#ffffff")
      
      
      def _local(tag):
          """Strip an eventual {namespace} prefix from an ElementTree tag."""
          return tag.rsplit('}', 1)[-1]
      
      
      def _f(v, default=None):
          try:
              return float(v)
          except (ValueError, TypeError):
              return default
      
      
      def _text_width(content: str, font_size: float, bold: bool) -> float:
          """Rough rendered width estimate consistent with the evaluator's metric."""
          visible = re.sub(r'<[^>]+>', '', html.unescape(content))
          coef = 0.62 if bold else 0.55
          return sum(font_size * (1.0 if ord(ch) > 0x2E80 else coef) for ch in visible)
      
      
      def _abs_bbox(local_bbox, m):
          """Map a local-space BBox to absolute canvas coords under matrix m."""
          x, y, w, h = local_bbox
          if m == (1.0, 0.0, 0.0, 1.0, 0.0, 0.0):
              return BBox(x, y, w, h)
          pts = [transform_point(m, p) for p in
                 ((x, y), (x + w, y), (x, y + h), (x + w, y + h))]
          xs = [p[0] for p in pts]
          ys = [p[1] for p in pts]
          return BBox(min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys))
      
      
      _NUM_RE = re.compile(r'-?\d+(?:\.\d+)?')
      _PATH_TOKEN_RE = re.compile(r'[MmLlHhVvCcSsQqTtAaZz]|-?\d+(?:\.\d+)?')
      # per-command argument counts (endpoint coords are the last pair of each)
      _PATH_ARITY = {"M": 2, "m": 2, "L": 2, "l": 2, "T": 2, "t": 2,
                     "H": 1, "h": 1, "V": 1, "v": 1,
                     "C": 6, "c": 6, "S": 4, "s": 4, "Q": 4, "q": 4,
                     "A": 7, "a": 7, "Z": 0, "z": 0}
      
      
      def _path_points(d):
          """Approximate a path's geometry by tracking an absolute cursor.
      
          Raw-number-soup bboxes corrupt on arcs (rx/ry/flags leak into the x/y
          pairing — a database cylinder's `A 130,6.96 0 0 1 260,6.96` would grow
          the bbox to 260x260). Walking commands with a cursor and recording the
          endpoint after each gives an honest envelope; control points are ignored,
          which is fine for label-host selection.
          """
          tokens = _PATH_TOKEN_RE.findall(d)
          pts = []
          x = y = 0.0
          cmd, args = None, []
      
          def _apply(tok, vals):
              nonlocal x, y
              n = _PATH_ARITY.get(tok, 0)
              if n == 0 or not vals:
                  return
              # SVG allows implicit coordinate repeats ("L 10,10 20,20") — the
              # number run exceeds the command arity; apply in arity-sized chunks.
              if len(vals) < n:
                  vals = vals + [0.0] * (n - len(vals))
              for i in range(0, len(vals) - n + 1, n):
                  chunk = vals[i:i + n]
                  # curve control points bound the curve's convex hull — include
                  # them in the envelope (a Q's apex lives in its control point;
                  # endpoints alone can collapse a dome path to zero height)
                  if tok in ("C", "S", "Q", "c", "s", "q"):
                      for j in range(0, len(chunk) - 1, 2):
                          pts.append((chunk[j], chunk[j + 1]))
                  if tok in ("M", "L", "T", "C", "S", "Q", "A"):
                      x, y = chunk[-2], chunk[-1]
                  elif tok in ("m", "l", "t", "c", "s", "q", "a"):
                      x, y = x + chunk[-2], y + chunk[-1]
                  elif tok == "H":
                      x = chunk[-1]
                  elif tok == "h":
                      x += chunk[-1]
                  elif tok == "V":
                      y = chunk[-1]
                  elif tok == "v":
                      y += chunk[-1]
                  else:
                      continue
                  pts.append((x, y))
      
          for tok in tokens:
              if tok in _PATH_ARITY:
                  if cmd is not None:
                      _apply(cmd, [float(v) for v in args])
                  cmd, args = tok, []
              else:
                  args.append(tok)
          if cmd is not None:
              _apply(cmd, [float(v) for v in args])
          return pts
      
      
      def _shape_bbox(tag, a):
          """Local bbox for a shape element, or None. Covers every node shape the
          DSL emits: rect, polygon (decision/hexagon/component), circle, ellipse,
          and filled paths (database/cloud)."""
          if tag == "rect":
              x, y = _f(a.get("x")), _f(a.get("y"))
              w, h = _f(a.get("width")), _f(a.get("height"))
              if None in (x, y, w, h):
                  return None
              return (x, y, max(w, 0.0), max(h, 0.0))
          if tag == "circle":
              cx, cy, r = _f(a.get("cx")), _f(a.get("cy")), _f(a.get("r"))
              if None in (cx, cy, r):
                  return None
              return (cx - r, cy - r, 2 * r, 2 * r)
          if tag == "ellipse":
              cx, cy = _f(a.get("cx")), _f(a.get("cy"))
              rx, ry = _f(a.get("rx")), _f(a.get("ry"))
              if None in (cx, cy, rx, ry):
                  return None
              return (cx - rx, cy - ry, 2 * rx, 2 * ry)
          if tag == "polygon":
              nums = [float(v) for v in _NUM_RE.findall(a.get("points", ""))]
              if len(nums) < 4:
                  return None
              xs, ys = nums[0::2], nums[1::2]
              x0, y0 = min(xs), min(ys)
              return (x0, y0, max(xs) - x0, max(ys) - y0)
          if tag == "path":
              # Filled paths are node candidates (database/cloud bodies). A stroke-
              # only path is normally an edge — EXCEPT when the stroke is a thick
              # band (>= 8px): a 15px "pool" arc drawn as a fat stroke is visually
              # a shape that owns its centered label, not a connector.
              fill = a.get("fill") or "none"
              sw = _f(a.get("stroke-width"), 1.0) or 1.0
              if fill in ("none",) and sw < 8.0:
                  return None
              pts = _path_points(a.get("d", ""))
              if len(pts) < 2:
                  return None
              xs = [p[0] for p in pts]
              ys = [p[1] for p in pts]
              x0, y0 = min(xs), min(ys)
              return (x0, y0, max(xs) - x0, max(ys) - y0)
          return None
      
      
      def _walk(el, m, out):
          """Depth-first walk applying group transforms, filling the out dict.
      
          Recurses into ANY element with children (<g>, <defs>, <a>, ...) so nested
          markers and grouped shapes are all seen. <marker> returns early so its
          inner <polygon> (the arrowhead glyph) is never counted as a node box.
          """
          tag = _local(el.tag)
          a = el.attrib
          if tag == "marker":
              if a.get("id"):
                  out["markers"].add(a["id"])
              return
          # marker refs live on any drawn element (line/path/polyline/...);
          # pair each ref with its own element bbox so the report can point at the
          # offending connector's location.
          ref = a.get("marker-end") or a.get("marker-start") or a.get("marker-mid")
          if ref:
              lb = _shape_bbox(tag, a)
              if tag == "line":
                  x1, y1, x2, y2 = (_f(a.get(k)) for k in ("x1", "y1", "x2", "y2"))
                  if None not in (x1, y1, x2, y2):
                      p1, p2 = transform_point(m, (x1, y1)), transform_point(m, (x2, y2))
                      lb = (min(p1[0], p2[0]), min(p1[1], p2[1]),
                            abs(p2[0] - p1[0]) or 1, abs(p2[1] - p1[1]) or 1)
              # stroke-only paths are excluded from _shape_bbox; still give the
              # report a rough location from the raw d numbers.
              if lb is None:
                  pts = _path_points(a.get("d", ""))
                  if len(pts) >= 2:
                      xs = [p[0] for p in pts]
                      ys = [p[1] for p in pts]
                      lb = (min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys))
                  else:
                      nums = [float(v) for v in _NUM_RE.findall(
                          a.get("d", "") + " " + a.get("points", ""))]
                      if len(nums) >= 4:
                          xs, ys = nums[0::2], nums[1::2]
                          lb = (min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys))
              out["line_refs"].append(
                  (ref, _abs_bbox(lb, m) if lb else BBox(0, 0, 1, 1)))
          if tag in ("rect", "polygon", "circle", "ellipse", "path"):
              role = a.get("data-graph-role", "")
              lb = _shape_bbox(tag, a)
              if lb is None:
                  # stroked paths (edges/curves) contribute their polyline to the
                  # segment inventory so near-edge label annotations are recognized
                  if tag == "path" and a.get("d"):
                      lpts = _path_points(a.get("d", ""))
                      if len(lpts) >= 2:
                          abspts = [transform_point(m, p) for p in lpts]
                          prole = a.get("data-graph-role", "")
                          psw = _f(a.get("stroke-width"), 1.0)
                          out["segments"].extend(
                              (a_, b_, prole, psw)
                              for a_, b_ in zip(abspts, abspts[1:]))
                          # whole-edge spec (first->last point) for flow checks:
                          # an orthogonal/curved edge must count as ONE edge, not
                          # per-sub-segment (sub-segment attribution loses L-edges
                          # whose middle jog lands between bands).
                          if a.get("fill", "none") in (None, "", "none"):
                              out["edge_specs"].append((abspts[0], abspts[-1], prole))
                  return
              # full-canvas / background-role shapes are never node hosts
              if role == "background":
                  return
              if tag == "rect" and lb[0] <= 0.5 and lb[1] <= 0.5 \
                      and lb[2] >= out["canvas"][0] - 0.5 and lb[3] >= out["canvas"][1] - 0.5:
                  return
              if tag == "path" and role in ("edge",):
                  return
              out["rects"].append(_abs_bbox(lb, m))
              out["rect_roles"].append(role)
              # identity + paint for the design-brief contract check (A/B/C)
              out["rect_ids"].append(a.get("data-node-id", ""))
              out["rect_paints"].append((a.get("fill", ""), a.get("stroke", "")))
              if tag == "circle":
                  out["circles"].append(_abs_bbox(lb, m))
          elif tag == "text":
              x, y = _f(a.get("x")), _f(a.get("y"))
              if x is None or y is None:
                  return
              px, py = transform_point(m, (x, y))
              content = "".join(el.itertext())
              fs = _f(a.get("font-size"), 12.0)
              bold = "bold" in a.get("font-weight", "")
              anchor = a.get("text-anchor", "start")
              w = _text_width(content, fs, bold)
              bx = px - w / 2 if anchor == "middle" else (px - w if anchor == "end" else px)
              out["texts"].append((BBox(bx, py - fs / 2, w, fs), anchor,
                                    content.strip(), fs, px, py))
          elif tag == "line":
              x1, y1, x2, y2 = (_f(a.get(k)) for k in ("x1", "y1", "x2", "y2"))
              if None not in (x1, y1, x2, y2):
                  p1, p2 = transform_point(m, (x1, y1)), transform_point(m, (x2, y2))
                  out["segments"].append((p1, p2, a.get("data-graph-role", ""),
                                           _f(a.get("stroke-width"), 1.0)))
                  out["edge_specs"].append((p1, p2, a.get("data-graph-role", "")))
          # recurse into any remaining container (g/defs/a/switch/...)
          if len(el):
              t = a.get("transform")
              child_m = multiply_matrix(m, parse_transform(t)) if t else m
              for child in el:
                  _walk(child, child_m, out)
      
      
      def _collect(svg: str):
          """Parse the rendered SVG into semantic structures (absolute coords).
      
          Returns a dict: markers / rects / circles / segments / line_refs / texts /
          canvas / rect_ids / rect_paints / edge_specs. rects covers every
          node-shape kind (rect / polygon / circle / ellipse / filled path) with
          group transforms applied — decision diamonds and cloud/database paths are
          label hosts exactly like rects; circles are ALSO listed separately
          (junction dots legitimately carry adjacent labels). segments holds
          absolute line endpoints for the edge-annotation exemption. rect_ids /
          rect_paints align 1:1 with rects (data-node-id + raw fill/stroke) for the
          design-brief contract check; edge_specs holds whole edges (first->last
          point) so orthogonal/curved connectors count as ONE edge in flow checks.
          """
          empty = {"markers": set(), "rects": [], "rect_roles": [], "circles": [],
                   "segments": [], "line_refs": [], "texts": [],
                   "rect_ids": [], "rect_paints": [], "edge_specs": [],
                   "canvas": (0.0, 0.0)}
          try:
              root = _ET.fromstring(svg)
          except _ET.ParseError:
              return empty
          out = dict(empty)
          w = _f(root.get("width"))
          h = _f(root.get("height"))
          if w and h:
              out["canvas"] = (float(w), float(h))
          identity = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
          for child in root:
              _walk(child, identity, out)
          return out
      
      
      def _dedup_rects(rects, rect_roles=None, eps=0.5, aligned=None):
          """Collapse rects that describe the *same* box (identical geometry) so a
          card + its own text-frame aren't double-counted. rect_roles and any
          aligned list given in ``aligned`` (name -> list, 1:1 with rects, e.g.
          rect_ids / rect_paints) are filtered in lockstep so indices stay
          aligned. A dropped twin's aligned identity (e.g. data-node-id) is
          ADOPTED into the kept entry when the kept one lacks it — a frame/body
          rect pair where only the second carries data-node-id must not lose its
          brief-contract attribution."""
          out, out_roles, out_aligned = [], [], {k: [] for k in (aligned or {})}
          order = sorted(range(len(rects)),
                         key=lambda i: (rects[i].w * rects[i].h, rects[i].x, rects[i].y),
                         reverse=True)
          for orig in order:
              b = rects[orig]
              hit = next(
                  (j for j, o in enumerate(out)
                   if abs(b.x - o.x) < eps and abs(b.y - o.y) < eps
                   and abs(b.w - o.w) < eps and abs(b.h - o.h) < eps),
                  None)
              if hit is None:
                  out.append(b)
                  if rect_roles is not None:
                      out_roles.append(rect_roles[orig] if orig < len(rect_roles) else "")
                  for k, seq in (aligned or {}).items():
                      out_aligned[k].append(seq[orig] if orig < len(seq) else None)
              else:
                  for k, seq in (aligned or {}).items():
                      val = seq[orig] if orig < len(seq) else None
                      if val and not out_aligned[k][hit]:
                          out_aligned[k][hit] = val
          if aligned is not None:
              return out, out_roles, out_aligned
          if rect_roles is not None:
              return out, out_roles
          return out
      
      
      # ---------------------------------------------------------------------------
      # Check 1 — marker 缺省陷阱
      # ---------------------------------------------------------------------------
      def _ref_id(ref: Optional[str]) -> Optional[str]:
          if not ref:
              return None
          m = re.match(r'url\(#([^)]+)\)', ref)
          return m.group(1) if m else None
      
      
      def check_marker_refs(markers, line_refs, errs):
          """Every marker-end/start/mid URL must resolve to a defined <marker id>.
      
          This is the marker 缺省陷阱: connect() defaults to marker_end='arrowhead'
          while authors register arrow_head('arrow', ...). The mismatched reference
          silently drops every arrowhead — the line renders, geometry passes, but
          the diagram shows no direction at all.
          """
          for ref, bbox in line_refs:
              rid = _ref_id(ref)
              if rid is None or rid in markers:
                  continue
              cx, cy = bbox.cx, bbox.cy
              defined = sorted(markers) if markers else ["(none)"]
              errs.append(Issue(
                  "fail", "marker-dangling",
                  f"connector near ({cx:.0f},{cy:.0f}) references undefined marker "
                  f"'#{rid}' — its arrowhead will silently not render. Defined "
                  f"markers: {defined}. connect() defaults to marker_end='arrowhead'; "
                  f"if you registered arrow_head('arrow', ...), pass "
                  f"marker_end='arrow' explicitly on every connect() call.",
                  element=f"marker-end='{ref}'",
              ))
          # Reverse direction: a marker that is defined but never referenced usually
          # means the author registered an arrowhead and then forgot to use it.
          # With zero refs at all it is even worse — every arrow may be lost — but a
          # single-marker edgeless figure is legitimate, so require either some
          # usage or more than one defined marker.
          if markers:
              used = {_ref_id(ref) for ref, _ in line_refs}
              unused = markers - used
              if unused and (line_refs or len(markers) > 1):
                  errs.append(Issue(
                      "warn", "marker-unused",
                      f"marker(s) {sorted(unused)} defined but never referenced — "
                      f"did some connect() forget marker_end=?",
                  ))
      
      
      # ---------------------------------------------------------------------------
      # Check 2 — FIGS 尺寸漂移
      # ---------------------------------------------------------------------------
      def check_figure_size(canvas, rects, texts, expected_size, errs):
          """Declared canvas size vs. actual content bbox.
      
          - content bigger than canvas → overflow (fail).
          - content far smaller than canvas → size drift: the diagram is a small island
            in a huge canvas (warn).
          - expected_size (from the design spec) mismatching the declared canvas →
            declared-size drift (warn).
          """
          cw, ch = canvas
          if not cw or not ch:
              errs.append(Issue("fail", "size-unknown", "could not read <svg width height>."))
              return
      
          all_boxes = rects + [t[0] for t in texts]
          content = None
          for b in all_boxes:
              if content is None:
                  content = BBox(b.x, b.y, b.w, b.h)
              else:
                  x0, y0 = min(content.x, b.x), min(content.y, b.y)
                  x1 = max(content.x + content.w, b.x + b.w)
                  y1 = max(content.y + content.h, b.y + b.h)
                  content = BBox(x0, y0, x1 - x0, y1 - y0)
          if content is None:
              errs.append(Issue("warn", "size-empty", "no drawable content found."))
              return
      
          # (a) overflow
          pad = 2.0
          if (content.x < 0 or content.y < 0 or
                  content.x + content.w > cw + pad or content.y + content.h > ch + pad):
              errs.append(Issue(
                  "fail", "size-overflow",
                  f"content bbox ({content.x:.0f},{content.y:.0f} "
                  f"{content.w:.0f}x{content.h:.0f}) exceeds declared canvas "
                  f"{cw:.0f}x{ch:.0f}.",
              ))
      
          # (b) drift — content too small relative to canvas
          area_ratio = (content.w * content.h) / (cw * ch)
          if area_ratio < 0.12:
              errs.append(Issue(
                  "warn", "size-drift",
                  f"content occupies only {area_ratio * 100:.1f}% of the {cw:.0f}x{ch:.0f} "
                  f"canvas (content {content.w:.0f}x{content.h:.0f}); diagram may be "
                  f"mis-sized or misplaced.",
              ))
      
          # (c) declared size vs. design spec
          if expected_size:
              ew, eh = expected_size
              if abs(ew - cw) > 1 or abs(eh - ch) > 1:
                  errs.append(Issue(
                      "warn", "size-declared-vs-spec",
                      f"declared canvas {cw:.0f}x{ch:.0f} differs from design-spec "
                      f"expected {ew:.0f}x{eh:.0f}.",
                  ))
      
      
      def _point_rect_dist(px, py, rb):
          """Distance from a point to the nearest edge of a rect (0 when inside)."""
          dx = max(rb.x - px, 0.0, px - (rb.x + rb.w))
          dy = max(rb.y - py, 0.0, py - (rb.y + rb.h))
          return math.hypot(dx, dy)
      
      
      # ---------------------------------------------------------------------------
      # Check 3 — 标签错位
      # ---------------------------------------------------------------------------
      def _point_seg_dist(px, py, seg):
          """Distance from a point to a line segment ((x1,y1),(x2,y2))."""
          (x1, y1), (x2, y2) = seg
          dx, dy = x2 - x1, y2 - y1
          length_sq = dx * dx + dy * dy
          if length_sq == 0:
              return math.hypot(px - x1, py - y1)
          t = max(0.0, min(1.0, ((px - x1) * dx + (py - y1) * dy) / length_sq))
          return math.hypot(px - (x1 + t * dx), py - (y1 + t * dy))
      
      
      def check_labels(canvas_h, rects, circles, segments, texts, errs):
          """Label / container association.
      
          - every text-anchor=middle label must sit centered over *its* containing
            node box — but only when the box plausibly owns the text (box width
            within 4x the estimated text width). A lane/band caption inside a wide
            container is intentionally off-center and must not be flagged.
          - every business-node rect (area above a floor) should contain at least
            one text label; circles are exempt (junction dots / start-end nodes
            legitimately carry adjacent labels).
          We deliberately do NOT flag (a) top-band centered titles/subtitles (they
          are meant to float above the grid), (b) tiny decorative squares, and
          (c) labels sitting beside an edge segment (flowchart branch labels like
          "No", bidirectional link annotations).
          """
          title_band = 0.13 * canvas_h     # top stripe reserved for the diagram title
          node_floor = 60 * 40              # min node box area to count as a business node
          circle_set = {(c.x, c.y, c.w, c.h) for c in circles}
          occupied = [False] * len(rects)
      
          for (tb, anchor, content, fs, tx, ty) in texts:
              if not content:
                  continue
              if anchor != "middle":
                  continue  # left labels are naturally caption-style
              # top-band centered titles/subtitles are expected to float — not orphans
              if ty < title_band and anchor == "middle":
                  continue
              # candidate containers whose center the label should match.
              # Any size qualifies (a 52x30 KV-block chip owns its "L0" label as
              # much as a 360x124 card owns its title); the smallest containing box
              # wins. Two ownership guards keep false hosts out:
              #   - the box must hold at least half the estimated text box;
              #   - the box must not be wider than 4x the text (a 43px lane caption
              #     inside a 500px band is a band caption, not the band's title).
              cx, cy = tb.cx, tb.cy
              candidates = [
                  (i, rb) for i, rb in enumerate(rects)
                  if rb.w >= 0.5 * tb.w and rb.h >= 0.5 * tb.h
                  and rb.w <= 4.0 * max(tb.w, 1.0)
                  and rb.x <= cx <= rb.x + rb.w and rb.y - fs <= cy <= rb.y + rb.h + fs
              ]
              best = (min(candidates, key=lambda p: p[1].w * p[1].h)
                      if candidates else None)
              if best is not None:
                  # composite shapes: a label centered over the UNION of overlapping
                  # candidates (e.g. two half-arcs forming one bracket) is centered,
                  # even though it is off the single smallest candidate's centre.
                  if len(candidates) >= 2:
                      ux0 = min(rb.x for _, rb in candidates)
                      ux1 = max(rb.x + rb.w for _, rb in candidates)
                      if abs((ux0 + ux1) / 2 - cx) <= 6.0:
                          for i, _rb in candidates:
                              occupied[i] = True
                          candidates = []
                          best = None
                          continue
              if best is None:
                  # centered label floating in empty space. Three legitimate
                  # patterns are exempt:
                  #   - near a box (~24px: icon-chip labels);
                  #   - beside an edge segment (~24px: branch labels like "No",
                  #     mid-link annotations between a paired up/down link);
                  #   - a cluster caption: >=2 node boxes directly below (or above)
                  #     within 90px and within a ±120px horizontal window — the
                  #     "constellation title over its satellites" pattern.
                  near_box = any(_point_rect_dist(cx, cy, rb) <= 24.0 for rb in rects)
                  near_edge = any(_point_seg_dist(cx, cy, (s[0], s[1])) <= 24.0
                                 for s in segments)
                  cluster_below = sum(
                      1 for rb in rects
                      if abs(rb.cx - cx) <= 120.0 and 0 <= (rb.y - cy) <= 90.0)
                  cluster_above = sum(
                      1 for rb in rects
                      if abs(rb.cx - cx) <= 120.0 and 0 <= (cy - (rb.y + rb.h)) <= 90.0)
                  if near_box or near_edge or cluster_below >= 2 or cluster_above >= 2:
                      continue
                  errs.append(Issue(
                      "warn", "label-orphan",
                      f"middle-anchored label '{content[:20]}' at ({tx:.0f},{ty:.0f}) is "
                      f"not inside (nor near) any node box.",
                  ))
                  continue
              i, rb = best
              occupied[i] = True
              # horizontal centering within that box
              center_dx = abs(rb.cx - cx)
              tol_center = max(6.0, 0.05 * rb.w)
              if center_dx > tol_center:
                  errs.append(Issue(
                      "warn", "label-offcenter",
                      f"label '{content[:20]}' center x-offset {center_dx:.1f}px from its "
                      f"box center ({rb.cx:.0f}); text-anchor=middle wants centering.",
                  ))
      
          # --- unlabelled business nodes --------------------------------
          # A box only *needs* an interior label when the diagram labels its boxes
          # from the inside (the common convention). If every label in the figure sits
          # adjacent/outside (chip-style legends, side captions), boxes without an
          # interior label are legitimate layout, not a defect.
          tol = 10.0  # allow slight label overflow outside a box
      
          def _inside(rb, t):
              return (rb.x - tol <= t[0].cx <= rb.x + rb.w + tol and
                      rb.y - tol <= t[0].cy <= rb.y + rb.h + tol)
      
          interior_convention = any(
              any(_inside(rb, t) for t in texts) for rb in rects
          )
          for i, rb in enumerate(rects):
              if occupied[i] or rb.w * rb.h < node_floor:
                  continue
              # circles (junction dots / start-end nodes) legitimately carry
              # adjacent labels instead of interior ones
              if (rb.x, rb.y, rb.w, rb.h) in circle_set:
                  continue
              if not (interior_convention and not any(_inside(rb, t) for t in texts)):
                  continue
              errs.append(Issue(
                  "warn", "label-missing",
                  f"node box at ({rb.x:.0f},{rb.y:.0f} {rb.w:.0f}x{rb.h:.0f}) "
                  f"contains no text label.",
              ))
      
      
      # ---------------------------------------------------------------------------
      # Check 4 — connector route vs filled shapes(箭头线盖在组件上)
      # ---------------------------------------------------------------------------
      def _interior_samples(p1, p2, rect, margin=3.0, endpoint_skip=0.0, steps=60):
          """Sampled points of segment p1→p2 strictly inside rect (with margin).
      
          endpoint_skip drops samples within that many px of either endpoint — a
          connector legitimately touching a shape's border at its anchor should not
          count as passing through the interior.
          """
          seg_len = math.hypot(p2[0] - p1[0], p2[1] - p1[1])
          inside = []
          for i in range(steps + 1):
              t = i / steps
              px, py = p1[0] + (p2[0] - p1[0]) * t, p1[1] + (p2[1] - p1[1]) * t
              if endpoint_skip:
                  if math.hypot(px - p1[0], py - p1[1]) < endpoint_skip:
                      continue
                  if math.hypot(px - p2[0], py - p2[1]) < endpoint_skip:
                      continue
              if rect.x + margin < px < rect.x + rect.w - margin \
                      and rect.y + margin < py < rect.y + rect.h - margin:
                  inside.append((px, py))
          return inside, seg_len
      
      
      def check_connector_routes(rects, rect_roles, circles, segments, errs,
                                 card_min_w=40.0, card_max_w=320.0, card_min_h=24.0,
                                 card_max_h=180.0,
                                 container_min_w=400.0, container_min_area=40000.0,
                                 anchor_tol=14.0, min_rail_len=60.0):
          """Raw-SVG route check: connectors vs filled shapes, role-aware.
      
          The registry-based evaluator cannot see this defect class: bus rails are
          drawn as raw line() calls (never registered as edges), and band containers
          are role='layer' (never registered as nodes) — so "edge routes through
          node" is structurally blind to a spine slicing through colored bands.
          This check parses the rendered geometry instead:
      
          - connector-through-card (FAIL): a segment crosses a business card's
            interior (cards = unmarked shapes 40–320px wide; anything wider is a
            container — arrows legitimately live inside white layer containers).
          - rail-slices-container (WARN): a >=60px connector whose NEITHER endpoint
            is anchored to any card/circle crosses a wide filled band/container
            interior for >=24px — the classic "right-side AgentEvent spine drawn
            at x=776 inside the band rect (right edge 790) instead of outside it",
            painting over the component bands it was meant to route around.
          Legend/decoration shapes are exempt on both sides.
          """
          skip_roles = ("legend", "decoration", "background")
          cards = [r for r, role in zip(rects, rect_roles)
                   if role not in skip_roles
                   and card_min_w <= r.w <= card_max_w and card_min_h <= r.h <= card_max_h]
          containers = [r for r, role in zip(rects, rect_roles)
                        if role not in skip_roles
                        and r.w >= container_min_w and r.w * r.h >= container_min_area]
          # Anchoring recognizes business shapes that are NOT wide containers — an
          # edge landing on a wide card's border is anchored, but a rail endpoint
          # merely floating inside a big band's interior (or a wide strip) is not.
          anchors = [r for r, role in zip(rects, rect_roles)
                     if role not in skip_roles and r.w < container_min_w] \
              + list(circles)
      
          def anchored(p):
              return any(_point_rect_dist(p[0], p[1], c) <= anchor_tol for c in anchors)
      
          warned = 0
          for seg in segments:
              p1, p2 = seg[0], seg[1]
              role = seg[2] if len(seg) > 2 else ""
              sw = seg[3] if len(seg) > 3 else 1.0
              if role in skip_roles or sw <= 1.0:
                  continue  # legend/decoration lines and hairline dividers are not rails
              # (a) through a business card interior → FAIL
              for c in cards:
                  inside, _ = _interior_samples(p1, p2, c,
                                                margin=3.0, endpoint_skip=10.0)
                  if len(inside) >= 8:
                      errs.append(Issue(
                          "fail", "connector-through-card",
                          f"connector ({p1[0]:.0f},{p1[1]:.0f})->({p2[0]:.0f},{p2[1]:.0f}) "
                          f"runs through the interior of card "
                          f"({c.x:.0f},{c.y:.0f} {c.w:.0f}x{c.h:.0f}) — reroute it "
                          f"around the card or anchor it on the card border.",
                      ))
                      break
              # (b) floating rail slicing a filled band/container → WARN
              if math.hypot(p2[0] - p1[0], p2[1] - p1[1]) < 30.0:
                  continue
              if anchored(p1) or anchored(p2):
                  continue
              slicing = []
              for c in containers:
                  inside, seg_len = _interior_samples(p1, p2, c, margin=3.0)
                  # crossing DEPTH in px (sample fraction × length) — count-based
                  # thresholds dilute for long rails (a 616px spine spends ~110px
                  # per band but only ~12 raw samples of 61).
                  if seg_len <= 0 or len(inside) / 61.0 * seg_len < 24.0:
                      continue
                  # only a pass-through / edge-entry counts: at least one endpoint
                  # must lie OUTSIDE the container — a rail that lives wholly inside
                  # one band (legend arrows between chips) is that band's business.
                  outside = [
                      not (c.x + 3.0 < pt[0] < c.x + c.w - 3.0
                           and c.y + 3.0 < pt[1] < c.y + c.h - 3.0)
                      for pt in (p1, p2)
                  ]
                  if not any(outside):
                      continue
                  slicing.append(c)
              if slicing and warned < 6:
                  warned += 1
                  names = ", ".join(f"({c.x:.0f},{c.y:.0f} {c.w:.0f}x{c.h:.0f})"
                                  for c in slicing[:3])
                  errs.append(Issue(
                      "warn", "rail-slices-container",
                      f"connector ({p1[0]:.0f},{p1[1]:.0f})->({p2[0]:.0f},{p2[1]:.0f}) "
                      f"with no node-anchored endpoint slices through filled "
                      f"container(s) {names} — move the rail outside the containers "
                      f"(or register it as a business edge). (箭头线盖在组件上)",
                  ))
      
      
      # ---------------------------------------------------------------------------
      # Check 5 — text semantics vs the spec(文本语义)
      # ---------------------------------------------------------------------------
      _DESIGN_STOPWORDS = frozenset(
          "w left right top bottom up down solid dashed arrow arrows spine band layer"
          " layers title subtitle legend canvas fill stroke px width height exact hex"
          " accents tier tiers bold italic box line lines row rows col cols".split())
      
      
      def _norm_entity(s):
          """Normalize a spec term for matching: keep alnum + CJK, lowercase."""
          return "".join(ch.lower() for ch in s
                         if ch.isalnum() or ord(ch) > 0x2E80)
      
      
      def _spec_entities(spec_text):
          """Extract component-name entities from a spec's **bold** / `backtick`
          spans. Long phrases are split on separators; hex colors, pure numbers and
          design vocabulary are dropped — they describe style, not diagram text."""
          ents = set()
          spans = re.findall(r"\*\*([^*]+)\*\*", spec_text or "")
          spans += re.findall(r"`([^`]+)`", spec_text or "")
          for span in spans:
              for term in re.split(r"[((·—/:;、,,。\|\s]+|\s+to\s+", span):
                  t = term.strip()
                  n = _norm_entity(t)
                  if not n:
                      continue
                  if re.fullmatch(r"[0-9a-f]{6}|[0-9a-f]{3}|\d+", n):
                      continue
                  # design directives, not component names
                  if n[0].isdigit() or "=" in t:
                      continue
                  has_cjk = any(ord(c) > 0x2E80 for c in t)
                  if not has_cjk:
                      # ASCII entities must LOOK like identifiers (camelCase,
                      # ALLCAPS, or hyphen/underscore-joined) — plain words and
                      # bolded design sentences ("All edges are solid") drop out
                      camel = re.search(r"[a-z][A-Z]", t)
                      caps = re.fullmatch(r"[A-Z][A-Za-z]*[A-Z]|[A-Z]{2,}", t)
                      joined = re.search(r"[A-Za-z0-9][-_][A-Za-z0-9]", t) and len(n) >= 4
                      if not (camel or caps or joined):
                          continue
                  if len(n) < (2 if has_cjk else 3):
                      continue
                  if n in _DESIGN_STOPWORDS:
                      continue
                  ents.add(n)
          return ents
      
      
      def check_text_semantics(texts, spec_text, errs, min_coverage=0.4, warn_coverage=0.85):
          """Text-level semantic checks (content, not geometry).
      
          - placeholder/garbled text (TODO/TBD/mojibake/empty) → FAIL
          - spec entities missing from the diagram: coverage below min_coverage →
            FAIL (forces regeneration or text correction); individual misses above
            the floor are tolerated (synonym rewording like 事件序列 vs 事件流).
          """
          for (tb, anchor, content, fs, tx, ty) in texts:
              if not content:
                  errs.append(Issue(
                      "fail", "text-empty",
                      f"empty <text> element at ({tx:.0f},{ty:.0f})."))
                  continue
              if re.search(r"\bTODO\b|\bTBD\b|\bFIXME\b|\bXXX+\b|lorem ipsum"
                          r"|\bundefined\b|\bNaN\b", content) \
                      or re.search(r"Ã|â€", content):
                  errs.append(Issue(
                      "fail", "text-placeholder",
                      f"placeholder/garbled text '{content[:24]}' at ({tx:.0f},{ty:.0f})."))
          if not spec_text:
              return
          ents = _spec_entities(spec_text)
          if not ents:
              return
          blob = _norm_entity(" ".join(t[2] for t in texts))
          missing = [e for e in sorted(ents) if e not in blob]
          coverage = 1.0 - len(missing) / len(ents)
          if coverage < min_coverage:
              errs.append(Issue(
                  "fail", "spec-entities-missing",
                  f"only {coverage * 100:.0f}% of the spec's component names appear in "
                  f"the diagram (< {min_coverage * 100:.0f}%) — whole components may "
                  f"be missing. Missing: {', '.join(missing[:10])}",
              ))
          elif coverage < warn_coverage:
              errs.append(Issue(
                  "warn", "spec-entities-partial",
                  f"{coverage * 100:.0f}% of the spec's component names appear in the "
                  f"diagram — consider adding: {', '.join(missing[:8])}",
              ))
      # ---------------------------------------------------------------------------
      # Design-brief contract check (Step-1 declared intent vs rendered output)
      # ---------------------------------------------------------------------------
      _NON_BUSINESS = ("decoration", "legend", "background")
      
      
      def _pnorm(raw):
          """Normalize a raw fill/stroke attr to a comparable token."""
          if raw is None:
              return ""
          return normalize_color(raw) or str(raw).strip().lower()
      
      
      def _contains(box, px, py, inset=1.0):
          return (box.x + inset <= px <= box.x + box.w - inset
                  and box.y + inset <= py <= box.y + box.h - inset)
      
      
      def check_design_brief(brief, doc, errs, dominance=0.70):
          """Assert the RENDERED SVG against the declared DesignBrief (A/B/C).
      
          A — palette contract: every declared key (data-node-id) must be rendered
              as a business shape with the declared (fill, stroke) pair; declared
              tint rendered white/none = FAIL (structure lost its color — tint-
              plainness follows design_brief.is_plain, so a declared gray tint
              counts as color too), wrong chromatic tint = WARN, stroke mismatch =
              WARN; chromatic paints on undeclared business shapes = WARN.
          B — layout contract: band -> each declared layer container exists
              (identity via data-node-id) and is non-empty; unmarked layer-role
              containers = WARN. node -> any layer container rendered = FAIL.
              Runs on data-graph-role="layer" as corroborating signal; identity is
              primary.
          C — flow contract (dominance, not per-edge monotonicity — real diagrams
              carry return edges): >=70% of inter-layer edges must agree with the
              declared axis, and the declared first/last/middle layers must satisfy
              out>=1 / in>=1 / both>=1. Declared layer ORDER must match geometric
              order. Skipped when B finds no layer basis or flow == "none".
      
          The 0.70 dominance default tolerates up to ~30% back-edges (a vLLM-style
          request/response diagram carries real return flows) while still catching
          inverted or incoherent routing; exposed as a parameter for recalibration.
          Capability boundary: this verifies rendering <-> SELF-declared contract
          consistency. A coherently-wrong brief passes; intent is guarded by
          spec-entity coverage and human review of the brief.
          """
          rects, rect_roles, aligned = _dedup_rects(
              doc["rects"], doc.get("rect_roles"), eps=0.75,
              aligned={"rect_ids": doc["rect_ids"], "rect_paints": doc["rect_paints"]})
          rect_ids, rect_paints = aligned["rect_ids"], aligned["rect_paints"]
          paints = [(_pnorm(p[0]), _pnorm(p[1])) if p else ("", "")
                    for p in rect_paints]
      
          def business(i):
              return (rect_roles[i] if i < len(rect_roles) else "") \
                  not in _NON_BUSINESS
      
          # --- A: palette contract ------------------------------------------------
          by_id = {}
          for i, nid in enumerate(rect_ids):
              if nid:
                  by_id.setdefault(nid, []).append(i)
          declared_fills = {s.fill for s in brief.palette_role.values()}
          declared_strokes = {s.stroke for s in brief.palette_role.values()}
          for key, spec in sorted(brief.palette_role.items()):
              idxs = [i for i in by_id.get(key, []) if business(i)]
              if not idxs:
                  errs.append(Issue(
                      "fail", "brief-shape-missing",
                      f"declared palette key '{key}' has no rendered business shape "
       f"(data-node-id) — declared structure missing from the diagram."))
                  continue
              for i in idxs:
                  fill, stroke = paints[i]
                  if not is_plain(spec.fill) and fill in ("", "none", "#ffffff"):
                      errs.append(Issue(
                          "fail", "brief-tint-lost",
                          f"declared tint '{key}' rendered with a white/neutral "
                          f"fill (declared {spec.fill}) — structure lost its color."))
                  elif fill != spec.fill and fill not in ("", "none", "#ffffff"):
                      errs.append(Issue(
                          "warn", "brief-fill-mismatch",
                          f"'{key}' rendered fill {fill} != declared {spec.fill}."))
                  if stroke and stroke != spec.stroke and not is_neutral(spec.stroke):
                      errs.append(Issue(
                          "warn", "brief-stroke-mismatch",
                          f"'{key}' rendered stroke {stroke} != declared "
                          f"{spec.stroke}."))
          for i, (fill, stroke) in enumerate(paints):
              nid = rect_ids[i] if i < len(rect_ids) else ""
              if not business(i) or (nid and nid in brief.palette_role):
                  continue
              if fill and fill not in ("none", "#ffffff") and not is_neutral(fill) \
                      and fill not in declared_fills:
                  errs.append(Issue(
                      "warn", "brief-fill-undeclared",
                      f"chromatic fill {fill} at ({rects[i].x:.0f},"
                      f"{rects[i].y:.0f}) is outside the declared palette."))
              if stroke and stroke != "none" and not is_neutral(stroke) \
                      and stroke not in declared_strokes:
                  errs.append(Issue(
                      "warn", "brief-stroke-undeclared",
                      f"chromatic stroke {stroke} at ({rects[i].x:.0f},"
                      f"{rects[i].y:.0f}) is outside the declared palette."))
      
          # --- B: layout contract -------------------------------------------------
          layer_boxes = {}
          role_layers = []
          for i, r in enumerate(rects):
              nid = rect_ids[i] if i < len(rect_ids) else ""
              role = rect_roles[i] if i < len(rect_roles) else ""
              if role == "layer":
                  role_layers.append((nid, r))
              if brief.layout == "band" and nid and nid in brief.palette_role:
                  cur = layer_boxes.get(nid)
                  if cur is None or r.w * r.h < cur.w * cur.h:
                      layer_boxes[nid] = r
          if brief.layout == "band":
              for nid, r in role_layers:
                  if not nid or nid not in brief.palette_role:
                      errs.append(Issue(
                          "warn", "brief-layer-undeclared",
                          f"layer container at ({r.x:.0f},{r.y:.0f}, {r.w:.0f}x"
                          f"{r.h:.0f}) is not declared in the brief palette."))
              declared_set = set(brief.palette_role)
              for k, box in layer_boxes.items():
                  # business content predicate: not decoration/legend/background,
                  # not itself a declared container; text bullets count as content
                  # (a text-only optimizations band is legitimately non-empty)
                  n = sum(
                      1 for i, r in enumerate(rects)
                      if business(i) and rect_ids[i] not in declared_set
                      and _contains(box, r.x + r.w / 2, r.y + r.h / 2))
                  n += sum(
                      1 for t in doc["texts"]
                      if _contains(box, t[0].x + t[0].w / 2, t[0].y + t[0].h / 2))
                  if n == 0:
                      errs.append(Issue(
                          "fail", "brief-layer-empty",
                          f"declared layer '{k}' renders empty — no business node "
                          f"or label inside its container."))
          else:  # node style
              for nid, r in role_layers:
                  errs.append(Issue(
                      "fail", "brief-layout-contradicted",
                      f"node-style brief but a layer container is rendered at "
                      f"({r.x:.0f},{r.y:.0f}) — layout contradicts the contract."))
      
          # --- C: flow contract (skipped without a structural basis) --------------
          if brief.flow == "none":
              return
          if brief.layout == "band" and not brief.layers:
              return          # short-circuit: no chain declared; C would be noise
          edges = [(p1, p2) for p1, p2, role in doc["edge_specs"]
                   if role not in _NON_BUSINESS]
          def layer_of(pt):
              best, best_area = None, None
              for idx, k in enumerate(brief.layers):
                  box = layer_boxes.get(k)
                  # inset=-4: expand the container by 4px so endpoints snapped to
                  # band borders still attribute correctly — connect() retracts
                  # arrow tips by marker_tip_depth (~1.5px), which lands the
                  # endpoint just OUTSIDE the target band in the gutter.
                  if box and _contains(box, pt[0], pt[1], inset=-4.0):
                      area = box.w * box.h
                      if best is None or area < best_area:
                          best, best_area = idx, area
              return best
      
          if brief.layout == "band" and len(brief.layers) >= 2:
              # declared order must match geometric order along the flow axis
              axes = [layer_boxes[k].y + layer_boxes[k].h / 2
                      if k in layer_boxes else None for k in brief.layers]
              known = [a for a in axes if a is not None]
              vertical = brief.flow == "top-down"
              if not vertical:
                  axes = [layer_boxes[k].x + layer_boxes[k].w / 2
                          if k in layer_boxes else None for k in brief.layers]
                  known = [a for a in axes if a is not None]
              if any(a > b for a, b in zip(known, known[1:])):
                  errs.append(Issue(
                      "fail", "brief-layer-order",
                      "declared layer order contradicts the rendered geometry "
                      "(containers not monotonic along the flow axis)."))
              inter = []
              for p1, p2 in edges:
                  a, b = layer_of(p1), layer_of(p2)
                  if a is None or b is None or a == b:
                      continue
                  inter.append((a, b, p1, p2))
              if len(inter) >= 4:
                  m = sum(
                      1 for a, b, p1, p2 in inter
                      if (p2[1] - p1[1] if vertical else p2[0] - p1[0]) > 0)
                  if m / len(inter) < dominance:
                      errs.append(Issue(
                          "fail", "brief-flow-dominance",
                          f"only {100 * m / len(inter):.0f}% of inter-layer edges "
                          f"follow the declared '{brief.flow}' flow (< "
                          f"{dominance * 100:.0f}%) — routing is inverted or "
                          f"incoherent with the contract."))
              # axis truthfulness: if most inter-layer edges travel farther along
              # the CROSS axis than the declared flow axis, the flow direction
              # itself is misdeclared (e.g. "left-right" over horizontal bands
              # whose spine runs vertically). Symmetric to dominance, so it stays
              # robust on small samples (>= 2 edges) where the directional
              # dominance ratio is skipped by design.
              if len(inter) >= 2:
                  def _cross_dominant(p1, p2):
                      dy, dx = abs(p2[1] - p1[1]), abs(p2[0] - p1[0])
                      return dx > dy if vertical else dy > dx
                  cross = sum(1 for a, b, p1, p2 in inter if _cross_dominant(p1, p2))
                  if cross / len(inter) >= dominance:
                      errs.append(Issue(
                          "fail", "brief-flow-axis",
                          f"{100 * cross / len(inter):.0f}% of inter-layer edges "
                          f"travel farther along the cross axis than the declared "
                          f"'{brief.flow}' axis — the flow direction is "
                          f"misdeclared."))
              outs = [0] * len(brief.layers)
              ins = [0] * len(brief.layers)
              for a, b, p1, p2 in inter:
                  outs[a] += 1
                  ins[b] += 1
              last = len(brief.layers) - 1
              for i, k in enumerate(brief.layers):
                  if k not in layer_boxes:
                      continue
                  if i == 0 and outs[i] < 1:
                      errs.append(Issue(
                          "fail", "brief-chain-broken",
                          f"first declared layer '{k}' has no outgoing "
                          f"inter-layer edge."))
                  elif i == last and ins[i] < 1:
                      errs.append(Issue(
                          "fail", "brief-chain-broken",
                          f"last declared layer '{k}' has no incoming "
                          f"inter-layer edge."))
                  elif 0 < i < last and (outs[i] < 1 or ins[i] < 1):
                      errs.append(Issue(
                          "fail", "brief-chain-broken",
                          f"middle declared layer '{k}' breaks the chain "
                          f"(in={ins[i]}, out={outs[i]})."))
          elif edges:
              # node style: whole-edge direction dominance only, no degree rules
              m = sum(1 for p1, p2 in edges
                      if (p2[1] - p1[1] if brief.flow == "top-down"
                          else p2[0] - p1[0]) > 0)
              n = sum(1 for p1, p2 in edges
                      if (p2[1] - p1[1] if brief.flow == "top-down"
                          else p2[0] - p1[0]) != 0)
              if n >= 4 and m / n < dominance:
                  errs.append(Issue(
                      "fail", "brief-flow-dominance",
                      f"only {100 * m / n:.0f}% of edges follow the declared "
                      f"'{brief.flow}' flow (< {dominance * 100:.0f}%)."))
      
      
      # ---------------------------------------------------------------------------
      # Orchestration
      # ---------------------------------------------------------------------------
      def run_semantic_qa(drawable, expected_size: Optional[tuple] = None,
                         spec_text: Optional[str] = None,
                         brief=None) -> SemanticResult:
          """Analyze a drawer (.render()) or a raw SVG string.
      
          spec_text: the original requirement text (e.g. input.md). When given,
          text-semantics checks entity coverage of the diagram against the spec.
          brief: a DesignBrief (or None). Given, the rendered SVG is asserted
          against the declared contract (check_design_brief). Absent, a WARN is
          raised — an undeclared brief is visible, not silently skipped.
          """
          svg = drawable.render() if hasattr(drawable, "render") else str(drawable)
          doc = _collect(svg)
          rects, rect_roles = _dedup_rects(doc["rects"], doc.get("rect_roles"), eps=0.75)
          errs = []
          check_marker_refs(doc["markers"], doc["line_refs"], errs)
          cw, ch = doc["canvas"]
          check_figure_size((cw, ch), rects, doc["texts"], expected_size, errs)
          check_labels(ch, rects, doc["circles"], doc["segments"], doc["texts"], errs)
          check_connector_routes(rects, rect_roles, doc["circles"], doc["segments"], errs)
          check_text_semantics(doc["texts"], spec_text, errs)
          if brief is None:
              errs.append(Issue(
                  "warn", "brief-absent",
                  "no design brief declared (DesignBrief / brief.json) — palette/"
                  "layout/flow contract checks skipped. gen.py should declare "
                  "BRIEF = DesignBrief(...) and pass brief=BRIEF."))
          else:
              from design_brief import DesignBrief
              if not isinstance(brief, DesignBrief):
                  brief = DesignBrief.from_dict(brief)
              check_design_brief(brief, doc, errs)
          return SemanticResult(issues=errs)
      
      
      def semantic_qa(drawable, expected_size=None, spec_text=None):
          """Alias matching the evaluator's public-function style."""
          return run_semantic_qa(drawable, expected_size, spec_text)
      
      
      if __name__ == "__main__":
          import sys
          path = sys.argv[1] if len(sys.argv) > 1 else None
          if path:
              with open(path, "r", encoding="utf-8") as fh:
                  svg = fh.read()
              res = run_semantic_qa(svg)
              print("\n".join(res.report()))
              print(f"semantic-qa score: {res.score}  ok={res.ok}")
          else:
              print("usage: python semantic_qa.py <diagram.svg> [expected_w expected_h]")
    • svg2pptx.py 43.8 KB
      # -*- coding: utf-8 -*-
      """
      SVG to PowerPoint (PPTX) export — native editable shapes.
      
      Converts SVG content into native, editable PowerPoint shapes: rectangles,
      ovals, connectors, text boxes, and freeforms. Inspired by and modeled after
      the svg2pptx project (github.com/benouinirachid/svg2pptx), but self-contained
      and tuned for diagrams produced by SVGDrawer.
      
      Two export modes:
        - "shapes" (default): each SVG element becomes an editable PPTX shape.
          Best for diagrams you want to tweak in PowerPoint/Keynote.
        - "image": rasterize the SVG to PNG via rsvg-convert and embed as a picture.
          Perfect visual fidelity (arrows, curves, everything), but NOT individually
          editable. Use when the SVG is complex and you just need it to look right.
      
      Coordinate system: SVG pixels are mapped to PowerPoint EMU (1 px = 9525 EMU at
      96 DPI). The SVG viewBox is scaled to fit the slide while preserving aspect
      ratio (letterboxed, centered).
      
      Usage:
          from svg2pptx import svg_to_pptx
      
          # From a file
          svg_to_pptx("diagram.svg", "diagram.pptx")
      
          # From an SVG string (what SVGDrawer.render() returns)
          svg_to_pptx(drawer.render(), "diagram.pptx")
      
          # Image mode (raster fallback for complex SVGs)
          svg_to_pptx("diagram.svg", "diagram.pptx", mode="image")
      
          # Add to an existing presentation's slide
          from svg2pptx import add_svg_to_slide
          add_svg_to_slide(svg_string, slide)
      """
      from __future__ import annotations
      
      import html
      import math
      import os
      import re
      import shutil
      import subprocess
      import sys
      import tempfile
      from dataclasses import dataclass, field
      from pathlib import Path
      from typing import Optional, Union
      from xml.etree import ElementTree as ET
      
      from pptx import Presentation
      from pptx.enum.shapes import MSO_CONNECTOR, MSO_SHAPE
      from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
      from pptx.dml.color import RGBColor
      from pptx.oxml.ns import qn
      from pptx.util import Emu, Pt
      
      # Reuse the battle-tested affine transform helpers from svg_utils (same dir).
      from svg_utils import (IDENTITY, multiply_matrix, parse_transform,
                             transform_point)
      
      # ---------------------------------------------------------------------------
      # Units
      # ---------------------------------------------------------------------------
      # 1 inch = 914400 EMU, 1 inch = 96 px  ->  1 px = 9525 EMU.
      EMU_PER_PX = 9525
      
      
      def px(v: float) -> int:
          """Convert SVG pixels to EMU (rounded)."""
          return int(round(v * EMU_PER_PX))
      
      
      # ---------------------------------------------------------------------------
      # Configuration
      # ---------------------------------------------------------------------------
      @dataclass
      class PptxConfig:
          """Export configuration.
      
          slide_w / slide_h : slide dimensions in inches (default 13.333 x 7.5 = 16:9).
          margin            : letterbox margin in inches.
          scale             : extra scale multiplier on top of fit-to-slide.
          curve_tolerance   : Bezier flattening tolerance in px (lower = smoother).
          mode              : "shapes" (native editable) or "image" (rasterized).
          image_dpi         : DPI for raster mode.
          default_fill      : fill used when SVG omits it ("none" or hex).
          default_stroke    : stroke used when SVG omits it.
          """
      
          slide_w: float = 13.333
          slide_h: float = 7.5
          margin: float = 0.0
          scale: float = 1.0
          curve_tolerance: float = 1.0
          mode: str = "shapes"
          image_dpi: int = 200
          default_fill: str = "none"
          default_stroke: str = "none"
      
      
      # ---------------------------------------------------------------------------
      # Color helpers
      # ---------------------------------------------------------------------------
      _NAMED = {
          "white": "#FFFFFF", "black": "#000000", "red": "#FF0000",
          "green": "#008000", "blue": "#0000FF", "yellow": "#FFFF00",
          "none": "none", "transparent": "none",
      }
      
      
      def _normalize_color(val: Optional[str], default: str = "none") -> str:
          """Normalize a CSS/SVG color to '#rrggbb' (lowercase) or 'none'."""
          if not val or val.strip() == "":
              return default
          val = val.strip().lower()
          val = _NAMED.get(val, val)
          if val.startswith("#"):
              h = val[1:]
              if len(h) == 3:
                  h = "".join(c * 2 for c in h)
              if len(h) == 6:
                  return f"#{h}"
          return val if val == "none" else default
      
      
      def _hex_to_rgbcolor(hex_color: str) -> Optional[RGBColor]:
          """Convert '#rrggbb' to RGBColor, or None for 'none'."""
          hex_color = _normalize_color(hex_color)
          if hex_color == "none":
              return None
          h = hex_color.lstrip("#")
          return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
      
      
      # ---------------------------------------------------------------------------
      # Style parsing (presentation attributes + style="..." attribute)
      # ---------------------------------------------------------------------------
      @dataclass
      class SvgStyle:
          fill: str = "none"
          stroke: str = "none"
          stroke_width: float = 1.0
          fill_opacity: float = 1.0
          stroke_opacity: float = 1.0
          opacity: float = 1.0
          stroke_dasharray: str = ""
          font_size: float = 14.0
          font_weight: str = "normal"
          font_style: str = "normal"
          font_family: str = "Arial, sans-serif"
          text_anchor: str = "start"
          dominant_baseline: str = "auto"
      
      
      _STYLE_PROPS = {
          "fill", "stroke", "stroke-width", "fill-opacity", "stroke-opacity",
          "opacity", "stroke-dasharray", "font-size", "font-weight", "font-style",
          "font-family", "text-anchor", "dominant-baseline",
      }
      
      
      def _parse_style(el: ET.Element, parent: Optional[SvgStyle] = None) -> SvgStyle:
          """Merge presentation attributes, inline style, and parent inheritance."""
          base = parent if parent else SvgStyle()
          s = SvgStyle(
              fill=base.fill, stroke=base.stroke, stroke_width=base.stroke_width,
              fill_opacity=base.fill_opacity, stroke_opacity=base.stroke_opacity,
              opacity=base.opacity, stroke_dasharray=base.stroke_dasharray,
              font_size=base.font_size, font_weight=base.font_weight,
              font_style=base.font_style, font_family=base.font_family,
              text_anchor=base.text_anchor, dominant_baseline=base.dominant_baseline,
          )
          # Inline style attribute
          inline = {}
          style_attr = el.get("style", "")
          if style_attr:
              for decl in style_attr.split(";"):
                  decl = decl.strip()
                  if ":" in decl:
                      k, v = decl.split(":", 1)
                      inline[k.strip()] = v.strip()
          # Apply — inline style overrides presentation attrs in SVG spec, but for
          # diagrams generated programmatically the attrs are the source of truth.
          for prop in _STYLE_PROPS:
              val = el.get(prop)
              if val is None:
                  val = inline.get(prop)
              if val is None:
                  continue
              val = val.strip()
              if prop == "fill":
                  s.fill = _normalize_color(val, "none")
              elif prop == "stroke":
                  s.stroke = _normalize_color(val, "none")
              elif prop == "stroke-width":
                  s.stroke_width = _float(val, s.stroke_width)
              elif prop == "fill-opacity":
                  s.fill_opacity = _float(val, s.fill_opacity)
              elif prop == "stroke-opacity":
                  s.stroke_opacity = _float(val, s.stroke_opacity)
              elif prop == "opacity":
                  s.opacity = _float(val, s.opacity)
              elif prop == "stroke-dasharray":
                  s.stroke_dasharray = val
              elif prop == "font-size":
                  s.font_size = _float(val, s.font_size)
              elif prop == "font-weight":
                  s.font_weight = val
              elif prop == "font-style":
                  s.font_style = val
              elif prop == "font-family":
                  s.font_family = val
              elif prop == "text-anchor":
                  s.text_anchor = val
              elif prop == "dominant-baseline":
                  s.dominant_baseline = val
          return s
      
      
      def _effective_opacity(style: SvgStyle) -> float:
          return min(1.0, max(0.0,
                              style.opacity * style.fill_opacity))
      
      
      def _effective_stroke_opacity(style: SvgStyle) -> float:
          return min(1.0, max(0.0, style.opacity * style.stroke_opacity))
      
      
      def _float(val: str, default: float = 0.0) -> float:
          try:
              return float(re.sub(r"[^0-9.\-]", "", val))
          except (ValueError, TypeError):
              return default
      
      
      # ---------------------------------------------------------------------------
      # SVG path parsing + Bezier flattening
      # ---------------------------------------------------------------------------
      _PATH_CMD = re.compile(r"([MLHVCSTAQZmlhvcstaqz])")
      _NUM = re.compile(
          r"[-+]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][-+]?\d+)?"
      )
      
      
      def _tokenize_path(d: str):
          """Yield (command, [floats...]) tuples from an SVG path 'd' attribute."""
          # Split into command + numbers segments.
          parts = _PATH_CMD.split(d)
          # parts[0] is leading whitespace/garbage before first command
          for i in range(1, len(parts), 2):
              cmd = parts[i]
              nums = [float(m.group()) for m in _NUM.finditer(parts[i + 1])]
              yield cmd, nums
      
      
      def _flatten_path(d: str, tol: float = 1.0):
          """Flatten an SVG path into a list of sub-paths (polylines).
      
          Returns: list of (points, closed) where points is [(x,y), ...].
          Curves (C/S/Q/T/A) are subdivided into line segments within `tol` px.
          """
          subpaths = []
          points: list[tuple[float, float]] = []
          closed = False
          cx = cy = 0.0  # current point
          sx = sy = 0.0  # subpath start point
          # Last Bezier control point for S/T smooth-curve reflection. Local (not
          # module-level) so each path flattening is independent; reset to the current
          # point after any non-curve command per the SVG spec (S/T only reflect when
          # the immediately preceding command was C/S or Q/T respectively).
          last_ctrl = [0.0, 0.0]
      
          def moveto(x, y, relative=False):
              nonlocal cx, cy, sx, sy
              if relative:
                  x, y = cx + x, cy + y
              cx, cy = x, y
              sx, sy = x, y
      
          def lineto(x, y, relative=False):
              nonlocal cx, cy
              if relative:
                  x, y = cx + x, cy + y
              points.append((x, y))
              cx, cy = x, y
      
          for cmd, nums in _tokenize_path(d):
              it = iter(nums)
      
              if cmd in ("M", "m"):
                  if points:
                      subpaths.append((points, closed))
                  x, y = next(it), next(it)
                  moveto(x, y, cmd == "m")
                  points = [(cx, cy)]
                  closed = False
                  # subsequent pairs are implicit lineto
                  for px2, py2 in _pairs(it):
                      lineto(px2, py2, cmd == "m")
                  last_ctrl[0], last_ctrl[1] = cx, cy  # M is not a curve command
      
              elif cmd in ("L", "l"):
                  for x, y in _pairs(it):
                      lineto(x, y, cmd == "l")
                  last_ctrl[0], last_ctrl[1] = cx, cy
      
              elif cmd in ("H", "h"):
                  for x in it:
                      lineto(x if cmd == "H" else cx + x, cy)
                  last_ctrl[0], last_ctrl[1] = cx, cy
      
              elif cmd in ("V", "v"):
                  for y in it:
                      lineto(cx, y if cmd == "V" else cy + y)
                  last_ctrl[0], last_ctrl[1] = cx, cy
      
              elif cmd in ("C", "c"):
                  for x1, y1, x2, y2, x3, y3 in _group(it, 6):
                      if cmd == "c":
                          x1, y1 = cx + x1, cy + y1
                          x2, y2 = cx + x2, cy + y2
                          x3, y3 = cx + x3, cy + y3
                      _flatten_cubic(cx, cy, x1, y1, x2, y2, x3, y3, tol, points)
                      last_ctrl[0], last_ctrl[1] = x2, y2  # 2nd control point for S reflection
                      cx, cy = x3, y3
      
              elif cmd in ("S", "s"):
                  # smooth cubic — reflect previous control point
                  for x2, y2, x3, y3 in _group(it, 4):
                      if cmd == "s":
                          x2, y2 = cx + x2, cy + y2
                          x3, y3 = cx + x3, cy + y3
                      x1 = 2 * cx - last_ctrl[0]
                      y1 = 2 * cy - last_ctrl[1]
                      _flatten_cubic(cx, cy, x1, y1, x2, y2, x3, y3, tol, points)
                      last_ctrl[0], last_ctrl[1] = x2, y2
                      cx, cy = x3, y3
      
              elif cmd in ("Q", "q"):
                  for x1, y1, x2, y2 in _group(it, 4):
                      if cmd == "q":
                          x1, y1 = cx + x1, cy + y1
                          x2, y2 = cx + x2, cy + y2
                      _flatten_quad(cx, cy, x1, y1, x2, y2, tol, points)
                      last_ctrl[0], last_ctrl[1] = x1, y1
                      cx, cy = x2, y2
      
              elif cmd in ("T", "t"):
                  for x2, y2 in _pairs(it):
                      if cmd == "t":
                          x2, y2 = cx + x2, cy + y2
                      x1 = 2 * cx - last_ctrl[0]
                      y1 = 2 * cy - last_ctrl[1]
                      _flatten_quad(cx, cy, x1, y1, x2, y2, tol, points)
                      last_ctrl[0], last_ctrl[1] = x1, y1
                      cx, cy = x2, y2
      
              elif cmd in ("A", "a"):
                  for rx_, ry_, angle, large, sweep, x, y in _group(it, 7):
                      if cmd == "a":
                          x, y = cx + x, cy + y
                      _arc_to_cubics(cx, cy, rx_, ry_, angle, bool(large),
                                     bool(sweep), x, y, tol, points)
                      cx, cy = x, y
                  last_ctrl[0], last_ctrl[1] = cx, cy  # A is not a C/S/Q/T command
      
              elif cmd in ("Z", "z"):
                  closed = True
                  cx, cy = sx, sy
                  last_ctrl[0], last_ctrl[1] = cx, cy
      
          if points:
              subpaths.append((points, closed))
          return subpaths
      
      
      def _pairs(it):
          """Yield (x, y) pairs from a flat iterator."""
          while True:
              try:
                  x = next(it)
                  y = next(it)
                  yield x, y
              except StopIteration:
                  return
      
      
      def _group(it, n):
          """Yield n-tuples from a flat iterator."""
          while True:
              chunk = []
              try:
                  for _ in range(n):
                      chunk.append(next(it))
              except StopIteration:
                  if len(chunk) == n:
                      yield tuple(chunk)
                  return
              yield tuple(chunk)
      
      
      def _flatten_cubic(x0, y0, x1, y1, x2, y2, x3, y3, tol, out, depth=0):
          """Adaptive de Casteljau subdivision for a cubic Bezier."""
          # Flatness test: distance from control points to the chord.
          dx = x3 - x0
          dy = y3 - y0
          d1 = abs((x1 - x0) * dy - (y1 - y0) * dx)
          d2 = abs((x2 - x0) * dy - (y2 - y0) * dx)
          seg_len = math.hypot(dx, dy) or 1.0
          flatness = (d1 + d2) / seg_len
          if (flatness <= tol and depth >= 1) or depth > 18:
              out.append((x3, y3))
              return
          # Subdivide at t=0.5
          mx0, my0 = (x0 + x1) / 2, (y0 + y1) / 2
          mx1, my1 = (x1 + x2) / 2, (y1 + y2) / 2
          mx2, my2 = (x2 + x3) / 2, (y2 + y3) / 2
          mx3, my3 = (mx0 + mx1) / 2, (my0 + my1) / 2
          mx4, my4 = (mx1 + mx2) / 2, (my1 + my2) / 2
          mx, my = (mx3 + mx4) / 2, (my3 + my4) / 2
          _flatten_cubic(x0, y0, mx0, my0, mx3, my3, mx, my, tol, out, depth + 1)
          _flatten_cubic(mx, my, mx4, my4, mx2, my2, x3, y3, tol, out, depth + 1)
      
      
      def _flatten_quad(x0, y0, x1, y1, x2, y2, tol, out, depth=0):
          """Adaptive subdivision for a quadratic Bezier."""
          dx = x2 - x0
          dy = y2 - y0
          d = abs((x1 - x0) * dy - (y1 - y0) * dx)
          seg_len = math.hypot(dx, dy) or 1.0
          if (d / seg_len <= tol and depth >= 1) or depth > 18:
              out.append((x2, y2))
              return
          mx0, my0 = (x0 + x1) / 2, (y0 + y1) / 2
          mx1, my1 = (x1 + x2) / 2, (y1 + y2) / 2
          mx, my = (mx0 + mx1) / 2, (my0 + my1) / 2
          _flatten_quad(x0, y0, mx0, my0, mx, my, tol, out, depth + 1)
          _flatten_quad(mx, my, mx1, my1, x2, y2, tol, out, depth + 1)
      
      
      def _arc_to_cubics(x0, y0, rx_, ry_, x_rot, large, sweep, x1, y1, tol, out):
          """Flatten an SVG arc by converting to cubic Bezier segments."""
          # If endpoints are identical, arc is omitted.
          if x0 == x1 and y0 == y1:
              return
          rx_ = abs(rx_)
          ry_ = abs(ry_)
          if rx_ == 0 or ry_ == 0:
              out.append((x1, y1))
              return
          phi = math.radians(x_rot)
          cos_phi, sin_phi = math.cos(phi), math.sin(phi)
          # Step 1: compute (x1', y1')
          dx = (x0 - x1) / 2
          dy = (y0 - y1) / 2
          x1p = cos_phi * dx + sin_phi * dy
          y1p = -sin_phi * dx + cos_phi * dy
          # Correct radii
          r2 = (x1p * x1p) / (rx_ * rx_) + (y1p * y1p) / (ry_ * ry_)
          if r2 > 1:
              f = math.sqrt(r2)
              rx_ *= f
              ry_ *= f
          # Step 2: compute (cx', cy')
          sign = -1 if large == sweep else 1
          num = rx_ * rx_ * ry_ * ry_ - rx_ * rx_ * y1p * y1p - ry_ * ry_ * x1p * x1p
          den = rx_ * rx_ * y1p * y1p + ry_ * ry_ * x1p * x1p
          coef = math.sqrt(max(0, num / den)) if den else 0
          cxp = sign * coef * (rx_ * y1p / ry_)
          cyp = sign * coef * (-ry_ * x1p / rx_)
          # Step 3: compute (cx, cy)
          cx_ = cos_phi * cxp - sin_phi * cyp + (x0 + x1) / 2
          cy_ = sin_phi * cxp + cos_phi * cyp + (y0 + y1) / 2
          # Step 4: compute theta1 and delta_theta
          def angle(ux, uy, vx, vy):
              dot = ux * vx + uy * vy
              len_u = math.hypot(ux, uy)
              len_v = math.hypot(vx, vy)
              c = max(-1, min(1, dot / (len_u * len_v)))
              a = math.acos(c)
              if ux * vy - uy * vx < 0:
                  a = -a
              return a
          theta1 = angle(1, 0, (x1p - cxp) / rx_, (y1p - cyp) / ry_)
          delta = angle((x1p - cxp) / rx_, (y1p - cyp) / ry_,
                        (-x1p - cxp) / rx_, (-y1p - cyp) / ry_)
          if not sweep and delta > 0:
              delta -= 2 * math.pi
          elif sweep and delta < 0:
              delta += 2 * math.pi
          # Split into segments of <= 90°
          n_segs = max(1, int(math.ceil(abs(delta) / (math.pi / 2))))
          seg_delta = delta / n_segs
          t = math.tan(seg_delta / 2)
          alpha = math.sin(seg_delta) * (math.sqrt(4 + 3 * t * t) - 1) / 3
          cos_t1, sin_t1 = math.cos(theta1), math.sin(theta1)
          for i in range(n_segs):
              theta1_i = theta1 + i * seg_delta
              cos1, sin1 = math.cos(theta1_i), math.sin(theta1_i)
              cos2, sin2 = math.cos(theta1_i + seg_delta), math.sin(theta1_i + seg_delta)
              # control point 1
              px1 = cx_ + rx_ * (cos1 - alpha * sin1) * cos_phi \
                  - ry_ * (sin1 + alpha * cos1) * sin_phi
              py1 = cy_ + rx_ * (cos1 - alpha * sin1) * sin_phi \
                  + ry_ * (sin1 + alpha * cos1) * cos_phi
              # control point 2
              px2 = cx_ + rx_ * (cos2 + alpha * sin2) * cos_phi \
                  - ry_ * (sin2 - alpha * cos2) * sin_phi
              py2 = cy_ + rx_ * (cos2 + alpha * sin2) * sin_phi \
                  + ry_ * (sin2 - alpha * cos2) * cos_phi
              # end point
              ex = cx_ + rx_ * cos2 * cos_phi - ry_ * sin2 * sin_phi
              ey = cy_ + rx_ * cos2 * sin_phi + ry_ * sin2 * cos_phi
              _flatten_cubic(x0 if i == 0 else out[-1][0],
                             y0 if i == 0 else out[-1][1],
                             px1, py1, px2, py2, ex, ey, tol, out)
      
      
      # ---------------------------------------------------------------------------
      # Marker (arrowhead) handling
      # ---------------------------------------------------------------------------
      @dataclass
      class MarkerDef:
          """Parsed <marker> definition."""
          id: str
          width: float = 10
          height: float = 8
          ref_x: float = 9
          ref_y: float = 4
          fill: str = "#000000"
          points: list = field(default_factory=list)  # polygon points
          # markerUnits: "strokeWidth" (SVG default — marker dims scale with the
          # path's stroke-width) or "userSpaceOnUse" (1:1). SVGDrawer markers omit
          # the attribute, so the default "strokeWidth" applies and must be honoured.
          marker_units: str = "strokeWidth"
      
      
      def _parse_markers(root: ET.Element) -> dict[str, MarkerDef]:
          markers = {}
          ns = {"svg": "http://www.w3.org/2000/svg"}
          for m in root.iter():
              tag = _local(m.tag)
              if tag == "marker":
                  mid = m.get("id", "")
                  md = MarkerDef(id=mid)
                  md.marker_units = m.get("markerUnits", "strokeWidth")
                  md.width = _float(m.get("markerWidth", "10"))
                  md.height = _float(m.get("markerHeight", "8"))
                  md.ref_x = _float(m.get("refX", "0"))
                  md.ref_y = _float(m.get("refY", "0"))
                  # Find the polygon inside
                  for child in m:
                      ct = _local(child.tag)
                      if ct == "polygon":
                          pts_str = child.get("points", "")
                          md.points = _parse_points(pts_str)
                          md.fill = _normalize_color(child.get("fill", "#000000"))
                      elif ct == "path":
                          # Approximate path marker as polygon
                          sub = _flatten_path(child.get("d", ""))
                          if sub:
                              md.points = sub[0][0]
                          md.fill = _normalize_color(child.get("fill", "#000000"))
                  markers[mid] = md
          return markers
      
      
      def _render_marker(shape_collection, marker: MarkerDef, x, y, ux, uy,
                         scale, offset_x, offset_y, stroke_width=1.0):
          """Render an arrowhead marker as a freeform triangle at endpoint (x,y).
      
          The marker is oriented so its x-axis aligns with direction (ux, uy).
          markerUnits=strokeWidth (the SVG default) scales marker dimensions by the
          owning path's stroke-width; userSpaceOnUse renders at 1:1.
          """
          if not marker.points:
              return
          ms = stroke_width if marker.marker_units == "strokeWidth" else 1.0
          ref_x = marker.ref_x * ms
          ref_y = marker.ref_y * ms
          # Perpendicular to direction
          nx, ny = -uy, ux
          # Transform each marker-space point to world space:
          # world = endpoint + (mx*ms - refX) * u + (my*ms - refY) * n
          world_pts = []
          for mx, my in marker.points:
              wx = x + (mx * ms - ref_x) * ux + (my * ms - ref_y) * nx
              wy = y + (mx * ms - ref_x) * uy + (my * ms - ref_y) * ny
              world_pts.append((wx, wy))
          _add_freeform(shape_collection, world_pts, True, SvgStyle(fill=marker.fill),
                        scale, offset_x, offset_y)
      
      
      # ---------------------------------------------------------------------------
      # Shape creation
      # ---------------------------------------------------------------------------
      def _local(tag: str) -> str:
          return tag.split("}")[-1].lower() if "}" in tag else tag.lower()
      
      
      def _parse_points(s: str) -> list[tuple[float, float]]:
          vals = re.split(r"[\s,]+", s.strip())
          vals = [v for v in vals if v]
          pts = []
          for i in range(0, len(vals) - 1, 2):
              try:
                  pts.append((float(vals[i]), float(vals[i + 1])))
              except ValueError:
                  continue
          return pts
      
      
      def _apply_fill(shape, style: SvgStyle, config: PptxConfig):
          """Apply fill from SvgStyle to a pptx shape."""
          fill = shape.fill
          fill_color = _normalize_color(style.fill, config.default_fill)
          eff_opacity = _effective_opacity(style)
          if fill_color == "none":
              fill.background()
          else:
              fill.solid()
              rgb = _hex_to_rgbcolor(fill_color)
              if rgb:
                  fill.fore_color.rgb = rgb
              if eff_opacity < 1.0:
                  _set_fill_alpha(shape, eff_opacity)
      
      
      def _set_fill_alpha(shape, alpha: float):
          """Set fill transparency via direct XML manipulation (python-pptx lacks API)."""
          alpha_pct = int(round(alpha * 100000))
          sp = shape.fill._xPr  # spPr or ln element
          srgb = sp.find(qn("a:solidFill") + "/" + qn("a:srgbClr"))
          if srgb is None:
              solid = sp.find(qn("a:solidFill"))
              if solid is not None:
                  srgb = solid.find(qn("a:srgbClr"))
          if srgb is not None:
              alpha_el = srgb.find(qn("a:alpha"))
              if alpha_el is None:
                  alpha_el = srgb.makeelement(qn("a:alpha"), {})
                  srgb.append(alpha_el)
              alpha_el.set("val", str(alpha_pct))
      
      
      def _apply_line(shape, style: SvgStyle, config: PptxConfig):
          """Apply stroke from SvgStyle to a pptx shape's line."""
          line = shape.line
          stroke = _normalize_color(style.stroke, config.default_stroke)
          if stroke == "none":
              line.fill.background()
              return
          rgb = _hex_to_rgbcolor(stroke)
          if rgb:
              line.color.rgb = rgb
          line.width = Emu(px(style.stroke_width))
          if style.stroke_dasharray:
              _set_line_dash(shape, style.stroke_dasharray, style.stroke_width)
      
      
      def _set_line_dash(shape, dasharray: str, width: float):
          """Set a custom dash pattern via XML (python-pptx has limited dash support)."""
          # Parse "6,3" or "4 3" → dash, gap lengths in px
          parts = [float(x) for x in re.split(r"[\s,]+", dasharray.strip()) if x]
          if not parts:
              return
          # Build prstDash val from common patterns, else custom
          ln = shape.line._get_or_add_ln()
          # Remove existing dash
          for old in ln.findall(qn("a:prstDash")):
              ln.remove(old)
          for old in ln.findall(qn("a:custDash")):
              ln.remove(old)
          if len(parts) == 2 and abs(parts[0] / max(parts[1], 0.01) - 2.0) < 0.3:
              prst = ln.makeelement(qn("a:prstDash"), {"val": "dash"})
              ln.append(prst)
          else:
              # Custom dash
              cust = ln.makeelement(qn("a:custDash"), {})
              for i, p in enumerate(parts):
                  d_len = int(p * EMU_PER_PX)
                  tag = qn("a:ds") if i % 2 == 0 else qn("a:g")
                  el = cust.makeelement(tag, {"d": str(max(d_len, 1)), "sp": str(max(d_len, 1))})
                  cust.append(el)
              ln.append(cust)
      
      
      def _disable_shadow(shape):
          """Disable inherited shadow (python-pptx autoShape default has shadow)."""
          try:
              shape.shadow.inherit = False
          except Exception:
              pass
      
      
      def _estimate_text_width(text: str, font_size: float, weight: str) -> float:
          """Estimate text width in px (matches SVGDrawer / evaluator metric)."""
          coef = 0.62 if weight == "bold" else 0.55
          return sum(font_size * (1.0 if ord(c) > 0x2E80 else coef) for c in text)
      
      
      def _create_text(shapes, el: ET.Element, transform, style: SvgStyle,
                       scale, offset_x, offset_y):
          """Create a PowerPoint text box from an SVG <text> element."""
          x = _float(el.get("x", "0"))
          y = _float(el.get("y", "0"))
          tx, ty = transform_point(transform, (x, y))
          # Concatenate direct text + <tspan> children (itertext handles both).
          content = "".join(el.itertext())
          content = html.unescape(content)
          if not content.strip():
              return
      
          fs = style.font_size
          w_est = _estimate_text_width(content, fs, style.font_weight)
          h_est = fs * 1.3
      
          anchor = style.text_anchor
          baseline = style.dominant_baseline
      
          if anchor == "middle":
              left = tx - w_est / 2
              align = PP_ALIGN.CENTER
          elif anchor == "end":
              left = tx - w_est
              align = PP_ALIGN.RIGHT
          else:
              left = tx
              align = PP_ALIGN.LEFT
      
          if baseline in ("central", "middle"):
              top = ty - h_est / 2
              vert = MSO_ANCHOR.MIDDLE
          elif baseline in ("hanging", "text-before-edge"):
              top = ty
              vert = MSO_ANCHOR.TOP
          else:
              top = ty - fs
              vert = MSO_ANCHOR.BOTTOM
      
          # Add padding to avoid clipping
          pad = fs * 0.3
          left -= pad
          top -= pad * 0.3
          w_est += pad * 2
          h_est += pad * 0.6
      
          box_left = offset_x + px(left * scale)
          box_top = offset_y + px(top * scale)
          box_w = px(w_est * scale)
          box_h = px(h_est * scale)
      
          tx_shape = shapes.add_textbox(box_left, box_top, box_w, box_h)
          tx_shape.text_frame.word_wrap = False
          tx_shape.text_frame.margin_left = 0
          tx_shape.text_frame.margin_right = 0
          tx_shape.text_frame.margin_top = 0
          tx_shape.text_frame.margin_bottom = 0
          tx_shape.text_frame.vertical_anchor = vert
      
          p = tx_shape.text_frame.paragraphs[0]
          p.alignment = align
          run = p.add_run()
          run.text = content
          run.font.size = Pt(fs * scale * (72 / 96))  # px→pt: 1pt = 1.333px at 96dpi
          run.font.bold = (style.font_weight == "bold")
          run.font.italic = (style.font_style == "italic")
          fill_color = _normalize_color(style.fill, "#000000")
          rgb = _hex_to_rgbcolor(fill_color)
          if rgb:
              run.font.color.rgb = rgb
      
          # Disable textbox border/fill
          _disable_shadow(tx_shape)
          tx_shape.fill.background()
          tx_shape.line.fill.background()
      
      
      def _add_freeform(shapes, points, closed, style, scale, offset_x, offset_y):
          """Add a freeform polygon/polyline to the shapes collection."""
          if len(points) < 2:
              return None
          # Transform points to absolute EMU
          emu_pts = [(offset_x + px(p[0] * scale), offset_y + px(p[1] * scale))
                     for p in points]
          try:
              builder = shapes.build_freeform(emu_pts[0][0], emu_pts[0][1])
              builder.add_line_segments(emu_pts[1:], close=closed)
              shape = builder.convert_to_shape()
          except Exception:
              # Fallback: if freeform fails, skip this shape
              return None
      
          _apply_fill(shape, style, _noop_config())
          _apply_line(shape, style, _noop_config())
          _disable_shadow(shape)
          return shape
      
      
      _noop_cfg = None
      
      
      def _noop_config():
          global _noop_cfg
          if _noop_cfg is None:
              _noop_cfg = PptxConfig()
          return _noop_cfg
      
      
      # ---------------------------------------------------------------------------
      # Main converter
      # ---------------------------------------------------------------------------
      class _Converter:
          """Walks the SVG element tree and creates PPTX shapes."""
      
          def __init__(self, config: PptxConfig):
              self.config = config
              self.markers: dict[str, MarkerDef] = {}
      
          def convert(self, svg_content: str) -> Presentation:
              """Parse SVG string, return a Presentation with one slide."""
              # Strip XML namespace prefixes for easier parsing
              svg_clean = svg_content
              root = ET.fromstring(svg_content)
              # Register namespace
              ET.register_namespace("", "http://www.w3.org/2000/svg")
      
              # Get SVG dimensions
              vb = root.get("viewBox")
              if vb:
                  parts = [float(x) for x in re.split(r"[\s,]+", vb.strip())]
                  vb_x, vb_y, vb_w, vb_h = parts[0], parts[1], parts[2], parts[3]
              else:
                  vb_x = vb_y = 0
                  vb_w = _float(root.get("width", "1200"))
                  vb_h = _float(root.get("height", "800"))
      
              # Parse markers from defs
              self.markers = _parse_markers(root)
      
              # Create presentation
              prs = Presentation()
              prs.slide_width = Emu(int(self.config.slide_w * 914400))
              prs.slide_height = Emu(int(self.config.slide_h * 914400))
              slide = prs.slides.add_slide(prs.slide_layouts[6])
      
              if self.config.mode == "image":
                  self._add_image(svg_content, slide, vb_w, vb_h, prs)
              else:
                  # Calculate fit-to-slide scale
                  margin_px = self.config.margin * 96
                  avail_w = self.config.slide_w * 96 - 2 * margin_px
                  avail_h = self.config.slide_h * 96 - 2 * margin_px
                  fit_scale = min(avail_w / vb_w, avail_h / vb_h)
                  scale = fit_scale * self.config.scale
      
                  # Center the SVG content
                  rendered_w = vb_w * scale
                  rendered_h = vb_h * scale
                  offset_x = px((self.config.slide_w * 96 - rendered_w) / 2 - vb_x * scale)
                  offset_y = px((self.config.slide_h * 96 - rendered_h) / 2 - vb_y * scale)
      
                  # Walk elements
                  for child in root:
                      self._walk(child, IDENTITY, None, slide.shapes, scale,
                                 offset_x, offset_y)
      
              return prs
      
          def _walk(self, el, parent_transform, parent_style, shapes, scale, ox, oy):
              tag = _local(el.tag)
              if tag in ("defs", "marker", "clippath", "filter",
                          "lineargradient", "radialgradient", "pattern", "symbol"):
                  return  # skip non-rendering elements
      
              own_t = parse_transform(el.get("transform", ""))
              transform = multiply_matrix(parent_transform, own_t)
              style = _parse_style(el, parent_style)
      
              if tag == "g":
                  for child in el:
                      self._walk(child, transform, style, shapes, scale, ox, oy)
      
              elif tag == "rect":
                  self._make_rect(el, transform, style, shapes, scale, ox, oy)
      
              elif tag in ("circle", "ellipse"):
                  self._make_oval(el, tag, transform, style, shapes, scale, ox, oy)
      
              elif tag == "line":
                  self._make_line(el, transform, style, shapes, scale, ox, oy)
      
              elif tag in ("polygon", "polyline"):
                  self._make_polygon(el, tag, transform, style, shapes, scale, ox, oy)
      
              elif tag == "path":
                  self._make_path(el, transform, style, shapes, scale, ox, oy)
      
              elif tag == "text":
                  _create_text(shapes, el, transform, style, scale, ox, oy)
      
              elif tag == "use":
                  # Resolve <use href="#id"> — find the referenced element and render it
                  href = el.get("href") or el.get("{http://www.w3.org/1999/xlink}href")
                  if href:
                      ref_id = href.lstrip("#")
                      # Would need a symbol/defs registry; skip for now
                      pass
      
          def _make_rect(self, el, transform, style, shapes, scale, ox, oy):
              x = _float(el.get("x", "0"))
              y = _float(el.get("y", "0"))
              w = _float(el.get("width", "0"))
              h = _float(el.get("height", "0"))
              rx_ = _float(el.get("rx", "0"))
              ry_ = _float(el.get("ry", str(rx_)))
              if w <= 0 or h <= 0:
                  return
              # Apply transform to all four corners, get AABB
              corners = [(x, y), (x + w, y), (x, y + h), (x + w, y + h)]
              pts = [transform_point(transform, c) for c in corners]
              xs = [p[0] for p in pts]
              ys = [p[1] for p in pts]
              ax, ay = min(xs), min(ys)
              aw, ah = max(xs) - ax, max(ys) - ay
      
              # Check if transform is axis-aligned (no rotation/skew)
              is_axis_aligned = (abs(transform[1]) < 1e-6 and abs(transform[2]) < 1e-6)
      
              if rx_ > 0 or ry_ > 0:
                  shape = shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE,
                                           Emu(ox + px(ax * scale)),
                                           Emu(oy + px(ay * scale)),
                                           Emu(px(aw * scale)),
                                           Emu(px(ah * scale)))
                  # Adjust corner radius (adjustment value 0-0.5)
                  try:
                      adj = min(rx_, ry_) / min(w, h)
                      shape.adjustments[0] = min(0.5, adj)
                  except Exception:
                      pass
              else:
                  shape = shapes.add_shape(MSO_SHAPE.RECTANGLE,
                                           Emu(ox + px(ax * scale)),
                                           Emu(oy + px(ay * scale)),
                                           Emu(px(aw * scale)),
                                           Emu(px(ah * scale)))
      
              # Apply rotation uniformly to both rect variants.
              # For a rectangle, the AABB center == rotated-rect center (by symmetry),
              # and PPTX rotates around the shape center, so this is geometrically exact
              # for rotation+scale+translate transforms.
              if not is_axis_aligned:
                  angle = math.degrees(math.atan2(transform[1], transform[0]))
                  shape.rotation = angle
      
              _apply_fill(shape, style, self.config)
              _apply_line(shape, style, self.config)
              _disable_shadow(shape)
      
          def _make_oval(self, el, tag, transform, style, shapes, scale, ox, oy):
              cx_ = _float(el.get("cx", "0"))
              cy_ = _float(el.get("cy", "0"))
              if tag == "circle":
                  r = _float(el.get("r", "0"))
                  rx_ = ry_ = r
              else:
                  rx_ = _float(el.get("rx", "0"))
                  ry_ = _float(el.get("ry", "0"))
              if rx_ <= 0 or ry_ <= 0:
                  return
              x = cx_ - rx_
              y = cy_ - ry_
              w = 2 * rx_
              h = 2 * ry_
              corners = [(x, y), (x + w, y), (x, y + h), (x + w, y + h)]
              pts = [transform_point(transform, c) for c in corners]
              xs = [p[0] for p in pts]
              ys = [p[1] for p in pts]
              ax, ay = min(xs), min(ys)
              aw, ah = max(xs) - ax, max(ys) - ay
      
              shape = shapes.add_shape(MSO_SHAPE.OVAL,
                                       Emu(ox + px(ax * scale)),
                                       Emu(oy + px(ay * scale)),
                                       Emu(px(aw * scale)),
                                       Emu(px(ah * scale)))
              # Apply rotation if transform is non-axis-aligned (same reasoning as rect).
              if abs(transform[1]) > 1e-6 or abs(transform[2]) > 1e-6:
                  angle = math.degrees(math.atan2(transform[1], transform[0]))
                  shape.rotation = angle
              _apply_fill(shape, style, self.config)
              _apply_line(shape, style, self.config)
              _disable_shadow(shape)
      
          def _make_line(self, el, transform, style, shapes, scale, ox, oy):
              x1 = _float(el.get("x1", "0"))
              y1 = _float(el.get("y1", "0"))
              x2 = _float(el.get("x2", "0"))
              y2 = _float(el.get("y2", "0"))
              p1 = transform_point(transform, (x1, y1))
              p2 = transform_point(transform, (x2, y2))
      
              conn = shapes.add_connector(MSO_CONNECTOR.STRAIGHT,
                                          Emu(ox + px(p1[0] * scale)),
                                          Emu(oy + px(p1[1] * scale)),
                                          Emu(ox + px(p2[0] * scale)),
                                          Emu(oy + px(p2[1] * scale)))
              _disable_shadow(conn)
              stroke = _normalize_color(style.stroke, self.config.default_stroke)
              if stroke != "none":
                  rgb = _hex_to_rgbcolor(stroke)
                  if rgb:
                      conn.line.color.rgb = rgb
              conn.line.width = Emu(px(style.stroke_width))
              if style.stroke_dasharray:
                  _set_line_dash(conn, style.stroke_dasharray, style.stroke_width)
      
              # Render marker-end (arrowhead) if present
              marker_ref = el.get("marker-end", "")
              if marker_ref:
                  mid = re.search(r"url\(#([^)]+)\)", marker_ref)
                  if mid and mid.group(1) in self.markers:
                      marker = self.markers[mid.group(1)]
                      dx, dy = p2[0] - p1[0], p2[1] - p1[1]
                      seglen = math.hypot(dx, dy) or 1.0
                      ux, uy = dx / seglen, dy / seglen
                      _render_marker(shapes, marker, p2[0], p2[1], ux, uy, scale, ox, oy, style.stroke_width)
      
              marker_start = el.get("marker-start", "")
              if marker_start:
                  mid = re.search(r"url\(#([^)]+)\)", marker_start)
                  if mid and mid.group(1) in self.markers:
                      marker = self.markers[mid.group(1)]
                      dx, dy = p1[0] - p2[0], p1[1] - p2[1]
                      seglen = math.hypot(dx, dy) or 1.0
                      ux, uy = dx / seglen, dy / seglen
                      _render_marker(shapes, marker, p1[0], p1[1], ux, uy, scale, ox, oy, style.stroke_width)
      
          def _make_polygon(self, el, tag, transform, style, shapes, scale, ox, oy):
              pts_str = el.get("points", "")
              local_pts = _parse_points(pts_str)
              if len(local_pts) < 2:
                  return
              world_pts = [transform_point(transform, p) for p in local_pts]
              closed = (tag == "polygon")
              _add_freeform(shapes, world_pts, closed, style, scale, ox, oy)
      
          def _make_path(self, el, transform, style, shapes, scale, ox, oy):
              d = el.get("d", "")
              if not d.strip():
                  return
              subpaths = _flatten_path(d, self.config.curve_tolerance)
              for pts, closed in subpaths:
                  if len(pts) < 2:
                      continue
                  world_pts = [transform_point(transform, p) for p in pts]
                  _add_freeform(shapes, world_pts, closed, style, scale, ox, oy)
      
              # Render marker-end for paths too
              marker_ref = el.get("marker-end", "")
              if marker_ref and subpaths:
                  mid = re.search(r"url\(#([^)]+)\)", marker_ref)
                  if mid and mid.group(1) in self.markers:
                      marker = self.markers[mid.group(1)]
                      last_pts = subpaths[-1][0]
                      if len(last_pts) >= 2:
                          p1 = transform_point(transform, last_pts[-2])
                          p2 = transform_point(transform, last_pts[-1])
                          dx, dy = p2[0] - p1[0], p2[1] - p1[1]
                          seglen = math.hypot(dx, dy) or 1.0
                          ux, uy = dx / seglen, dy / seglen
                          _render_marker(shapes, marker, p2[0], p2[1], ux, uy, scale, ox, oy, style.stroke_width)
      
          def _add_image(self, svg_content, slide, vb_w, vb_h, prs):
              """Rasterize SVG to PNG via rsvg-convert and embed as a picture."""
              png_path = None
              try:
                  with tempfile.NamedTemporaryFile(suffix=".svg", delete=False, mode="w") as sf:
                      sf.write(svg_content)
                      svg_file = sf.name
                  png_path = svg_file.replace(".svg", ".png")
                  dpi = self.config.image_dpi
                  scale = dpi / 96
                  subprocess.run(
                      ["rsvg-convert", "-d", str(dpi), "-p", str(dpi),
                       svg_file, "-o", png_path],
                      check=True, capture_output=True)
                  # Compute placement
                  slide_w = self.config.slide_w * 914400
                  slide_h = self.config.slide_h * 914400
                  margin = self.config.margin * 914400
                  avail_w = slide_w - 2 * margin
                  avail_h = slide_h - 2 * margin
                  # Aspect ratio from SVG
                  ratio = vb_w / vb_h
                  target_w = avail_w
                  target_h = int(target_w / ratio)
                  if target_h > avail_h:
                      target_h = avail_h
                      target_w = int(target_h * ratio)
                  left = int((slide_w - target_w) / 2)
                  top = int((slide_h - target_h) / 2)
                  slide.shapes.add_picture(png_path, Emu(left), Emu(top),
                                           Emu(int(target_w)), Emu(int(target_h)))
              finally:
                  for f in ([png_path] if png_path else []):
                      try:
                          os.unlink(f)
                      except OSError:
                          pass
                  try:
                      os.unlink(svg_file)
                  except (OSError, NameError):
                      pass
      
      
      # ---------------------------------------------------------------------------
      # Public API
      # ---------------------------------------------------------------------------
      def svg_to_pptx(
          svg: Union[str, Path],
          pptx_path: Union[str, Path],
          config: Optional[PptxConfig] = None,
      ) -> Presentation:
          """Convert SVG content to a PowerPoint file.
      
          Args:
              svg:        SVG content as a string, or a path to an .svg file.
              pptx_path:  Output .pptx file path.
              config:     Optional PptxConfig. Defaults to 16:9, shapes mode.
      
          Returns:
              The pptx Presentation object (also saved to pptx_path).
          """
          cfg = config or PptxConfig()
          # Determine if svg is a file path or SVG string
          svg_str = svg
          if isinstance(svg, (str, Path)):
              sp = str(svg)
              if os.path.isfile(sp) and sp.lower().endswith(".svg"):
                  with open(sp, "r", encoding="utf-8") as f:
                      svg_str = f.read()
          # If svg_to_pptx is called with a pure SVG string that happens to not exist
          # as a file, use it directly (it's already the SVG content).
          converter = _Converter(cfg)
          prs = converter.convert(svg_str)
          out = Path(pptx_path).resolve()
          out.parent.mkdir(parents=True, exist_ok=True)
          prs.save(str(out))
          return prs
      
      
      def add_svg_to_slide(
          svg: Union[str, Path],
          slide,
          x: float = 0,
          y: float = 0,
          scale: float = 1.0,
          config: Optional[PptxConfig] = None,
      ):
          """Add SVG shapes to an existing slide (in-place).
      
          Args:
              svg:    SVG content string or .svg file path.
              slide:  pptx Slide object to add shapes to.
              x, y:   Top-left placement in inches.
              scale:  Scale factor (1.0 = original pixel size mapped to EMU).
              config: Optional PptxConfig (mode/curve_tolerance used).
          """
          cfg = config or PptxConfig()
          svg_str = svg
          if isinstance(svg, (str, Path)):
              sp = str(svg)
              if os.path.isfile(sp) and sp.lower().endswith(".svg"):
                  with open(sp, "r", encoding="utf-8") as f:
                      svg_str = f.read()
          root = ET.fromstring(svg_str)
          ET.register_namespace("", "http://www.w3.org/2000/svg")
          converter = _Converter(cfg)
          converter.markers = _parse_markers(root)
          vb = root.get("viewBox")
          if vb:
              parts = [float(x2) for x2 in re.split(r"[\s,]+", vb.strip())]
              vb_x, vb_y = parts[0], parts[1]
          else:
              vb_x = vb_y = 0
          offset_x = px(x * 96 - vb_x * scale)
          offset_y = px(y * 96 - vb_y * scale)
          for child in root:
              converter._walk(child, IDENTITY, None, slide.shapes, scale,
                              offset_x, offset_y)
      
      
      def save_pptx(drawer, pptx_path, config: Optional[PptxConfig] = None):
          """Convert an SVGDrawer's rendered output to PPTX.
      
          Convenience wrapper: drawer.render() → svg_to_pptx().
          """
          return svg_to_pptx(drawer.render(), pptx_path, config)
      
    • svg_utils.py 56.2 KB
      import html
      import math
      import re as _re
      import subprocess
      from pathlib import Path
      
      _INVISIBLE_PAINTS = {"none", "None", None, ""}
      
      
      def _shape_visible(fill, stroke, opacity):
          """True if a shape renders ANYTHING the eye can see.
      
          fill=none + stroke=none, or opacity=0, produces no pixels — such a node is
          invisible and must not be used as an edge endpoint (phantom anchor).
          """
          if opacity is not None and opacity <= 0:
              return False
          has_fill = fill not in _INVISIBLE_PAINTS
          has_stroke = stroke not in _INVISIBLE_PAINTS
          return has_fill or has_stroke
      
      def _dash_attr(dashed):
          """Normalize the ``dashed`` flag/pattern into a stroke-dasharray attribute.
      
          ``dashed=True`` means the standard "6,3" pattern; a non-empty string is
          taken verbatim. Centralizing it gives one spelling for consistent
          dashed rendering across rect/circle/line/path/connect so callers never need
          the raw ``extra='stroke-dasharray="..."'`` spelling.
          """
          if dashed is True:
              return 'stroke-dasharray="6,3"'
          if isinstance(dashed, str) and dashed:
              return f'stroke-dasharray="{dashed}"'
          return ""
      
      
      _NAMED_COLORS = {
          "white": "#ffffff", "black": "#000000", "red": "#ff0000",
          "green": "#008000", "blue": "#0000ff", "yellow": "#ffff00",
          "cyan": "#00ffff", "magenta": "#ff00ff", "gray": "#808080",
          "grey": "#808080", "silver": "#c0c0c0", "navy": "#000080",
          "maroon": "#800000", "olive": "#808000", "purple": "#800080",
          "teal": "#008080",
      }
      
      
      def normalize_color(value):
          """Normalize a CSS/SVG color to '#rrggbb' (lowercase) or return None.
      
          Returns None for none/transparent/unparseable colors so callers can skip
          them. Supports #RGB, #RRGGBB, rgb()/rgba() ints, and common named colors.
          """
          if value in _INVISIBLE_PAINTS or value == "transparent":
              return None
          v = str(value).strip()
          if v.startswith("#"):
              h = v[1:]
              if len(h) == 3:
                  h = "".join(c * 2 for c in h)
              if len(h) == 6:
                  try:
                      int(h, 16)
                      return "#" + h.lower()
                  except ValueError:
                      return None
              return None
          m = _re.match(r"rgba?\(([^)]+)\)", v)
          if m:
              parts = [p.strip() for p in m.group(1).split(",") if p.strip()]
              nums = []
              for p in parts:
                  n = p.rstrip("%")
                  try:
                      nums.append(float(n))
                  except ValueError:
                      return None
              if len(nums) >= 3:
                  rgb = [min(int(round(nums[i])), 255) for i in range(3)]
                  return "#%02x%02x%02x" % (rgb[0], rgb[1], rgb[2])
              return None
          named = _NAMED_COLORS.get(v.lower())
          return named.lower() if named else None
      
      
      def _hex_rgb(hex_color):
          """Return (r, g, b) ints for a #rrggbb string, or None if unparseable."""
          c = hex_color.lstrip("#")
          try:
              return int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)
          except (ValueError, IndexError):
              return None
      
      
      def is_neutral(hex_color):
          """True for white/black/grayscale colors (R==G==B) — structural, not accent."""
          rgb = _hex_rgb(hex_color)
          return bool(rgb) and rgb[0] == rgb[1] == rgb[2]
      
      
      def relative_luminance(hex_color):
          """WCAG sRGB relative luminance in [0, 1]. Higher = lighter."""
          rgb = _hex_rgb(hex_color)
          if rgb is None:
              return 0.5
          r, g, b = rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0
      
          def chan(v):
              return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
      
          return 0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b)
      
      
      # Affine transform matrix helpers (matrix(a,b,c,d,e,f) = [[a c e],[b d f],[0 0 1]]).
      IDENTITY = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
      
      
      def multiply_matrix(m1, m2):
          """Compose two affine matrices: result = m1 ∘ m2 (m2 applied first)."""
          a1, b1, c1, d1, e1, f1 = m1
          a2, b2, c2, d2, e2, f2 = m2
          return (
              a1 * a2 + c1 * b2,
              b1 * a2 + d1 * b2,
              a1 * c2 + c1 * d2,
              b1 * c2 + d1 * d2,
              a1 * e2 + c1 * f2 + e1,
              b1 * e2 + d1 * f2 + f1,
          )
      
      
      def transform_point(matrix, point):
          """Apply affine matrix (a,b,c,d,e,f) to (x, y)."""
          a, b, c, d, e, f = matrix
          x, y = point
          return (a * x + c * y + e, b * x + d * y + f)
      
      
      def parse_transform(value):
          """Parse an SVG transform attribute into an affine matrix.
      
          Supports matrix()/translate()/scale()/rotate()/skewX()/skewY(), chained.
          Ported from fireworks-tech-graph validate_svg.parse_transform.
          """
          result = IDENTITY
          if not value:
              return result
          for name, raw in _re.findall(r"([A-Za-z]+)\s*\(([^)]*)\)", str(value)):
              vals = [float(v) for v in _re.findall(r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?", raw)]
              name = name.lower()
              cur = IDENTITY
              if name == "matrix" and len(vals) == 6:
                  cur = tuple(vals)
              elif name == "translate" and vals:
                  cur = (1, 0, 0, 1, vals[0], vals[1] if len(vals) > 1 else 0)
              elif name == "scale" and vals:
                  cur = (vals[0], 0, 0, vals[1] if len(vals) > 1 else vals[0], 0, 0)
              elif name == "rotate" and vals:
                  ang = math.radians(vals[0])
                  rot = (math.cos(ang), math.sin(ang), -math.sin(ang), math.cos(ang), 0, 0)
                  if len(vals) >= 3:
                      cx, cy = vals[1], vals[2]
                      cur = multiply_matrix(multiply_matrix((1, 0, 0, 1, cx, cy), rot), (1, 0, 0, 1, -cx, -cy))
                  else:
                      cur = rot
              elif name == "skewx" and len(vals) == 1:
                  cur = (1, 0, math.tan(math.radians(vals[0])), 1, 0, 0)
              elif name == "skewy" and len(vals) == 1:
                  cur = (1, math.tan(math.radians(vals[0])), 0, 1, 0, 0)
              result = multiply_matrix(result, cur)
          return result
      
      
      class BBox:
          def __init__(self, x, y, w, h):
              self.x = x
              self.y = y
              self.w = w
              self.h = h
      
          def intersects(self, other):
              return not (self.x + self.w <= other.x or
                          other.x + other.w <= self.x or
                          self.y + self.h <= other.y or
                          other.y + other.h <= self.y)
      
          def contains(self, other):
              """True if this bbox fully encloses `other` (parent-child containment)."""
              return (self.x <= other.x and self.y <= other.y and
                      self.x + self.w >= other.x + other.w and
                      self.y + self.h >= other.y + other.h)
      
          @property
          def cx(self):
              return self.x + self.w / 2.0
      
          @property
          def cy(self):
              return self.y + self.h / 2.0
      
          def __repr__(self):
              return f"BBox(x={self.x}, y={self.y}, w={self.w}, h={self.h})"
      
      
      class Node:
          """A connectable rectangular element (operation, layer, block, ...).
      
          Registered so that edges (lines/paths) can be validated against it: every
          connection endpoint should land on or near a node border.
          """
      
          SIDES = ("top", "bottom", "left", "right")
      
          def __init__(self, node_id, x, y, w, h, kind="op", visible=True, role="node"):
              self.id = node_id
              self.x = x
              self.y = y
              self.w = w
              self.h = h
              self.kind = kind  # 'op' | 'layer' | 'block' | 'region' ...
              # Whether the node's shape actually renders anything. Invisible nodes
              # (fill=none + stroke=none, opacity=0, or zero-size) referenced by edges
              # are "phantom anchors" and get flagged by the validator.
              self.visible = visible
              # Semantic role: 'node' (business) | 'decoration' | 'legend' | 'label'
              # | 'background' | 'reserved'. Decorative/legend/background nodes are
              # excluded from business-logic checks (spacing, palette count, etc.).
              self.role = role
              # Relocate support: a node drawn outside any group (matrix == identity)
              # remembers where its emitted XML sits in drawer.elements and how to
              # regenerate it at new coords, so relocate_node() can move it post-emit.
              # Mutating x/y directly does NOT update the baked-in emit — this does.
              self._emit_range = None    # (start, end) indices into drawer.elements
              self._bbox_index = None    # index into drawer.bboxes (None if bbox=False)
              self._rebuild_xml = None   # callable(nx, ny) -> list[str]
              self._rebuild_bbox = None  # callable(nx, ny) -> BBox
      
          @property
          def cx(self):
              return self.x + self.w / 2.0
      
          @property
          def cy(self):
              return self.y + self.h / 2.0
      
          def edge_point(self, side, offset=0.0):
              """Midpoint of a given border side, optionally shifted along the
              border by ``offset`` px (positive = down/right along the border;
              used by automatic port spreading so same-side edges fan out).
              """
              if side == "top":
                  return (self.cx + offset, self.y)
              if side == "bottom":
                  return (self.cx + offset, self.y + self.h)
              if side == "left":
                  return (self.x, self.cy + offset)
              if side == "right":
                  return (self.x + self.w, self.cy + offset)
              raise ValueError(f"Unknown side: {side!r} (expected one of {Node.SIDES})")
      
          def border_distance(self, px, py):
              """Shortest distance from point (px, py) to the node's rectangular border.
      
              Returns 0 when the point lies on the border rectangle.
              """
              dx = max(self.x - px, 0.0, px - (self.x + self.w))
              dy = max(self.y - py, 0.0, py - (self.y + self.h))
              return math.hypot(dx, dy)
      
      
      class Edge:
          """A semantic connection between two points (optionally arrow-terminated).
      
          path_d holds the actual rendered path data (the `d` attribute) when the
          edge is a curve; the evaluator samples it (bezier/arc) instead of
          approximating by the chord. For straight line edges path_d is None.
          """
      
          def __init__(self, start, end, edge_id=None, has_arrow=True, label=None, path_d=None, role="edge"):
              self.id = edge_id
              self.start = (start[0], start[1])
              self.end = (end[0], end[1])
              self.has_arrow = has_arrow
              self.label = label
              self.path_d = path_d
              # Semantic role: 'edge' (business) | 'decoration'. Decorative edges
              # (rail casings, echoes) are excluded from crossing/connection checks.
              self.role = role
              # Relocate support: connect()-built edges remember their node anchors +
              # emit range so relocate_node() can re-route them when an endpoint node
              # moves. None for raw line()/path() edges (not auto-rerouted).
              self.from_id = None
              self.from_side = None
              self.to_id = None
              self.to_side = None
              self._emit_range = None    # (start, end) indices into drawer.elements
              self._rebuild_xml = None   # callable() -> list[str] (reads live node coords)
              # Deterministic same-port spread offset for this edge's endpoints,
              # keyed {(node_id, side): px_along_border}; assigned by
              # SVGDrawer._spread_ports() when several connect() edges share one
              # node side so they fan out symmetrically instead of stacking on the
              # border midpoint (which reads as one thick line). See
              # PORT_SPREAD_* constants near SVGDrawer.
              self._spread_offsets = {}
      
          @property
          def length(self):
              return math.hypot(self.end[0] - self.start[0], self.end[1] - self.start[1])
      
      
      class SVGDrawer:
          def __init__(self, width=1200, height=800, bg="#FFFFFF"):
              self.width = width
              self.height = height
              self.elements = []
              self.defs = []
              self.bboxes = []
              self.canvas_bbox = BBox(0, 0, width, height)
              # Semantic registries for connection validation
              self.nodes = {}       # id -> Node
              self.edges = []       # list[Edge]
              self._node_seq = 0
              self._edge_seq = 0
              # Registered arrow markers: id -> (markerWidth, markerHeight, refX).
              # connect() derives per-edge tip retraction from the marker actually
              # used, so the arrow always kisses the target border regardless of
              # marker size. Falls back to marker_depth for unregistered markers.
              self.markers = {}
              self.marker_depth = 8.0
              # Design-system registries for typography/palette quality checks.
              # font_sizes: every font_size passed to text()/multiline_text().
              # accent_colors: distinct non-neutral fills/strokes actually used.
              self.font_sizes = []
              self.accent_colors = set()
              # Background: defaults to white (a light canvas is the sane default for
              # technical diagrams). set_background() or the bg= ctor arg overrides.
              self._bg_index = None  # element-list slot of the bg rect, if any
              self.background = "#ffffff"
              # Transform stack for group() contexts. The current accumulated matrix
              # maps local coordinates (used inside a group) to absolute canvas coords.
              # register_node/register_edge/add_element apply it so nodes/bboxes/edges
              # are stored in absolute space regardless of grouping.
              self._matrix_stack = [(1.0, 0.0, 0.0, 1.0, 0.0, 0.0)]
              self.set_background(bg)
      
          # ------------------------------------------------------------------
          # Low-level element emission
          # ------------------------------------------------------------------
          @property
          def _current_matrix(self):
              return self._matrix_stack[-1]
      
          def _transform_bbox(self, bbox):
              """Map a local-space bbox to absolute canvas coords via current matrix.
      
              Transforms all four corners and takes the axis-aligned bounding box of
              the result (handles rotation/skew by enlarging the AABB).
              """
              m = self._current_matrix
              if m == IDENTITY:
                  return bbox
              corners = [(bbox.x, bbox.y), (bbox.x + bbox.w, bbox.y),
                         (bbox.x, bbox.y + bbox.h), (bbox.x + bbox.w, bbox.y + bbox.h)]
              pts = [transform_point(m, c) for c in corners]
              xs = [p[0] for p in pts]
              ys = [p[1] for p in pts]
              return BBox(min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys))
      
          def add_element(self, element, bbox=None):
              self.elements.append(element)
              if bbox:
                  bbox = self._transform_bbox(bbox)
                  self.bboxes.append(bbox)
                  # Check for canvas overflow
                  if bbox.x < 0 or bbox.y < 0 or bbox.x + bbox.w > self.width or bbox.y + bbox.h > self.height:
                      print(f"Warning: Element at ({bbox.x:.1f}, {bbox.y:.1f}) exceeds canvas boundaries.")
      
          def group(self, transform):
              """Open a transform context: nodes/bboxes/edges drawn inside are stored
              in absolute canvas coordinates.
      
              Usage:
                  with drawer.group("translate(100, 50)"):
                      drawer.rect(0, 0, 40, 30, node_id="a")  # registered at abs (100,50)
              Supports matrix()/translate()/scale()/rotate()/skewX()/skewY(), chained.
              """
              local = parse_transform(transform)
              parent = self._current_matrix
              self._matrix_stack.append(multiply_matrix(parent, local))
              self.elements.append(f'<g transform="{transform}">')
              return _GroupContext(self)
          def add_def(self, def_content):
              self.defs.append(def_content)
      
          def _record_color(self, *colors):
              """Record each color (fill/stroke) as a normalized accent color.
      
              Neutrals (white/black/gray) and unparseable/none values are skipped so
              the palette check only counts genuine accent colors.
              """
              for c in colors:
                  norm = normalize_color(c)
                  if norm is not None and not is_neutral(norm):
                      self.accent_colors.add(norm)
      
          def set_background(self, color):
              """Set (or replace) the canvas background color.
      
              A full-canvas rect is kept at the bottom of the element stack so it
              always paints first. Defaults to white; pass a dark color only when the
              diagram is intentionally a dark theme (then opt in via this call so the
              palette check doesn't warn about a non-light background).
              """
              norm = normalize_color(color) or "#ffffff"
              self.background = norm
              bg_rect = (f'<rect x="0" y="0" width="{self.width}" height="{self.height}" '
                         f'fill="{norm}" stroke="none" />')
              if self._bg_index is None:
                  self.elements.insert(0, bg_rect)
                  self._bg_index = 0
              else:
                  self.elements[self._bg_index] = bg_rect
      
          # ------------------------------------------------------------------
          # Semantic registration (for connection validation)
          # ------------------------------------------------------------------
          def register_node(self, node_id, x, y, w, h, kind="op", visible=True, role="node"):
              m = self._current_matrix
              if m != IDENTITY:
                  corners = [(x, y), (x + w, y), (x, y + h), (x + w, y + h)]
                  pts = [transform_point(m, c) for c in corners]
                  xs = [p[0] for p in pts]; ys = [p[1] for p in pts]
                  x, y = min(xs), min(ys)
                  w, h = max(xs) - x, max(ys) - y
              node = Node(node_id, x, y, w, h, kind=kind, visible=visible, role=role)
              self.nodes[node_id] = node
              return node
      
          def register_edge(self, start, end, edge_id=None, has_arrow=True, label=None, path_d=None, role="edge"):
              m = self._current_matrix
              if m != IDENTITY:
                  start = transform_point(m, start)
                  end = transform_point(m, end)
              if edge_id is None:
                  edge_id = f"edge_{self._edge_seq}"
                  self._edge_seq += 1
              edge = Edge(start, end, edge_id=edge_id, has_arrow=has_arrow, label=label, path_d=path_d, role=role)
              self.edges.append(edge)
              return edge
          def _record_rebuild(self, node, start, bbox_idx, had_bbox,
                              rebuild_xml, rebuild_bbox):
              """Register post-emit relocation for a node drawn outside any group.
      
              Only when the current transform is identity (a top-level node): inside
              a group() coords are local, so absolute-xy relocation is meaningless.
              """
              if self._current_matrix != IDENTITY:
                  return
              node._emit_range = (start, len(self.elements))
              node._bbox_index = bbox_idx if had_bbox else None
              node._rebuild_xml = rebuild_xml
              node._rebuild_bbox = rebuild_bbox
      
          def relocate_node(self, node_id, new_x, new_y):
              """Move an already-drawn node to new top-left coords.
      
              Re-emits the node's shape XML, updates its collision bbox, and
              re-routes every connect()-built edge anchored on it. Critically, it
              also refreshes each re-routed edge's registry fields (start/end/
              path_d): the evaluator's connection/crossing/routing checks read
              `drawer.edges[*]`, NOT the re-parsed SVG, so a re-emit that left the
              registry stale would report phantom dangles and undermine the fix.
      
              Returns True if relocated, False if the node is unknown, was drawn
              inside a group, belongs to a shape without rebuild support, OR has a
              connect()-built edge that cannot be re-routed (e.g. the edge was
              drawn inside a group, so its `_rebuild_xml` is None). The check is
              atomic: if ANY anchored edge can't follow the node, the whole move is
              refused rather than leaving the node moved and its edges stale (a
              half-applied relocate would dangle).
      
              Unlike mutating node.x/y directly (which the baked-in emit ignores),
              this updates the actual rendered SVG — use it from auto_refine or any
              post-emit layout adjustment.
              """
              node = self.nodes.get(node_id)
              if node is None or node._rebuild_xml is None:
                  return False
              # Atomicity guard: refuse if any edge anchored here can't be re-routed.
              for edge in self.edges:
                  if node_id in (edge.from_id, edge.to_id) and edge._rebuild_xml is None:
                      return False
              s, e = node._emit_range
              new_elems = node._rebuild_xml(new_x, new_y)
              # A rebuild MUST emit exactly as many elements as it replaces, or every
              # later _emit_range index (other nodes', edges') silently shifts and
              # corrupts the element list.
              if len(new_elems) != e - s:
                  raise RuntimeError(
                      f"relocate_node('{node_id}'): node rebuild emitted "
                      f"{len(new_elems)} elements, expected {e - s}")
              self.elements[s:e] = new_elems
              if node._bbox_index is not None:
                  self.bboxes[node._bbox_index] = node._rebuild_bbox(new_x, new_y)
              node.x, node.y = new_x, new_y
              # Re-route edges anchored on this node: re-emit their XML AND refresh
              # the registry so evaluate_svg's connection checks see the new coords.
              for edge in self.edges:
                  if edge._rebuild_xml is None:
                      continue
                  if node_id in (edge.from_id, edge.to_id):
                      es, ee = edge._emit_range
                      new_xmls, (nstart, nend, npath_d) = edge._rebuild_xml()
                      if len(new_xmls) != ee - es:
                          raise RuntimeError(
                              f"relocate_node('{node_id}'): edge '{edge.id}' rebuild "
                              f"emitted {len(new_xmls)} elements, expected {ee - es}")
                      self.elements[es:ee] = new_xmls
                      edge.start, edge.end, edge.path_d = nstart, nend, npath_d
              return True
      
          def nearest_node(self, px, py, kinds=None):
              """Return (node, distance) for the closest registered node border."""
              best_node, best_dist = None, float("inf")
              for node in self.nodes.values():
                  if kinds is not None and node.kind not in kinds:
                      continue
                  d = node.border_distance(px, py)
                  if d < best_dist:
                      best_dist, best_node = d, node
              return best_node, best_dist
      
          # ------------------------------------------------------------------
          # Primitives (rendering + optional semantic registration)
          # ------------------------------------------------------------------
          def _rect_xml(self, x, y, w, h, rx, ry, fill, stroke, stroke_width,
                        opacity, id_attr, extra, attrs):
              return (f'<rect {id_attr} x="{x}" y="{y}" width="{w}" height="{h}" '
                      f'rx="{rx}" ry="{ry}" fill="{fill}" stroke="{stroke}" '
                      f'stroke-width="{stroke_width}" fill-opacity="{opacity}"{attrs} {extra} />')
      
          def rect(self, x, y, w, h, rx=5, ry=5, fill="white", stroke="black",
                   stroke_width=1, opacity=1, id=None, extra="", dashed=False,
                   node_id=None, node_kind="op", bbox=True, role=None):
              """Draw a rectangle.
      
              node_id: if given, also register a Node at these coords so edges can be
                       validated against it. No bbox collision tracking is added by
                       default for registered nodes (set bbox=True to also track it).
              role: semantic role for the validator ('node'|'decoration'|'legend'|
                    'label'|'background'). When set, emitted as data-graph-role and
                    stored on the node; decorative/legend roles skip business checks.
              """
              id_attr = f'id="{id}"' if id else ""
              extra = " ".join(filter(None, [_dash_attr(dashed), extra]))
              # Semantic identity emitted into the SVG so downstream checkers can
              # map rendered geometry back to declared roles (data-graph-role for
              # check filtering, data-node-id for brief-contract attribution).
              attrs = (f' data-graph-role="{role}"' if role else "") + \
                      (f' data-node-id="{node_id}"' if node_id else "")
              start, bbox_idx = len(self.elements), len(self.bboxes)
              self.add_element(
                  self._rect_xml(x, y, w, h, rx, ry, fill, stroke, stroke_width,
                                 opacity, id_attr, extra, attrs),
                  BBox(x, y, w, h) if bbox else None,
              )
              self._record_color(fill, stroke)
              if node_id:
                  visible = (_shape_visible(fill, stroke, opacity) and w > 0 and h > 0)
                  node = self.register_node(node_id, x, y, w, h, kind=node_kind, visible=visible, role=role or "node")
                  self._record_rebuild(
                      node, start, bbox_idx, bbox,
                      lambda nx, ny: [self._rect_xml(
                          nx, ny, w, h, rx, ry, fill, stroke, stroke_width,
                          opacity, id_attr, extra, attrs)],
                      lambda nx, ny: BBox(nx, ny, w, h))
      
          def text(self, x, y, content, font_size=14, font_family="Arial, sans-serif",
                   fill="black", anchor="middle", weight="normal", style="normal",
                   id=None, extra="", bbox=True):
              content_esc = html.escape(content)
              id_attr = f'id="{id}"' if id else ""
              # Width estimate: 0.55em per ASCII glyph (0.62 bold), 1.0em per CJK glyph.
              # This matches evaluator._estimate_text_width so rendering & checks agree.
              coef = 0.62 if weight == "bold" else 0.55
              w = sum(font_size * (1.0 if ord(c) > 0x2E80 else coef) for c in content)
              h = font_size
              tx = x - w / 2 if anchor == "middle" else (x - w if anchor == "end" else x)
              ty = y - h / 2
              # dominant-baseline="central" vertically centers on (x,y) exactly,
              # replacing the old y+0.35*fs approximation (ink-graph convention).
              self.add_element(
                  f'<text {id_attr} x="{x}" y="{y}" font-family="{font_family}" '
                  f'font-size="{font_size}" fill="{fill}" text-anchor="{anchor}" '
                  f'dominant-baseline="central" font-weight="{weight}" font-style="{style}" {extra}>{content_esc}</text>',
                  BBox(tx, ty, w, h) if bbox else None,
              )
              self.font_sizes.append(font_size)
              self._record_color(fill)
      
          def multiline_text(self, x, y, lines, font_size=14, line_height=1.2,
                             font_family="Arial, sans-serif", fill="black",
                             anchor="middle", weight="normal"):
              for i, line in enumerate(lines):
                  dy = i * font_size * line_height
                  self.text(x, y + dy, line, font_size, font_family, fill, anchor, weight)
          def formula(self, x, y, markup, font_size=14,
                      font_family="Consolas, 'Courier New', monospace", fill="black",
                      anchor="middle", weight="bold", bbox=False):
              """Render a formula with real sub/superscripts via <tspan> baseline shifts.
      
              Markup syntax:  _{...} -> subscript,  ^{...} -> superscript.
              Example: "F_{k} = MS^{↑}_{k} + g_{k}" renders F with subscript k, etc.
              Unlike text() (which HTML-escapes content and can only show literal
              underscores/carets), this emits genuine <tspan dy=... font-size=...>
              elements so sub/superscripts display correctly (down/up-shifted,
              ~0.72x smaller). The baseline auto-resets after each token so multiple
              sub/superscripts in one formula align properly.
      
              bbox: when True, registers a collision bbox (width estimate strips
                    tspan markup, matching evaluator._estimate_text_width). Off by
                    default since formulas are usually inline annotations.
              """
              SUB = font_size * 0.30   # subscript baseline shift (down)
              SUP = -font_size * 0.38  # superscript baseline shift (up)
              SSZ = font_size * 0.72   # sub/superscript glyph size
              parts = []
              baseline = 0.0
              for tok in _re.split(r'(_\{[^}]*\}|\^\{[^}]*\})', markup):
                  if not tok:
                      continue
                  if tok.startswith('_{'):
                      content, target, fs = tok[2:-1], SUB, SSZ
                  elif tok.startswith('^{'):
                      content, target, fs = tok[2:-1], SUP, SSZ
                  else:
                      content, target, fs = tok, 0.0, font_size
                  dy = target - baseline  # relative shift from current position
                  baseline = target
                  parts.append(
                      f'<tspan dy="{dy:.2f}" font-size="{fs:.2f}">{html.escape(content)}</tspan>')
              body = ''.join(parts)
              box = None
              if bbox:
                  visible = _re.sub(r'<[^>]+>', '', body)
                  coef = 0.62 if weight == "bold" else 0.55
                  w = sum(font_size * (1.0 if ord(c) > 0x2E80 else coef) for c in visible)
                  tx = x - w / 2 if anchor == "middle" else (x - w if anchor == "end" else x)
                  box = BBox(tx, y - font_size / 2, w, font_size)
              self.add_element(
                  f'<text x="{x}" y="{y}" font-family="{font_family}" font-size="{font_size}" '
                  f'fill="{fill}" text-anchor="{anchor}" dominant-baseline="central" '
                  f'font-weight="{weight}">{body}</text>', box)
              self.font_sizes.append(font_size)
              self._record_color(fill)
      
          def _circle_xml(self, cx, cy, r, fill, stroke, stroke_width,
                          opacity, id_attr, extra, attrs):
              return (f'<circle {id_attr} cx="{cx}" cy="{cy}" r="{r}" fill="{fill}" '
                      f'stroke="{stroke}" stroke-width="{stroke_width}" fill-opacity="{opacity}"{attrs} {extra} />')
      
          def circle(self, cx, cy, r, fill="white", stroke="black", stroke_width=1,
                     opacity=1, id=None, node_id=None, node_kind="junction", bbox=False,
                     extra="", dashed=False, role=None):
              """Draw a circle. When node_id is given, also register a square Node of
              side 2r centered at (cx, cy) so edges can snap to its border (the node
              approximates the circle for connection validation; endpoints landing at
              the center register distance 0)."""
              id_attr = f'id="{id}"' if id else ""
              extra = " ".join(filter(None, [_dash_attr(dashed), extra]))
              attrs = (f' data-graph-role="{role}"' if role else "") + \
                      (f' data-node-id="{node_id}"' if node_id else "")
              start, bbox_idx = len(self.elements), len(self.bboxes)
              self.add_element(
                  self._circle_xml(cx, cy, r, fill, stroke, stroke_width,
                                   opacity, id_attr, extra, attrs),
                  BBox(cx - r, cy - r, 2 * r, 2 * r) if bbox else None,
              )
              self._record_color(fill, stroke)
              if node_id:
                  visible = (_shape_visible(fill, stroke, opacity) and r > 0)
                  node = self.register_node(node_id, cx - r, cy - r, 2 * r, 2 * r, kind=node_kind, visible=visible, role=role or "node")
                  self._record_rebuild(
                      node, start, bbox_idx, bbox,
                      lambda nx, ny: [self._circle_xml(
                          nx + r, ny + r, r, fill, stroke, stroke_width,
                          opacity, id_attr, extra, attrs)],
                      lambda nx, ny: BBox(nx, ny, 2 * r, 2 * r))
      
          def database(self, x, y, w, h, fill="white", stroke="black", stroke_width=1,
                       opacity=1, id=None, node_id=None, node_kind="op", bbox=False,
                       extra="", role=None, label=None):
              """Cylinder (database) shape. Top ellipse depth = min(8, h*0.12)."""
              depth = min(8, h * 0.12)
              ra = f' data-graph-role="{role}"' if role else ""
              na = f' data-node-id="{node_id}"' if node_id else ""
              id_attr = f'id="{id}"' if id else ""
              body = (f'M 0,{depth} A {w/2},{depth} 0 0 1 {w},{depth} '
                      f'L {w},{h-depth} A {w/2},{depth} 0 0 1 0,{h-depth} Z')
              top = f'M 0,{depth} A {w/2},{depth} 0 0 0 {w},{depth}'
              self.add_element(
                  f'<g transform="translate({x},{y})" {id_attr}>'
                  f'<path d="{body}" fill="{fill}" stroke="{stroke}" stroke-width="{stroke_width}" '
                  f'fill-opacity="{opacity}"{ra}{na} {extra}/>'
                  f'<path d="{top}" fill="none" stroke="{stroke}" stroke-width="{stroke_width}"{ra}/>'
                  f'</g>', BBox(x, y, w, h) if bbox else None)
              self._record_color(fill, stroke)
              if node_id:
                  self.register_node(node_id, x, y, w, h, kind=node_kind,
                                     visible=_shape_visible(fill, stroke, opacity), role=role or "node")
              if label:
                  self.text(x + w / 2, y + h / 2, label, 12, anchor="middle")
      
          def decision(self, x, y, w, h, fill="white", stroke="black", stroke_width=1,
                       opacity=1, id=None, node_id=None, node_kind="op", bbox=False,
                       extra="", role=None, label=None):
              """Diamond (decision) shape. Four points around center."""
              ra = f' data-graph-role="{role}"' if role else ""
              na = f' data-node-id="{node_id}"' if node_id else ""
              id_attr = f'id="{id}"' if id else ""
              pts = f'{w/2},0 {w},{h/2} {w/2},{h} 0,{h/2}'
              self.add_element(
                  f'<g transform="translate({x},{y})" {id_attr}>'
                  f'<polygon points="{pts}" fill="{fill}" stroke="{stroke}" stroke-width="{stroke_width}" '
                  f'fill-opacity="{opacity}"{ra}{na} {extra}/></g>',
                  BBox(x, y, w, h) if bbox else None)
              self._record_color(fill, stroke)
              if node_id:
                  self.register_node(node_id, x, y, w, h, kind=node_kind,
                                     visible=_shape_visible(fill, stroke, opacity), role=role or "node")
              if label:
                  self.text(x + w / 2, y + h / 2, label, 12, anchor="middle")
      
          def hexagon(self, x, y, w, h, fill="white", stroke="black", stroke_width=1,
                      opacity=1, id=None, node_id=None, node_kind="op", bbox=False,
                      extra="", role=None, label=None):
              """Hexagon (gateway) with 25% corner insets."""
              ra = f' data-graph-role="{role}"' if role else ""
              na = f' data-node-id="{node_id}"' if node_id else ""
              id_attr = f'id="{id}"' if id else ""
              pts = f'{w*0.25},0 {w*0.75},0 {w},{h/2} {w*0.75},{h} {w*0.25},{h} 0,{h/2}'
              self.add_element(
                  f'<g transform="translate({x},{y})" {id_attr}>'
                  f'<polygon points="{pts}" fill="{fill}" stroke="{stroke}" stroke-width="{stroke_width}" '
                  f'fill-opacity="{opacity}"{ra}{na} {extra}/></g>',
                  BBox(x, y, w, h) if bbox else None)
              self._record_color(fill, stroke)
              if node_id:
                  self.register_node(node_id, x, y, w, h, kind=node_kind,
                                     visible=_shape_visible(fill, stroke, opacity), role=role or "node")
              if label:
                  self.text(x + w / 2, y + h / 2, label, 12, anchor="middle")
      
          def component(self, x, y, w, h, fill="white", stroke="black", stroke_width=1,
                        opacity=1, id=None, node_id=None, node_kind="op", bbox=False,
                        extra="", role=None, label=None):
              """Component box with two small tabs on the left edge."""
              ra = f' data-graph-role="{role}"' if role else ""
              na = f' data-node-id="{node_id}"' if node_id else ""
              id_attr = f'id="{id}"' if id else ""
              tab1 = f'<rect x="-8" y="{h*0.3}" width="16" height="8" rx="1" fill="{fill}" stroke="{stroke}" stroke-width="{stroke_width}"{ra}/>'
              tab2 = f'<rect x="-8" y="{h*0.55}" width="16" height="8" rx="1" fill="{fill}" stroke="{stroke}" stroke-width="{stroke_width}"{ra}/>'
              # Tabs protrude 8px LEFT of the box (x="-8" inside the translate group),
              # so the collision bbox must extend left by 8 to catch a left-side
              # neighbor — otherwise a component abutting another node on its left
              # would skip the collision check.
              self.add_element(
                  f'<g transform="translate({x},{y})" {id_attr}>'
                  f'<rect width="{w}" height="{h}" rx="4" ry="4" fill="{fill}" stroke="{stroke}" '
                  f'stroke-width="{stroke_width}" fill-opacity="{opacity}"{ra}{na} {extra}/>'
                  f'{tab1}{tab2}</g>', BBox(x - 8, y, w + 8, h) if bbox else None)
              self._record_color(fill, stroke)
              if node_id:
                  self.register_node(node_id, x, y, w, h, kind=node_kind,
                                     visible=_shape_visible(fill, stroke, opacity), role=role or "node")
              if label:
                  self.text(x + w / 2, y + h / 2, label, 12, anchor="middle")
      
          def cloud(self, x, y, w, h, fill="white", stroke="black", stroke_width=1,
                    opacity=1, id=None, node_id=None, node_kind="op", bbox=False,
                    extra="", role=None, label=None):
              """Multi-lobe cloud built from cubic curves (ink-graph shape #9)."""
              ra = f' data-graph-role="{role}"' if role else ""
              na = f' data-node-id="{node_id}"' if node_id else ""
              id_attr = f'id="{id}"' if id else ""
              d = (f'M {w*0.22},{h*0.68} C {w*0.10},{h*0.68} 0,{h*0.58} 0,{h*0.46} '
                   f'C 0,{h*0.34} {w*0.10},{h*0.24} {w*0.22},{h*0.24} '
                   f'C {w*0.27},{h*0.10} {w*0.40},0 {w*0.55},0 '
                   f'C {w*0.68},0 {w*0.80},{h*0.08} {w*0.86},{h*0.20} '
                   f'C {w*0.95},{h*0.20} {w},{h*0.30} {w},{h*0.40} '
                   f'C {w},{h*0.54} {w*0.89},{h*0.66} {w*0.76},{h*0.66} '
                   f'C {w*0.70},{h*0.76} {w*0.58},{h*0.82} {w*0.46},{h*0.80} '
                   f'C {w*0.37},{h*0.84} {w*0.27},{h*0.80} {w*0.22},{h*0.68} Z')
              self.add_element(
                  f'<g transform="translate({x},{y})" {id_attr}>'
                  f'<path d="{d}" fill="{fill}" stroke="{stroke}" stroke-width="{stroke_width}" '
                  f'fill-opacity="{opacity}"{ra}{na} {extra}/></g>',
                  BBox(x, y, w, h) if bbox else None)
              self._record_color(fill, stroke)
              if node_id:
                  self.register_node(node_id, x, y, w, h, kind=node_kind,
                                     visible=_shape_visible(fill, stroke, opacity), role=role or "node")
              if label:
                  self.text(x + w / 2, y + h * 0.5, label, 12, anchor="middle")
      
          def line(self, x1, y1, x2, y2, stroke="black", stroke_width=1, marker_end=None,
                   edge_id=None, register_edge=False, edge_label=None, bbox=False, extra="",
                   dashed=False, role=None):
              """Draw a straight line. Register as an Edge when register_edge=True."""
              role_attr = f' data-graph-role="{role}"' if role else ""
              extra = " ".join(filter(None, [_dash_attr(dashed), extra]))
              marker = f'marker-end="url(#{marker_end})"' if marker_end else ""
              self.add_element(
                  f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" '
                  f'stroke-width="{stroke_width}" {marker}{role_attr} {extra} />',
                  BBox(min(x1, x2), min(y1, y2), abs(x2 - x1) or 1, abs(y2 - y1) or 1) if bbox else None,
              )
              self._record_color(stroke)
              if register_edge:
                  self.register_edge((x1, y1), (x2, y2), edge_id=edge_id,
                                     has_arrow=marker_end is not None, label=edge_label, role=role or "edge")
      
          def path(self, d, fill="none", stroke="black", stroke_width=1, marker_end=None,
                   edge_id=None, register_edge=False, start=None, end=None,
                   edge_label=None, bbox=None, extra="", dashed=False, role=None):
              """Draw an SVG path.
      
              For connection validation pass start/end (the semantic endpoints) plus
              register_edge=True. The visible `d` may describe a curve; start/end are
              what the validator checks against node borders.
              """
              role_attr = f' data-graph-role="{role}"' if role else ""
              extra = " ".join(filter(None, [_dash_attr(dashed), extra]))
              marker = f'marker-end="url(#{marker_end})"' if marker_end else ""
              self.add_element(
                  f'<path d="{d}" fill="{fill}" stroke="{stroke}" '
                  f'stroke-width="{stroke_width}" {marker}{role_attr} {extra} />',
                  bbox,
              )
              self._record_color(fill, stroke)
              if register_edge:
                  if start is None or end is None:
                      raise ValueError("register_edge=True requires start and end points")
                  self.register_edge(start, end, edge_id=edge_id,
                                     has_arrow=marker_end is not None, label=edge_label, path_d=d, role=role or "edge")
      
          # ------------------------------------------------------------------
          # Connection helpers (snap endpoints to node borders)
          def _edge_xml(self, start, end, stroke, stroke_width, marker_end,
                        dashed, as_curve, curve_dir, role):
              """Generate a connect()-built edge's line/path XML (no side effects).
      
              Shared by connect() and edge relocate-rebuild so a re-routed edge is
              byte-identical to a freshly drawn one. Returns (xml, path_d_or_None).
              """
              extra = _dash_attr(dashed)
              role_attr = f' data-graph-role="{role}"' if role else ""
              marker = f'marker-end="url(#{marker_end})"' if marker_end else ""
              if marker_end:
                  depth = self.marker_tip_depth(marker_end, stroke_width)
                  dx, dy = end[0] - start[0], end[1] - start[1]
                  seglen = math.hypot(dx, dy) or 1.0
                  ux, uy = dx / seglen, dy / seglen
                  draw_end = (end[0] - ux * depth, end[1] - uy * depth)
              else:
                  draw_end = end
              if as_curve:
                  mx = (start[0] + end[0]) / 2.0
                  if curve_dir == "left":
                      mx = min(start[0], end[0]) - 60
                  elif curve_dir == "right":
                      mx = max(start[0], end[0]) + 60
                  d = (f"M{start[0]},{start[1]} C{mx},{start[1]} {mx},{draw_end[1]} "
                       f"{draw_end[0]},{draw_end[1]}")
                  xml = (f'<path d="{d}" fill="none" stroke="{stroke}" '
                         f'stroke-width="{stroke_width}" {marker}{role_attr} {extra} />')
                  return xml, d
              xml = (f'<line x1="{start[0]}" y1="{start[1]}" x2="{draw_end[0]}" '
                     f'y2="{draw_end[1]}" stroke="{stroke}" stroke-width="{stroke_width}" '
                     f'{marker}{role_attr} {extra} />')
              return xml, None
      
          # Port-spread constants (see _apply_port_spread): deterministic fan-out
          # of same-side connect() endpoints, so N edges leaving one node side do
          # not stack on the single border midpoint.
          PORT_SPREAD_GUTTER = 16.0     # px reserved at each end of a border side
          PORT_SPREAD_MAX_SPACING = 14.0  # px cap between adjacent spread ports
      
          def _port_group(self, node_id, side):
              """All top-level connect() edges attached to (node_id, side)."""
              return [e for e in self.edges
                      if e._rebuild_xml is not None
                      and ((e.from_id == node_id and e.from_side == side)
                           or (e.to_id == node_id and e.to_side == side))]
      
          def _apply_port_spread(self, node_id, side):
              """Deterministically spread same-side edge endpoints along a border.
      
              When several connect() edges share one node side, their endpoints
              would all land on the border midpoint and render as one stacked
              line (flagged downstream as duplicate edges). Instead, order the
              edges by their counterpart node's position along the border, then
              offset each endpoint symmetrically around the midpoint:
      
                  usable  = side_length - 2*PORT_SPREAD_GUTTER
                  spacing = min(PORT_SPREAD_MAX_SPACING, usable / (n - 1))
                  offset_i = (i - (n-1)/2) * spacing
      
              Edges on a side shorter than 2*gutter (spacing <= 0) keep midpoints.
              Each affected edge is re-emitted through its own _rebuild_xml (the
              same mechanism relocate_node uses), so the rendered SVG and the
              Edge registry stay in sync. Group-drawn edges are skipped (their
              coordinates are local; same capability boundary as relocate_node).
              """
              group = self._port_group(node_id, side)
              if len(group) < 2:
                  return
              node = self.nodes[node_id]
              vertical = side in ("left", "right")   # offset runs along y
              extent = node.h if vertical else node.w
      
              def sort_key(e):
                  other_id = e.to_id if e.from_id == node_id else e.from_id
                  other = self.nodes.get(other_id)
                  along = (other.cy if vertical else other.cx) if other else 0.0
                  return (along, e.id or "")
      
              group.sort(key=sort_key)
              usable = extent - 2.0 * self.PORT_SPREAD_GUTTER
              if len(group) > 1:
                  spacing = min(self.PORT_SPREAD_MAX_SPACING, usable / (len(group) - 1))
              else:
                  spacing = 0.0
              if spacing <= 0:
                  return
              for i, e in enumerate(group):
                  e._spread_offsets = {**e._spread_offsets,
                                       (node_id, side): (i - (len(group) - 1) / 2.0) * spacing}
                  self._reemit_edge(e)
      
          def _reemit_edge(self, edge):
              """Re-run an edge's _rebuild_xml and splice the result in place."""
              if edge._rebuild_xml is None or edge._emit_range is None:
                  return
              s, t = edge._emit_range
              xmls, (nstart, nend, npath_d) = edge._rebuild_xml()
              if len(xmls) != t - s:
                  raise RuntimeError(
                      f"_reemit_edge('{edge.id}'): rebuild emitted {len(xmls)} "
                      f"elements, expected {t - s}")
              self.elements[s:t] = xmls
              edge.start, edge.end, edge.path_d = nstart, nend, npath_d
          def connect(self, from_id, from_side, to_id, to_side,
                      stroke="black", stroke_width=1.5, marker_end="arrowhead",
                      edge_id=None, edge_label=None, as_curve=False, curve_dir=None,
                      dashed=False, role=None):
              """Draw a clean connection between two registered nodes.
      
              Endpoints are taken from the nodes' border midpoints, so the rendered
              line/arrow always lands exactly on a node edge. The connection is
              registered for validation automatically. Pass dashed=True for a
              stroke-dasharray style (e.g. lowering/bypass flows).
      
              Returns ``(start, end)`` — the two border-midpoint coords the edge was
              drawn between (useful for placing edge labels or chaining geometry).
              Edges drawn outside any group() are re-routable by relocate_node();
              edges drawn inside a group are not (their coords are local).
              """
              if from_id not in self.nodes:
                  raise KeyError(f"Unknown source node: {from_id!r}")
              if to_id not in self.nodes:
                  raise KeyError(f"Unknown target node: {to_id!r}")
              start = self.nodes[from_id].edge_point(from_side)
              end = self.nodes[to_id].edge_point(to_side)
              estart = len(self.elements)
              xml, path_d = self._edge_xml(start, end, stroke, stroke_width,
                                           marker_end, dashed, as_curve, curve_dir, role)
              self.add_element(xml, None)
              self._record_color(stroke)
              edge = self.register_edge(start, end, edge_id=edge_id,
                                        has_arrow=marker_end is not None,
                                        label=edge_label, path_d=path_d, role=role or "edge")
              edge.from_id, edge.from_side = from_id, from_side
              edge.to_id, edge.to_side = to_id, to_side
              if self._current_matrix == IDENTITY:
                  edge._emit_range = (estart, len(self.elements))
      
                  def _edge_rebuild():
                      # Recompute endpoints from the nodes' live border midpoints so
                      # a re-route after relocate_node() lands on the moved border.
                      # Same-port spread offsets (assigned by _apply_port_spread)
                      # are folded in so rebuilds preserve the fan-out.
                      # Returns (xml_list, (start, end, path_d)) so the caller can
                      # refresh both the rendered SVG and the Edge registry.
                      rs = self.nodes[from_id].edge_point(
                          from_side, edge._spread_offsets.get((from_id, from_side), 0.0))
                      re_ = self.nodes[to_id].edge_point(
                          to_side, edge._spread_offsets.get((to_id, to_side), 0.0))
                      rxml, rpath_d = self._edge_xml(
                          rs, re_, stroke, stroke_width, marker_end, dashed,
                          as_curve, curve_dir, role)
                      return [rxml], (rs, re_, rpath_d)
      
                  edge._rebuild_xml = _edge_rebuild
                  # Same-port spread: recompute the fan-out for both endpoint
                  # groups this edge joined, so every sibling edge is re-emitted
                  # with its assigned offset. No-op for the first edge on a side.
                  self._apply_port_spread(from_id, from_side)
                  self._apply_port_spread(to_id, to_side)
              return start, end
      
          # ------------------------------------------------------------------
          # Arrow markers / collision / render
          # ------------------------------------------------------------------
          def arrow_head(self, id="arrowhead", color="black", marker_width=10,
                         marker_height=7, ref_x=9, ref_y=3.5):
              """Register an arrow marker and record its geometry.
      
              The recorded (marker_width, ref_x) lets connect() derive the exact tip
              retraction per edge so the arrow tip sits on the target border.
              """
              self.markers[id] = (marker_width, marker_height, ref_x)
              self.add_def(f'''
              <marker id="{id}" markerWidth="{marker_width}" markerHeight="{marker_height}" refX="{ref_x}" refY="{ref_y}" orient="auto">
                  <polygon points="0 0, {marker_width} {ref_y}, 0 {marker_height}" fill="{color}" />
              </marker>''')
              self._record_color(color)
      
          def marker_tip_depth(self, marker_id, stroke_width):
              """Pixels the arrow tip protrudes beyond the line endpoint.
      
              With markerUnits=strokeWidth (the SVG default), the marker is scaled by
              stroke_width; the tip sits (markerWidth - refX) marker-units past the
              endpoint. Retracting the endpoint by this much lands the tip exactly on
              the target border. Falls back to self.marker_depth for unknown markers.
              """
              spec = self.markers.get(marker_id)
              if spec is None:
                  return self.marker_depth
              marker_width, _marker_height, ref_x = spec
              return (marker_width - ref_x) * max(stroke_width, 0.0)
      
          def check_collisions(self):
              """Containment-aware overlap check.
      
              Two bboxes "collide" only when they intersect AND neither fully contains
              the other. Parent/child nesting (Module > Layer > Block > op) is normal
              in architecture diagrams and must NOT count as a collision.
              """
              collisions = []
              for i in range(len(self.bboxes)):
                  for j in range(i + 1, len(self.bboxes)):
                      a, b = self.bboxes[i], self.bboxes[j]
                      if a.intersects(b) and not (a.contains(b) or b.contains(a)):
                          collisions.append((i, j))
              return collisions
      
          def render(self):
              defs_str = f"<defs>{''.join(self.defs)}</defs>" if self.defs else ""
              return f'''<svg width="{self.width}" height="{self.height}" viewBox="0 0 {self.width} {self.height}" xmlns="http://www.w3.org/2000/svg">
              {defs_str}
              {''.join(self.elements)}
              </svg>'''
      
      
      class _GroupContext:
          """Context manager that closes a <g> opened by SVGDrawer.group().
      
          On exit it appends '</g>', restores the parent transform on the matrix
          stack, so registrations after the `with` block use absolute coords again.
          """
      
          def __init__(self, drawer):
              self._drawer = drawer
      
          def __enter__(self):
              return self._drawer
      
          def __exit__(self, exc_type, exc_val, exc_tb):
              self._drawer.elements.append('</g>')
              if len(self._drawer._matrix_stack) > 1:
                  self._drawer._matrix_stack.pop()
              return False
      def save_svg(content, filename):
          """Write SVG *content* to *filename*, creating parent directories as needed.
      
          Returns the resolved absolute path written. The library writes wherever the
          caller asks — there is no enforced output directory. A previous version
          required an ``output/<task>/`` layout; that was removed for the public
          release because it refused legitimate temp and cross-project paths.
          """
          path = Path(filename).resolve()
          path.parent.mkdir(parents=True, exist_ok=True)
          with open(path, "w", encoding="utf-8") as f:
              f.write(content)
          return str(path)
      
      
      def rasterize_svg(svg_path, png_path, width):
          """Rasterize an SVG to PNG via rsvg-convert.
      
          Equivalent to ``rsvg-convert -w <width> <svg_path> -o <png_path>``.
          Creates the parent directory if missing. Returns the resolved PNG path.
          """
          out = Path(png_path).resolve()
          out.parent.mkdir(parents=True, exist_ok=True)
          subprocess.run(
              ["rsvg-convert", "-w", str(width), str(svg_path), "-o", str(out)],
              check=True,
          )
          return str(out)
      
      
      # ----------------------------------------------------------------------
      # Layout helpers (pure geometry — compute coords BEFORE drawing)
      # ----------------------------------------------------------------------
      # SVGDrawer bakes coordinates into the emitted string at draw time, so layout
      # must be computed up front and passed to the primitives. Mutating node
      # coords after drawing (the approach auto_refine took) does NOT update the
      # rendered SVG — see the layout-first pattern below.
      
      def _angle_to_side(angle_deg):
          """Pick (neighbor_side, hub_side) for connect() from a neighbor's angle.
      
          Angles are SVG-space degrees (0=+x/right, 90=+y/down). Returns the side
          of the neighbor that faces the hub, and the side of the hub that faces
          the neighbor, so connect() arrows land cleanly.
          """
          a = ((angle_deg + 180) % 360) - 180  # normalize to [-180, 180)
          if -45 <= a < 45:
              return "left", "right"     # neighbor is right of hub
          if 45 <= a < 135:
              return "top", "bottom"     # neighbor is below hub
          if a >= 135 or a < -135:
              return "right", "left"     # neighbor is left of hub
          return "bottom", "top"         # neighbor is above hub (-135..-45)
      
      
      def layout_radial(hub, neighbors, center, radius, start_angle=-90.0):
          """Radial (star) layout: hub at center, neighbors evenly on a circle.
      
          Pure geometry — returns top-left coords + connect() sides; the caller
          draws. A hub with N neighbors fanned evenly has **zero edge crossings**
          by construction (every edge is hub<->neighbor). Ideal for message-bus /
          gateway / load-balancer topologies where one central node talks to many.
      
          Args:
              hub: (id, w, h) of the central node.
              neighbors: list of (id, w, h) for the surrounding nodes.
              center: (cx, cy) canvas point for the hub's center.
              radius: distance from hub center to each neighbor's center.
              start_angle: degrees (0=+x, -90=up) for the first neighbor; others
                  follow at 360/N spacing clockwise.
          Returns:
              (positions, sides) where
                positions = {id: (x, y, w, h)} top-left + size for every node;
                sides     = {neighbor_id: (neighbor_side, hub_side)} best
                            connect() sides, picked from each neighbor's angle.
          """
          hid, hw, hh = hub
          cx, cy = center
          # Reject duplicate ids up front: a neighbor sharing the hub id (or another
          # neighbor's id) would silently overwrite positions[hid] / a prior neighbor,
          # yielding a wrong layout with no diagnostic.
          nids = [nid for nid, _w, _h in neighbors]
          dupes = {hid, }  # hub id is reserved
          for nid in nids:
              if nid in dupes:
                  raise ValueError(
                      f"layout_radial: duplicate node id {nid!r} (a neighbor cannot "
                      f"share the hub id or another neighbor's id)")
              dupes.add(nid)
          positions = {hid: (cx - hw / 2, cy - hh / 2, hw, hh)}
          sides = {}
          n = len(neighbors)
          step = 360.0 / n if n else 0.0
          for i, (nid, nw, nh) in enumerate(neighbors):
              ang = start_angle + i * step
              theta = math.radians(ang)
              ncx = cx + radius * math.cos(theta)
              ncy = cy + radius * math.sin(theta)
              positions[nid] = (ncx - nw / 2, ncy - nh / 2, nw, nh)
              sides[nid] = _angle_to_side(ang)
          return positions, sides
      
      def layout_grid(n, x0, y0, cols, w, h, gx, gy):
          """Rectangular grid: n cells in `cols` columns, left-to-right then
          top-to-bottom. Pure geometry — returns [(x, y), ...] top-left coords;
          the caller draws. Handles the common card-array arithmetic (chips in a
          band, station rows) so the caller states the array, not 40 coordinates.
          """
          if cols < 1:
              raise ValueError("layout_grid: cols must be >= 1")
          return [(x0 + (i % cols) * (w + gx), y0 + (i // cols) * (h + gy))
                  for i in range(n)]
      
      
      def layout_row(items, x0, y0, gx):
          """Single row of variable-size items along x.
      
          items: list of widths. Returns [(x, y0, w), ...] left-to-right with `gx`
          gutters — the arithmetic-free way to lay out a labelled flow
          (source -> queue -> engine -> sink) or a chip row.
          """
          out, x = [], x0
          for wd in items:
              out.append((x, y0, wd))
              x += wd + gx
          return out
      
      def layout_band(title, x, y, w, h, pad=24, title_h=28):
          """Band container geometry with a title slot reserved at the top.
      
          Returns (body_x, body_y, body_w, body_h) — the drawable interior after
          the title strip and padding, so contents laid out inside never collide
          with the band title or edges. Pure geometry; the caller draws the rect.
          `title` is accepted for self-documenting call sites; geometry does not
          depend on it.
          """
          body_x = x + pad
          body_y = y + title_h
          body_w = w - 2 * pad
          body_h = h - title_h - pad
          return body_x, body_y, body_w, body_h
      
  • SKILL.md 45.5 KB
    ---
    name: architecture-drawer
    description: Use when asked to draw system architecture diagrams, generate technical architecture SVGs, or export architecture diagrams to editable PowerPoint presentations. Supports multi-layer diagrams with automatic layout validation and scoring (16-dimension evaluator catches collisions, overlaps, dangles, crossings, palette issues incl. colorless and gray-dominant diagrams, low-contrast labels, misaligned peers, and a Step-1 design-brief contract: the declared palette/layout/flow is asserted against the rendered SVG).
    ---
    
    # SVG Architecture Drawer (Smart Version)
    
    This Skill converts complex technical descriptions into structured SVG architecture diagrams. It integrates layout constraints, collision detection, **connection/arrow connectivity validation**, and quality evaluation, automatically identifying and guiding the correction of layout errors.
    The script directory (referred to as `$SKILL` below) is this skill's own
    `scripts/` folder. From a generator script that lives next to its artifacts,
    resolve it relative to the script's own location (never hard-code an absolute
    path, which breaks on other machines):
    
    ```python
    import os, sys
    _HERE = os.path.dirname(os.path.abspath(__file__))
    # From evals/<name>/gen.py -> ../../scripts ; adjust depth for your layout.
    _SKILL = os.path.normpath(os.path.join(_HERE, "..", "..", "scripts"))
    if _SKILL not in sys.path:
        sys.path.insert(0, _SKILL)
    ```
    
    ## Fast Path (read this first; details below are on-demand reference)
    
    Ordinary generation follows this bounded loop. Do not read the full
    specification sections before the first candidate runs — the checks below
    tell you what to fix, and each section explains its own vocabulary when a
    report line names it.
    
    **Strategy — the evaluator is the oracle, not your own verification.** Do
    not read `scripts/evaluator.py` to internalize every threshold, and do not
    mentally verify the layout against all 16 checks before writing: that
    duplicates work the evaluator does in under a second. Sketch an approximate
    layout with sane coordinates, run `gen.py`, read the report lines, and fix
    exactly what they name (thresholds + repairs are tabulated in
    `references/checks_cheatsheet.md` — read that table instead of the
    evaluator source).
    
    **Do the arithmetic in code, not in your head.** `references/api_quickref.md`
    tabulates every DSL signature + the known traps (which default draws an
    arrow, text-y semantics, container `node_kind`), and the layout helpers
    (`layout_grid` / `layout_row` / `layout_band` / `layout_radial`) compute
    positions from the array spec — state "6 chips, 3 per row, 24px gutters",
    not forty hand-derived coordinates. Start from the skeleton in
    `references/gen_template.md` (constants block for bands/nodes/flow, fixed
    evaluate-export tail) instead of re-deriving the boilerplate.
    
    1. **Write the candidate** — resolve `$SKILL` (snippet above), pick ONE
       palette preset from `references/design_specs.md` (do not invent hex
       values), land the Step-1 design-brief tokens as a constants block atop
       `gen.py`, draw with `drawer.rect/circle/connect` (register `node_id`s so
       connections validate), then immediately run the script. One clear main
       flow beats a dense map; ≤12 primary nodes before the first evaluation.
    2. **Evaluate** — `evaluate_svg(drawer)` prints the score; every `[FAIL]`
       line names the defect class (dangle / route-through / crossing / text
       overlap / contrast / palette). Fix exactly what the lines name; the
       Auto-Correction section maps each tag to its repair.
    3. **Bounded repair** — at most **2 focused correction rounds** on the
       highest-penalty FAILs (call `auto_refine(drawer)` first: gutter and
       spacing fix themselves). If the score reaches ≥80 with no `[FAIL]`
       remaining, ship. If two rounds do not converge, stop and report the
       unresolved `[FAIL]` lines truthfully — never claim success with defects
       outstanding, and never widen the canvas or shrink text to hide them.
    
    ```dot
    digraph fastpath {
        "write candidate" -> "evaluate_svg()" -> "auto_refine + fix FAILs";
        "auto_refine + fix FAILs" -> "evaluate_svg()" [label="round ≤2"];
        "auto_refine + fix FAILs" -> "ship (score≥80, 0 FAIL)" [label="clean"];
        "auto_refine + fix FAILs" -> "report unresolved FAILs truthfully"
            [label="2 rounds spent"];
    }
    ```
    
    ## Step 0 — Intent Judgment (fidelity vs. completion)
    
    Before writing any code, classify the requirement — the two failure modes are
    mirror images: transcribing a vague spec literally produces a broken diagram,
    and "improving" a precise spec produces one the user did not ask for.
    
    **Faithful mode** — the description is clear and detailed (explicit components,
    relations, flow direction, canvas): transcribe it exactly. Do **not** invent
    components, layers, edges, or legend entries the user did not state, and do not
    "upgrade" the palette or topology on your own taste. Adding unrequested boxes
    is a defect, not a feature (画蛇添足).
    
    **Completion mode** — the description is vague (the user may not have a fixed
    picture in mind yet): infer a reasonable design, then state what you inferred.
    Ambiguity signals and the corresponding conservative defaults:
    
    | Missing in the spec | Conservative default |
    |---|---|
    | Relations between named components | connect adjacent tiers only, in the domain's natural flow (client → API → compute → storage) |
    | Canvas / size | 1200×800 (or the diagram-type preset in `references/diagram_types.md`) |
    | Diagram type | pick from `references/diagram_types.md` by content keywords |
    | Layer grouping | group only when the spec's own vocabulary implies it ("… layer", "… module") |
    | A composite-sounding component ("gateway", "engine") | stays ONE node — never split into sub-nodes the user did not mention |
    
    Completion rule: an addition is legitimate only when the diagram is
    structurally incoherent without it — never decorative. List every assumption
    in the final reply ("assumed top-to-bottom flow; inferred gateway→auth edge")
    so the user can veto. When two readings are both plausible, pick the simpler
    one and note the alternative.
    
    ## Step 1 — Design Brief (开工前完整设计)
    
    After Step 0 classifies the requirement and BEFORE writing any code, produce a
    complete design proposal that combines the user's `input.md` with this skill's
    design system — **state the design, then draw it**. In an interactive session
    you may show the brief first and let the user veto; in headless/automation,
    print it in the reply and proceed.
    
    The brief has five mandatory sections:
    
    1. **Canvas & layout skeleton** — canvas size, band/column/grid partition,
       margins, and the placement *strategy* as relative formulas (e.g.
       `gap = (band_w − n·card_w) / (n + 1)`, `y[i+1] = y[i] + h + GUTTER`), not
       per-element hard-coded coordinates. Name what Step 0 left open (completion
       mode) or what the spec pinned (faithful mode).
    2. **Palette** — pick ONE preset scheme from `references/design_specs.md`
       (S1–S4) by information need and give the role→color mapping table: each
       business role gets a light tint fill PAIRED with its dark accent stroke;
       op cards stay white; accents ≤8; at least one chromatic accent (the ⑯
       无配色 floor). Exact hex from the spec, when given, wins over the preset.
       Color must OWN THE STRUCTURE, not decorate it: tint the band/container
       fills (band-style) or color the primary nodes (node-style) — a mostly
       gray/white skeleton with color confined to small chips FAILs the ⑯
       灰色主导 check (chromatic coverage <35% of elements AND <15% of area).
    3. **Typography tiers** — 3–4 tiers with concrete values (e.g. 20 / 14 / 12 /
       10) and which text class uses each (title / section header / node label /
       note).
    4. **Edge routing** — flow directions, solid vs dashed semantics, spine/bus
       corridors routed OUTSIDE content areas (kiss container edges, never slice
       filled rects — the semantic-QA 箭头线盖在组件上 check), junction/merge
       points, and edge-label placement rules (off the line, perpendicular
       offset).
    5. **Risk checklist** — the pairings and budgets you expect to flirt with:
       text-on-fill contrast (⑮) for every tint+text pair, node spacing ≥14px,
       container gutter ≥20px, font-tier ratios ≥1.15×, marker ids actually used
       (marker 缺省陷阱).
    
    **Landing rule (常量落盘):** the brief must not stay prose. It lands in TWO
    executable forms:
    
    1. a constants block at the top of `gen.py` (dimensions and tokens the
       drawing code reads), and
    2. a **`BRIEF = DesignBrief(...)` contract object** from `$SKILL/design_brief.py`
       — the machine-readable declaration the semantic-QA layer asserts the
       RENDERED SVG against (palette/layout/flow). The brief is the single source
       of truth and **mutable during refine**: if a contrast fix changes a tint,
       update `BRIEF` in the same round rather than silently deviating.
    
    ```python
    # --- Design Brief tokens (Step 1) — edit here, not scattered below --------
    W, H       = 1240, 970                 # canvas
    GUTTER     = 20                        # band spacing
    INK, SUB   = "#1A1A1A", "#555555"      # text tiers 20/14/12/10
    TINTS      = ["#D5E1EB", "#BBCEDF"]    # S1 layer fills (paired strokes below)
    STROKES    = ["#1B3A5C", "#2563EB"]    # dark accent per tint
    F_TIERS    = [20, 14, 12, 10]
    
    from design_brief import DesignBrief, ColorSpec
    BRIEF = DesignBrief(
        scheme="S1", layout="band", flow="top-down",   # layout: band|node; flow: top-down|left-right|none
        palette_role={                                  # key = data-node-id on the shape
            "api":    ColorSpec(TINTS[0], STROKES[0]),  # tinted container: fill+stroke PAIR
            "engine": ColorSpec(TINTS[1], STROKES[0]),
            "store":  ColorSpec("white",  STROKES[0]),  # plain op cards stay white
        },
        flow_chain=("api", "engine"),   # ordered pipeline stages ONLY — side
    )                                   # bands / text-only bands stay out of the chain
    # Render each palette key with a matching node_id= so the contract can
    # attribute rendered shapes: drawer.rect(..., node_id="api", role="layer")
    ```
    
    **Contract rules** (enforced by `check_design_brief` in semantic QA):
    - `palette_role` keys are `data-node-id` values — band layout: layer
      containers (`role="layer"`); node layout: primary nodes. One map, no
      duplicate layer list to drift.
    - `flow_chain` is the ordered pipeline (⊆ palette keys). Memory/cache side
      columns and text-only bands are palette members but NOT chain stages.
    - Declared tints rendered white → FAIL (structure lost its color); wrong
      tint/stroke or undeclared chromatic paint → WARN; ≥70% of inter-layer
      edges must follow the declared flow (return edges tolerated); chain
      first/middle/last layers need out/both/in ≥1.
    - **Capability boundary**: the checker verifies *rendering ↔ self-declared
      contract* consistency, not *contract ↔ user intent* — spec-entity coverage
      and human review of the brief guard the intent side.
    
    ## Core Workflow: Generate-Evaluate-Correct
    
    ```dot
    digraph eval_loop {
        "Generate gen.py" -> "evaluate_svg()" [label="run"];
        "evaluate_svg()" -> "score≥100 AND no [FAIL]?" [label="score"];
        "score≥100 AND no [FAIL]?" -> "Done" [label="yes"];
        "score≥100 AND no [FAIL]?" -> "auto_refine(drawer, max_iter=3)" [label="no"];
        "auto_refine(drawer, max_iter=3)" -> "score≥80?" [label="after n iterations"];
        "score≥80?" -> "Manual fix (coordinates/text)" [label="yes · ship"] ;
        "score≥80?" -> "Regenerate gen.py" [label="no · restart"];
    }
    ```
    
    1. **Content Parsing & Design Brief**:
       - Identify layers, components, and flow direction.
       - Determine canvas dimensions (default 1200x800).
       - Write the Step 1 Design Brief (above) — layout skeleton, palette, tiers,
         edge routing, risks — BEFORE any drawing code; land its tokens as the
         gen.py constants block.
    
    2. **Coding & Layout**:
       - Write a Python script calling `$SKILL/svg_utils.py`.
       - **Required**: use `drawer.check_collisions()` to check overlaps; use the semantic API (below) to register nodes and edges so connections can be auto-validated.
    
    3. **Quality Evaluation**:
       - Call `evaluate_svg(drawer)` from `$SKILL/evaluator.py`.
       - Evaluation dimensions: ① **Containment-aware** element overlap detection (parent-child nesting does not count as a collision); ② boundary checks; ③ canvas coverage; ④ **connection/arrow connectivity** (endpoints must land on registered node borders, 12px tolerance; also detects degenerate zero-length edges and duplicate edges); ⑤ **phantom anchor detection** (nodes referenced by edges but invisible); ⑥ **edge-routes-through-node** (edges must not pass through the interior of a non-endpoint node, 3px inset); ⑦ **edge crossing** (two edge segments intersecting internally); ⑧ **same-kind node minimum spacing** (Euclidean distance between op/junction nodes ≥ 14px); ⑨ **font-size tier detection** (parse SVG to extract all `font-size` values, deduplicate to ≤4 tiers, adjacent tiers must be ≥1.15× apart — prevents accidental micro-steps like 11/12/13/14); ⑩ **palette detection** (accent colors ≤8 warning, ≤12 hard limit; coexistence of very dark L<0.2 and very light L>0.8 accents is flagged as a conflict; background defaults to light); ⑪ **text overflow detection** (parse all `<text>` elements' true geometry, estimate text width by font metrics: overflowing the canvas = FAIL, text wider than its container [smallest rectangle containing the text center] = WARN — closes the blind spot of text drawn via `add_element` that doesn't enter `bboxes` and is invisible to collision/boundary checks); ⑫ **composition quality budget** (ported from fireworks `assess_composition`: ≤2 bends per edge, path stretch ratio ≤1.35, container gutter ≥20px, shortest path segment ≥16px; **text as a measurable obstacle** — edge segments passing through a `<text>` bbox = FAIL, systematically eliminating "text crossed/covered by edges"; gutter is only checked for nodes fully contained within a container, cross-band nodes are not false-flagged). ⑨⑩⑪⑫ **parse the actual SVG** rather than relying on API calls, so they work equally well for raw `add_element` drawing.
       - ⑬ **text-vs-shape & text-vs-text overlap detection** (`check_text_overlaps`): parses every `<text>` bbox (center model, `dominant-baseline="central"`) against all visible circles/rects/polygons/lines/paths AND against other `<text>` — closes the registry blind spot where `bbox=False` text/shapes and `add_element` shapes are invisible to `check_collisions`. Legend/background shapes, the full-canvas bg rect, and rects fully containing the text (intentional in-box labels) are exempt. Like ⑨⑩⑪⑫, this **parses the actual SVG** rather than trusting the API.
       - ⑭ **same-kind peer alignment** (`check_alignment`): two SAME-SIZED same-kind visible nodes that read as a row or column (strong overlap on the perpendicular axis) yet share NEITHER a top/bottom/left/right edge (within 5px) NOR a center line (within 15% of the shorter side) are flagged — the "align to shared edges" layout principle. Differently-sized peers are skipped (a row of varied components legitimately staggers).
       - ⑮ **text-on-fill contrast** (`check_contrast`): WCAG 2 contrast ratio between each `<text>`'s fill and the fill of the smallest `<rect>` containing it — FAIL below 3:1 (large-text floor), WARN below 4.5:1 (AA for normal text) / 3:1 large (≥24px, or ≥18.5px bold). Only text on a **non-neutral (accent)** fill is measured; accent-colored text on a white/neutral canvas (category labels, captions) is a typographic choice, not a fill defect, and is skipped. Replaces the former "manual review recommended" placeholder.
       - ⑯ **chromatic palette floor & gray-dominance** (`check_palette`): at least one accent must carry a readable hue (HSL saturation ≥0.25) — a diagram whose only "accents" are desaturated slate tones (#546E7A et al.) or none at all is **effectively colorless (无配色)** and FAILs. And color must own the **structure**, not just decorate it: when chromatic shapes cover <35% of business elements AND <15% of painted area, the diagram is **gray-dominant (灰色主导)** — neutral bands/containers with color confined to small chips — and also FAILs. One strong axis is a legitimate scheme: band-style diagrams ride the area axis (tinted container fills), node-style diagrams the element axis (colored primary nodes). Pastel tints (#DAE8FC) count as chromatic; slate/pure grays do not. The classic trigger for both: "fixing" a contrast WARN by de-coloring.
    
       **3b. Semantic QA** (after the geometry score): call `run_semantic_qa` from
       `$SKILL/semantic_qa.py`. The evaluator above checks how the picture
       *renders*; this checks what the picture *means* — the three defect classes
       a bounding-box evaluator structurally cannot see:
       - **marker 缺省陷阱** — `marker-end="url(#X)"` referencing an undefined
         `<marker id>` (the classic case: `arrow_head("arrow", ...)` registered but
         a `connect()` call left at its default `marker_end="arrowhead"`) → every
         arrowhead on that edge silently vanishes. FAIL. A defined-but-never-used
         marker (usually a forgotten `marker_end=`) is flagged as WARN.
       - **FIGS 尺寸漂移** — declared canvas vs. actual content bbox: content far
         smaller than the canvas (mis-sized diagram), content poking outside
         (clipped), or a mismatch against the design-spec size passed as
         `expected_size=(w, h)`.
       - **标签错位** — a centered label off its node's centre, a label floating
         in whitespace (not inside, near, or beneath any node — legitimate
         top-band titles, branch labels beside edges, and cluster captions are
         exempt), or a business node box with no label at all.
       - **箭头线盖在组件上** (`rail-slices-container` / `connector-through-card`)
         — parsed straight from the rendered geometry, role-blind to the
         registry: a raw-`line()` bus rail that slices through filled band
         containers (the right-spine-at-x≈776-inside-the-band trap), or a
         connector crossing a business card's interior. The registry evaluator
         is structurally blind to both (rails are never registered as edges;
         `role='layer'` containers are never registered as nodes).
       - **文本语义** (`check_text_semantics`, pass `spec_text=input.md`) —
         placeholder/garbled/empty `<text>` → FAIL; spec component identifiers
         (bold/backtick identifiers like **AgentEvent**, `server_queue`) missing
         from the diagram: coverage <40% → FAIL (regenerate — whole components
         lost), 40–85% → WARN (paraphrase advisory fed back into refine rounds).
    
       ```python
       from semantic_qa import run_semantic_qa
    
       score, report = evaluate_svg(drawer)          # geometry first
       spec = Path("input.md").read_text() if Path("input.md").exists() else None
       qa = run_semantic_qa(drawer, expected_size=(1240, 970), spec_text=spec,
                            brief=BRIEF)             # + the Step-1 contract
       for line in qa.report():
           print(line)
       # qa.has_fail → semantic defect (dangling marker ref, rail over a
       # component, lost spec entities, brief-contract violation): fix before export
       # brief omitted → brief-absent WARN: declaring the contract is not optional
       ```
    
    4. **Auto-Correction**:
       - If the evaluation score is below **80**, analyze the `[FAIL]` items in the report.
       - Connection issues (`dangles` / `Degenerate edge` / `overlaps`): use `drawer.connect(...)` to let endpoints auto-snap to node borders; avoid manually computing offset coordinates.
       - `phantom` (phantom anchors): the node referenced by an edge is invisible → give it a real fill/stroke, or use the distinct-port pattern to connect to a visible junction.
       - `routes through node`: an edge cuts through an intermediate node → reroute via orthogonal bypass channels, or relay through a junction (see distinct-port pattern), keeping a ≥20px gap from the intermediate node.
       - `cross` (edge crossings): adjust node layout or routing channels so edges don't intersect (reference fireworks' zero-crossing budget).
       - `too close`: same-kind nodes are clustered → increase spacing or enlarge the canvas.
       - Arrow position: `connect()` auto-retracts by `marker_tip_depth` — retraction = `(markerWidth − refX) × stroke_width`, derived from the marker dimensions registered by `arrow_head()`, so the arrow tip lands exactly on the target border (neither poking in nor leaving a gap). For custom markers, pass the real dimensions via `arrow_head(id, color, marker_width=, ref_x=)` — no manual tweaking needed.
       - `font` (font sizes): more than 4 distinct tiers after dedup → converge to 3-4 tiers (title/body/note); near-overlapping tiers (ratio <1.15, e.g. 11/12) → merge into one tier. Recommended modular scale: 20 / 14 / 12 / 10 (all steps ≥1.15). This matches the tier count measured in each ink-graph style.
       - `palette`: accent count >8 → trim toward a preset scheme (S1–S4) — consolidate near-hue accents, drop redundant category colors; >12 → same, harder. `no chromatic accent` (无配色, FAIL) → restore tinted layer fills + accent strokes from a preset scheme. `gray-dominant` (灰色主导, FAIL) → color is marginal: tint the band/container fills (band-style) or color the primary nodes (node-style) so the scheme owns the skeleton — do not merely enlarge a legend/chip. **Never satisfy a palette or contrast finding by reverting the whole diagram to neutral** — that trades a WARN for a colorless or gray-dominant diagram, which now FAILs. Luminance conflict (very dark + very light coexist) → unify into one brightness family. Non-light background → apply white by default; dark themes must declare `set_background()`/`bg=`. See `references/design_specs.md` for the 4 preset schemes (S1–S4) and when to use each.
       - Layout issues: adjust component coordinates, spacing, or scale ratio, then regenerate.
       - `text` overflow: text exceeds the canvas → shorten the copy or shift the start point left; text wider than its card/container → shorten, auto-wrap by container width (greedy word-wrap), or widen the container. Note that `<text>` does not enter `bboxes` by default, so collision/boundary checks can't see it — this detection fills that gap.
       - `text overlap` (text on a shape or another text): a label sits on top of a circle/triangle/arc/line or collides with a neighboring label → move the label clear of the shape (place it above/below the icon, not on it) or shorten it. `auto_refine` cannot fix this (no geometry handle for raw `add_element` text) — adjust coordinates manually. This catches overlaps `check_collisions` misses because `bbox=False` text/shapes and `add_element` shapes bypass the collision registry.
       - `contrast` (low text-on-fill contrast): a label doesn't read against its accent card (ratio <3:1 FAIL, <4.5:1 WARN for normal text) → darken/lighten the text fill toward the channel extreme (pure `#000000`/`#ffffff` on a mid-tone card is always safe), or switch the card to a lighter tint of the same hue so a dark label clears AA. **De-coloring the card to white/gray is NOT a fix** — it silences this check by making the diagram colorless, which the ⑯ chromatic floor then FAILs; always keep a tint fill paired with its dark accent stroke. Note: accent-colored text on a **neutral** canvas is a deliberate category/heading choice and is not flagged — only labels on accent fills are. `auto_refine` does not touch colors; adjust manually.
       - `alignment` (misaligned same-size peers): two same-sized same-kind nodes in a row/column share no edge/center line → nudge one onto the other's top/bottom (row) or left/right (column) edge, or onto a shared center line. Differently-sized peers are exempt (they legitimately stagger). `auto_refine` does not handle alignment yet — adjust coordinates manually.
       - `composition` gutter (insufficient container margin): node too close to the container edge → push the node toward the container center; or call **`auto_refine(drawer)`** (below) to iteratively auto-correct.
       - **`auto_refine(drawer, target_score=100, max_iter=3)`**: reads the `evaluate_svg` report and auto-corrects programmable issue categories (gutter → nudge node toward container center; too close → spread along the primary axis), looping until the target is met or iterations are exhausted. Returns `(score, report, fixes)`. Complex fixes (dangles/cross/route-through) still need manual intervention — auto_refine only handles geometric micro-adjustments.
    
    ## Node & Edge Semantics
    
    For the evaluator to "see" connections, drawing code must register connectable rectangles as **nodes** and connections as **edges**:
    
    - **Register nodes**: `drawer.rect(..., node_id="op1", node_kind="op")` — draws a rectangle and registers a node simultaneously. `node_kind` can be `"op" | "layer" | "block" | "region"`; used for provenance only, not for validation.
    - **Circle nodes (junctions/markers)**: `drawer.circle(cx, cy, r, ..., node_id="jn", node_kind="junction")` — draws a visible circle and registers a square Node of side 2r as a snap anchor; endpoints landing at the center register distance 0. Ideal for bus junctions and port markers.
    - **Register edges**: prefer `drawer.connect(from_id, from_side, to_id, to_side, ...)` — endpoints are taken from node border midpoints (`"top"|"bottom"|"left"|"right"`), so arrows always land precisely on the node edge. When several edges share one node side, their endpoints **fan out symmetrically** along that border (deterministic same-port spread, ≤14px steps, skipped on sides shorter than 32px) instead of stacking into one line — no manual offset juggling. Options: `dashed=True` (dashed, e.g. lowering/bypass flows), `as_curve=True` + `curve_dir="left"|"right"` (curve), `edge_label=` (annotation).
    - **Dashed rendering**: `dashed=` is available on **all** primitives — `rect`, `circle`, `line`, `path`, and `connect`. Pass `dashed=True` for the standard `"6,3"` pattern, or `dashed="4,3"` for a custom dash pattern. This replaces the old `extra='stroke-dasharray="..."'` spelling (which still works for backward compatibility).
    - **Low-level entry**: `drawer.line(..., register_edge=True)` / `drawer.path(..., register_edge=True, start=..., end=...)` can also manually register edges (for curves, `start/end` are the semantic endpoints; `d` can be any path).
    - **`group(transform)` context**: nodes/edges/bboxes drawn inside `with drawer.group("translate(100,50) rotate(30)"):` are registered in **absolute coordinates** via the accumulated affine matrix, so local coordinates inside a group are also validated. Supports chained `matrix()/translate()/scale()/rotate()/skewX()/skewY()`.
    - **Advanced shapes** (ported from ink-graph `shapes.md`, local coordinates via `<g transform>`): `drawer.database(x,y,w,h,...)` (cylinder, top ellipse depth=min(8,h*0.12)), `drawer.decision(...)` (diamond, four points around center), `drawer.hexagon(...)` (gateway, 25% corner insets), `drawer.component(...)` (with left-edge double tabs), `drawer.cloud(...)` (multi-lobe cubic curves). All accept `node_id/role/label`, register nodes consistently with `rect()`/`circle()`, and support connection snapping. Text centering uses `dominant-baseline="central"` (exact, replacing the old y+0.35*fs approximation).
    - **Semantic role `role=`** (optional): `rect/circle/connect/line/path` all accept `role="node|edge|decoration|legend|background|layer"`. Elements set to `decoration`/`legend`/`background` emit a `data-graph-role` attribute and are **excluded from business checks** (spacing, collision, palette count) — used for decorative layers (rail casings, background textures, legends). `role="layer"` marks tinted band containers: they emit `data-graph-role="layer"` and are the primary signal for band detection in the design-brief layout check (pair each with a `node_id=` that matches a `palette_role` key). Default `node`/`edge` means business elements.
    - **Math formulas with sub/superscripts**: `drawer.formula(x, y, markup, font_size=, fill=, anchor=, weight=)` renders genuine `<tspan>` baseline shifts — unlike `text()` (which HTML-escapes content and can only show literal underscores/carets). Markup: `_{...}` → subscript, `^{...}` → superscript; baseline auto-resets between tokens so multiple indices align (e.g. `"F_{k} = MS^{↑}_{k} + g_{k}"`). Default monospace family + bold for an equation look; pass `weight="normal"` for inline annotations. The sub/superscript glyph sizes (~0.72×) are derivative of the parent text size and are **excluded from the font-tier count** (see `check_font_scale`), so formulas do not inflate the 3–4-tier typography budget. (Note: `svg2pptx` concatenates `<tspan>` text flat — use image mode if you need exact subscript fidelity in PowerPoint.)
    
    > **"Invisible anchor" anti-pattern (now forcefully blocked)**: it used to be possible to create `fill="none" stroke="none"` invisible rectangles to cheat the connection validation — the evaluator would pass, but the human eye would see dangling lines. Now `check_phantom_anchors()` detects any node referenced by an edge that is invisible (`fill=none ∧ stroke=none`/opacity=0/zero-size) and flags it as FAIL. When you need a "bus rail" or cross-layer channel, use the distinct-port pattern below to connect to a visible junction.
    
    > **Distinct-port / junction pattern** (cross-layer aggregation, side channels): place visible circular junction nodes at the channel position, connect each real component to the junction with a short solid line `connect(layer, "left", junction, "right")`, then chain them into a rail with dashed lines `connect(junction, "bottom", junction2, "top", dashed=True)`. This way each rail segment lands between real visible nodes — passing validation while remaining clear to humans.
    
    > Tip: container-type large rectangles (Module/Layer) enter bbox collision and coverage stats by default; for interior small elements (text, operation nodes), pass `bbox=False` to avoid false overlap reports or inflated coverage.
    
    ## Design Specifications
    - **Font-size tiers**: use only 3-4 tiers per diagram (title 20 / section header 14 / body 12 / note 10), adjacent tiers ≥1.15× apart (modular type scale). Exceeding this triggers an evaluator warning.
    - **Palette**: pick by **information need** (see `references/design_specs.md` and the per-type table in `references/diagram_types.md`) — S2 Categorical when 2–4 classes / branches / roles need hue, S3 Semantic for fixed component types (cloud / network), S4 Duotone for one focal element, S1 Monochrome Blue only as the fallback for pure layering with no categorical role. Do **not** reflex-default to S1 — it makes every diagram blue. All schemes pre-verified (accent ≤12, no luminance clash). Op cards stay `fill="white"`; color lives in layer fills + borders — and it must own the structure: at least one chromatic accent (无配色 floor) AND enough chromatic weight that the skeleton doesn't read gray (灰色主导: <35% of elements AND <15% of painted area FAILs; tint the containers or color the primary nodes). Background defaults to white (`SVGDrawer(bg="#FFFFFF")`); only use `set_background()` for dark themes.
    - **Color & typography**: see `references/design_specs.md`.
    - **Shapes & layout per type**: when the user names a diagram type (architecture / flowchart / ML model / ER / sequence / swimlane / network), apply the matching preset in `references/diagram_types.md` — it maps each semantic role to a primitive (`rect` / `database` / `decision` / `hexagon` / `component` / `cloud`) and gives direction + spacing defaults that pass the evaluator.
    - **Stability**: prefer relative layout logic (i.e. compute new component positions based on known component coordinates).
    - **Entity escaping**: SVG is strict XML — never use HTML entities in text (`&middot;`/`&mdash;` etc. are rejected by the parser). Use Unicode characters (`·` `—`) or `html.escape` instead.
    
    ## Example: Generation with Evaluation
    
    ```python
    import sys
    # Resolve the skill scripts dir relative to this file (see $SKILL note above).
    sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..", "scripts"))
    from svg_utils import SVGDrawer, save_svg
    from evaluator import evaluate_svg
    from pathlib import Path
    # Write outputs next to this script (see "Output Layout Convention" below).
    OUT = Path(__file__).resolve().parent
    
    drawer = SVGDrawer(1200, 800, bg="#FFFFFF")   # white background is the default; change only for dark themes
    drawer.arrow_head("arrowhead", "#333")
    
    # Nodes (Scheme S1 Monochrome Blue — L1 tier; see design_specs.md for the full library)
    drawer.rect(100, 100, 90, 34, fill="#D5E1EB", stroke="#1B3A5C",
                node_id="a", node_kind="op", bbox=False)
    drawer.rect(300, 100, 90, 34, fill="#D5E1EB", stroke="#1B3A5C",
                node_id="b", node_kind="op", bbox=False)
    
    # Edges: endpoints auto-snap to node borders, arrows land precisely on the edge
    drawer.connect("a", "right", "b", "left",
                   stroke="#1B3A5C", marker_end="arrowhead", edge_label="value")
    
    # Evaluation (includes connection/arrow validation)
    score, report = evaluate_svg(drawer)
    print(f"Quality Score: {score}")
    for line in report:
        print(line)
    
    if score >= 80:
        save_svg(drawer.render(), str(OUT / "diagram.svg"))
    else:
        print("Score too low, need adjustment.")
    ```
    
    ## Output Layout Convention
    
    Every diagram generation is **self-contained in its own subdirectory** under `output/`. One diagram = one directory; the generator script and its SVG/PNG/PPTX triplet live together.
    
    ```
    output/<timestamp>_<name>/
    - gen_<name>.py        # generator script (version-controlled source)
    - <name>.svg           # -
    - <name>.png           #  |- triplet, regenerated in place on each run
    - <name>.pptx          # -
    ```
    
    **Rules:**
    - **Script and outputs are co-located.** The `gen_*.py` lives *inside* its `output/<ts>_<name>/` directory — never in the repo root, never scattered away from its artifacts. Deleting the directory removes script + outputs together.
    - **Write to the script's own directory, not a fresh timestamped dir per run** — re-running refreshes the triplet in place rather than accumulating duplicate directories:
      ```python
      from pathlib import Path
      OUT = Path(__file__).resolve().parent   # this script's own directory
      NAME = "<name>"
      ```
    - **The `<timestamp>_<name>` directory name is frozen at creation** (a born-on date); it is not regenerated on each run.
    - **Always emit the full quartet** — SVG (`save_svg`), PNG (`rasterize_svg`, wraps `rsvg-convert`), PPTX (`svg2pptx.svg_to_pptx`), and `brief.json` (`BRIEF.write(...)`, the declared design contract) — so the directory is self-describing.
    - **Artifacts are gitignored by extension** (`**/*.svg`, `**/*.png`, `**/*.pptx`); the `gen_*.py` scripts stay version-controlled. Never gitignore the whole output directory — that hides the scripts.
    
    ### The save/rasterize helpers
    
    `save_svg()`, `rasterize_svg()`, and `svg2pptx.svg_to_pptx()` write wherever you ask — they create parent directories as needed and impose no layout constraint. A prior version enforced an `output/<task>/` directory at the library boundary (`validate_output_path` / `OutputPathError`); that was removed for the public release because it refused legitimate cross-project and temporary paths.
    
    ```python
    from svg_utils import save_svg, rasterize_svg
    
    save_svg(content, OUT / "diagram.svg")      # writes, mkdir -p the parent
    rasterize_svg(OUT / "diagram.svg", OUT / "diagram.png", width=1200)
    ```
    
    - `save_svg(content, filename)` — writes SVG *content* to *filename*, creating parents; returns the resolved path.
    - `rasterize_svg(svg_path, png_path, width)` — runs `rsvg-convert -w <width>`; creates parents; returns the PNG path.
    - `svg2pptx.svg_to_pptx(svg, pptx_path, config=None)` — converts to PPTX; creates parents.
    
    > Prefer these wrappers over a raw `subprocess.run(["rsvg-convert", ...])` so path handling stays uniform.
    
    ## SVG to PPTX Export (svg2pptx)
    
    After generating an SVG, you can export it to an **editable PowerPoint file** with one call. The module `$SKILL/svg2pptx.py` parses SVG elements into native PowerPoint shapes (rectangles, ovals, connectors, text boxes, freeforms) rather than embedding an image — so each element can be individually resized, recolored, and edited in PowerPoint/Keynote/LibreOffice. Inspired by the [svg2pptx](https://github.com/benouinirachid/svg2pptx) project.
    
    ### Two Export Modes
    
    | Mode | Parameter | Effect | Use Case |
    |---|---|---|---|
    | **shapes** (default) | `mode="shapes"` | Each element → an independent editable shape | Architecture diagrams you want to fine-tune in PPT |
    | **image** | `mode="image"` | Rasterize to PNG and embed (100% visual fidelity) | Complex SVGs for display only, no editing needed |
    
    ### API
    
    ```python
    # sys.path was set above in the Example section ($SKILL = scripts directory)
    from svg2pptx import svg_to_pptx, PptxConfig, save_pptx
    
    # Option 1: SVG string → PPTX (most common, takes drawer.render())
    svg_to_pptx(drawer.render(), OUT / "diagram.pptx")
    
    # Option 2: SVG file → PPTX (file must end with .svg, otherwise parsed as an SVG string)
    svg_to_pptx(OUT / "diagram.svg", OUT / "diagram.pptx")
    
    # Option 3: Export directly from an SVGDrawer (equivalent to Option 1)
    save_pptx(drawer, OUT / "diagram.pptx")
    
    # Custom config: 16:9 slide, 2x scale, shapes mode
    svg_to_pptx(OUT / "diagram.svg", OUT / "diagram.pptx",
                config=PptxConfig(slide_w=13.333, slide_h=7.5, scale=2.0))
    
    # Image mode (rasterized embed, requires rsvg-convert)
    svg_to_pptx(OUT / "diagram.svg", OUT / "diagram.pptx", config=PptxConfig(mode="image"))
    
    # Add to an existing presentation's slide (no new file created)
    # Note: add_svg_to_slide only supports shapes mode, not image rasterization
    from svg2pptx import add_svg_to_slide
    add_svg_to_slide(drawer.render(), slide, x=1.0, y=0.5, scale=0.8)
    ```
    
    ### SVG → PPTX Element Mapping
    
    | SVG Element | PPTX Shape | Notes |
    |---|---|---|
    | `<rect rx=0>` | Rectangle | Auto shape; **rotates correctly** inside a rotated `<g>` |
    | `<rect rx>0>` | Rounded Rectangle | Corner radius auto-mapped; **rotation** also handled |
    | `<circle>` / `<ellipse>` | Oval | Ellipse/circle; **rotation** handled correctly |
    | `<line>` | Connector (Straight) | Straight-line connector |
    | `<polygon>` | Freeform (closed) | Polygon → freeform |
    | `<polyline>` | Freeform (open) | Polyline → freeform |
    | `<path>` | Freeform | **Bezier/Arc auto-flattened** to line segments (`curve_tolerance` controls precision) |
    | `<text>` / `<tspan>` | Text Box | Preserves font/size/color/alignment, CJK works; `<tspan>` child text is auto-concatenated |
    | `<g transform>` | Coordinate transform | **Accumulated affine matrix** (translate/scale/rotate) applied to all child shapes |
    | `marker-end` | Freeform triangle | **Arrow auto-rendered**: draws a triangle at the segment endpoint based on marker geometry |
    | `fill-opacity` | Transparency | Implemented via `<a:alpha>` XML injection (python-pptx does not support this natively) |
    | `stroke-dasharray` | Dashed line | `prstDash` or `custDash` XML injection |
    
    ### Limitations
    - **Gradients** are not supported (the first color is used).
    - **Filters/effects** (blur, shadow) are not supported (shape shadows are disabled by default).
    - **Bezier curves** are flattened to line segments (lower `curve_tolerance` = smoother, default 1.0px).
    - **Font scaling**: font size scales proportionally with the fit-to-slide `scale` (`Pt(fs * scale * 72/96)`) — i.e. a large canvas mapped to a small slide shrinks text, and vice versa. The text-to-box ratio always stays consistent. To fix the font size, set `scale=1.0` and adjust `slide_w/slide_h` yourself.
    - **`add_svg_to_slide`** only supports shapes mode (no image rasterization); for image mode use `svg_to_pptx`.
    - **Image mode** requires `rsvg-convert` (installed on this system); shapes mode only requires `python-pptx`.
    
    ## Capabilities
    
    All detection/export capabilities parse the actually-rendered SVG (`evaluate, don't assert`), so they work equally well for raw `add_element` drawing.
    
    | # | Capability | Description |
    |---|---|---|
    | ① | Containment-aware collision | Parent-child nesting does not count as a collision |
    | ② | Phantom anchor detection | Nodes referenced by edges but invisible → FAIL |
    | ③ | Edge × edge crossing | Two edge segments intersecting internally |
    | ④ | Marker depth auto-derivation | `(markerWidth−refX)×stroke` |
    | ⑤ | Font-size tier detection | Parses SVG, ≤4 tiers, adjacent tiers ≥1.15× apart |
    | ⑥ | Palette detection | Accent ≤8/12, ≥1 chromatic (无配色 floor), and color owns the structure (灰色主导: FAIL when chromatic coverage <35% of elements AND <15% of painted area — one strong axis suffices); background defaults to light |
    | ⑦ | Curve bezier/arc sampling | Ported from fireworks `path_routes` |
    | ⑧ | Luminance conflict by channel | Fill/stroke checked separately for dark+light coexistence |
    | ⑨ | Transform matrix accumulation | `group()` context |
    | ⑩ | `data-graph-role` semantic roles | decoration/legend/background skip business checks |
    | ⑪ | Coverage bbox union | Sweep-line algorithm |
    | ⑫ | Text overflow detection | Parses `<text>` geometry |
    | ⑬ | Composition quality budget | bend≤2/stretch≤1.35/gutter≥20/segment≥16 + text as obstacle |
    | ⑭ | Node shape library | database/decision/hexagon/component/cloud |
    | ⑮ | Barycenter crossing minimization | Ported from DiagramForge: reorder by barycenter within layers |
    | ⑯ | `auto_refine` auto-correction | Reads eval report, iteratively fixes gutter/spacing by issue code |
    | ⑰ | SVG→PPTX export | Native editable shapes + rasterized image dual modes, arrow rendering, Bezier/Arc flattening, transparency/dash injection |
    | ⑱ | Text-vs-shape & text-vs-text overlap | Parses rendered SVG: `<text>` bbox vs visible circles/rects/polygons/lines/paths + text vs text; closes the bbox-registry blind spot (`bbox=False`/`add_element`) |
    | ⑲ | Formula rendering (sub/superscript) | `drawer.formula()` emits real `<tspan>` baseline shifts for `_{}`/`^{}` markup; evaluator strips markup in width estimates and counts only `<text>`-tier font sizes so subscripts don't inflate the tier budget |
    | ⑳ | Text-on-fill contrast (WCAG 2) | `check_contrast`: ratio of each `<text>` fill vs its smallest containing `<rect>` fill — FAIL <3:1, WARN <4.5:1 (AA) / 3:1 large (≥24px / ≥18.5px bold); only accent fills measured, accent text on neutral canvas skipped |
    | ㉑ | Same-kind peer alignment | `check_alignment`: same-sized same-kind nodes in a row/column must share a top/bottom/left/right edge (±5px) or a center line (±15%); differently-sized peers exempt |
    | ㉒ | Semantic QA (`semantic_qa.py`) | Meaning-level smoke check after scoring: dangling marker refs (marker 缺省陷阱, FAIL), defined-but-unused markers (WARN), declared-vs-actual canvas size drift (FIGS 尺寸漂移), label/host mismatch (标签错位), raw rails slicing filled containers or cards (箭头线盖在组件上), text semantics vs spec (placeholder/garbled/empty FAIL; spec-entity coverage <40% FAIL / <85% WARN) — parses the rendered SVG incl. grouped shapes, composite arcs, and stroke widths |
    | ㉓ | Design-brief contract (`design_brief.py` + `check_design_brief`) | Step-1 declared intent as data: `DesignBrief(scheme, layout band|node, flow top-down|left-right|none, palette_role {data-node-id: (fill,stroke)}, flow_chain)`. The rendered SVG is asserted against it — declared tint gone white FAIL, wrong/undeclared paint WARN, empty declared band FAIL, side-band-in-chain chain-broken FAIL, ≥70% inter-layer flow dominance (return edges tolerated), chain degree rules, declared order vs geometry. Absent brief → visible WARN. Capability boundary: verifies rendering ↔ self-declared contract, not contract ↔ user intent |
    
    ## References & Acknowledgments
    
    This Skill's geometry/connection detection draws on the following open-source projects (their references and validator implementations were actually studied):
    
    - **ink-graph** (`qaz1230sp/ink-graph`): its references/pitfalls.md #2 (arrow occluded by node → retract endpoint 8px), #3/#10 (edge crossing through node → 20px gap bypass), #17 (fan-out alignment), #26 (marker size proportional to stroke), #29 (fan-out/fan-in + junction dot); its references/shapes.md `dominant-baseline="central"` centering, its references/layout-rules.md grid/spacing rules. **Measured each of its `style-*.md` at exactly 3-4 font tiers, 4-13 palette colors** — empirical basis for the font-tier/palette thresholds. *(These files live in the ink-graph repo, not this skill.)*
    - **fireworks-tech-graph** (`yizhiyanhua-ai/fireworks-tech-graph`): its references/composition-quality-contract.md (executable budget: zero crossings/≤2 bends/≥40px node spacing/≥20px container gutter); its scripts/validate_svg.py `find_collisions` + `segment_hits_bounds` (path sampling vs node bbox), `data-graph-role` semantic roles, transform matrix accumulation, "evaluate, don't assert" (parse actual SVG rather than trusting API calls). *(Files live in the fireworks repo, not this skill.)*
    - **svg-animations** (`supermemoryai/skills`): SMIL/CSS animation basics and `stroke-dasharray` stroke animation recipes (this Skill does not enable animation yet, reserved for later).
    - **svg-design** (`tryopendata/skills`): primitive-first (circles use `<circle>`), `stroke-linecap="round"`, strict XML with no HTML entities, and other hygiene conventions.
    - **svg2pptx** (`benouinirachid/svg2pptx`): architectural blueprint for the PPTX export module. Its "SVG element → PowerPoint native editable shape" philosophy (rect→rectangle, circle→oval, line→connector, path→freeform, text→textbox), Config dataclass design, `build_freeform` + `add_line_segments` usage, and Bezier flattening tolerance parameter were all adapted into the self-contained `scripts/svg2pptx.py` module (which adds arrow marker rendering, `fill-opacity` transparency injection, `stroke-dasharray` dash injection, and an image rasterization fallback mode).
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related