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,
Install
npx skills add https://github.com/Andy1314Chen/architecture-drawer/tree/main/plugins/architecture-drawer/skills/architecture-drawer
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install andy1314chen-architecture-drawer@llmmart
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.
- Write the candidate — resolve
$SKILL(snippet above), pick ONE palette preset fromreferences/design_specs.md(do not invent hex values), land the Step-1 design-brief tokens as a constants block atopgen.py, draw withdrawer.rect/circle/connect(registernode_ids so connections validate), then immediately run the script. One clear main flow beats a dense map; ≤12 primary nodes before the first evaluation. - 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. - 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:
- 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). - 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). - 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).
- 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).
- 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:
- a constants block at the top of
gen.py(dimensions and tokens the drawing code reads), and - 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, updateBRIEFin 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_rolekeys aredata-node-idvalues — band layout: layer containers (role="layer"); node layout: primary nodes. One map, no duplicate layer list to drift.flow_chainis 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"];
}
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.
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.
- Write a Python script calling
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-sizevalues, 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 viaadd_elementthat doesn't enterbboxesand is invisible to collision/boundary checks); ⑫ composition quality budget (ported from fireworksassess_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 rawadd_elementdrawing. - ⑬ 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 wherebbox=Falsetext/shapes andadd_elementshapes are invisible tocheck_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_qafrom$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 aconnect()call left at its defaultmarker_end="arrowhead") → every arrowhead on that edge silently vanishes. FAIL. A defined-but-never-used marker (usually a forgottenmarker_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, passspec_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- Call
Auto-Correction:
- If the evaluation score is below 80, analyze the
[FAIL]items in the report. - Connection issues (
dangles/Degenerate edge/overlaps): usedrawer.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 bymarker_tip_depth— retraction =(markerWidth − refX) × stroke_width, derived from the marker dimensions registered byarrow_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 viaarrow_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 declareset_background()/bg=. Seereferences/design_specs.mdfor the 4 preset schemes (S1–S4) and when to use each.- Layout issues: adjust component coordinates, spacing, or scale ratio, then regenerate.
textoverflow: 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 enterbboxesby 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_refinecannot fix this (no geometry handle for rawadd_elementtext) — adjust coordinates manually. This catches overlapscheck_collisionsmisses becausebbox=Falsetext/shapes andadd_elementshapes 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/#ffffffon 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_refinedoes 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_refinedoes not handle alignment yet — adjust coordinates manually.compositiongutter (insufficient container margin): node too close to the container edge → push the node toward the container center; or callauto_refine(drawer)(below) to iteratively auto-correct.auto_refine(drawer, target_score=100, max_iter=3): reads theevaluate_svgreport 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.
- If the evaluation score is below 80, analyze the
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_kindcan 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, andconnect. Passdashed=Truefor the standard"6,3"pattern, ordashed="4,3"for a custom dash pattern. This replaces the oldextra='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/endare the semantic endpoints;dcan be any path). group(transform)context: nodes/edges/bboxes drawn insidewith 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 chainedmatrix()/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 acceptnode_id/role/label, register nodes consistently withrect()/circle(), and support connection snapping. Text centering usesdominant-baseline="central"(exact, replacing the old y+0.35fs approximation). - Semantic role
role=(optional):rect/circle/connect/line/pathall acceptrole="node|edge|decoration|legend|background|layer". Elements set todecoration/legend/backgroundemit adata-graph-roleattribute 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 emitdata-graph-role="layer"and are the primary signal for band detection in the design-brief layout check (pair each with anode_id=that matches apalette_rolekey). Defaultnode/edgemeans business elements. - Math formulas with sub/superscripts:
drawer.formula(x, y, markup, font_size=, fill=, anchor=, weight=)renders genuine<tspan>baseline shifts — unliketext()(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; passweight="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 (seecheck_font_scale), so formulas do not inflate the 3–4-tier typography budget. (Note:svg2pptxconcatenates<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. Nowcheck_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 linesconnect(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=Falseto 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.mdand the per-type table inreferences/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 stayfill="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 useset_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 (
·/—etc. are rejected by the parser). Use Unicode characters (·—) orhtml.escapeinstead.
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_*.pylives inside itsoutput/<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, wrapsrsvg-convert), PPTX (svg2pptx.svg_to_pptx), andbrief.json(BRIEF.write(...), the declared design contract) — so the directory is self-describing. - Artifacts are gitignored by extension (
**/*.svg,**/*.png,**/*.pptx); thegen_*.pyscripts 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)— runsrsvg-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, setscale=1.0and adjustslide_w/slide_hyourself. add_svg_to_slideonly supports shapes mode (no image rasterization); for image mode usesvg_to_pptx.- Image mode requires
rsvg-convert(installed on this system); shapes mode only requirespython-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.mddominant-baseline="central"centering, its references/layout-rules.md grid/spacing rules. Measured each of itsstyle-*.mdat 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.pyfind_collisions+segment_hits_bounds(path sampling vs node bbox),data-graph-rolesemantic 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 andstroke-dasharraystroke 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_segmentsusage, and Bezier flattening tolerance parameter were all adapted into the self-containedscripts/svg2pptx.pymodule (which adds arrow marker rendering,fill-opacitytransparency injection,stroke-dasharraydash 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 (& -> &): the reverse order would turn a user's literal '<b>' (emitted as '<b>') 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 (`·`/`—` 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.
Reviews (0)
No reviews yet.
No comments yet.