MCP : outils, normalisation des éléments et tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nh6YQSjM9ni5jiTc8u5Tj1
This commit is contained in:
1 parent
eeda956d24
commit
9b8f53d0d0
4 files changed
+1201
No files matched your search
@@ -0,0 +1,343 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Normalisation des éléments écrits par un client MCP : un modèle produit
|
||||
// rarement tous les champs d'un élément Excalidraw. normalize complète les
|
||||
// valeurs par défaut et accepte deux raccourcis de l'API « skeleton »
|
||||
// d'Excalidraw :
|
||||
// - "label": {"text": "..."} sur un rectangle, une ellipse, un losange ou
|
||||
// une flèche : texte lié, centré dans le conteneur ;
|
||||
// - "start"/"end": {"id": "..."} sur une flèche : liaison aux formes,
|
||||
// points calculés entre leurs bords si la flèche n'en donne pas.
|
||||
//
|
||||
// Excalidraw répare encore la scène au chargement (restore), mais un fichier
|
||||
// complet s'affiche aussi correctement en miniature et dans la recherche.
|
||||
|
||||
type element = map[string]any
|
||||
|
||||
func randID() string {
|
||||
b := make([]byte, 15)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func randInt() int64 {
|
||||
var b [4]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return int64(binary.BigEndian.Uint32(b[:]) >> 1)
|
||||
}
|
||||
|
||||
func num(e element, k string) (float64, bool) {
|
||||
switch v := e[k].(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func numOr(e element, k string, def float64) float64 {
|
||||
if v, ok := num(e, k); ok {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func setDefault(e element, k string, v any) {
|
||||
if _, ok := e[k]; !ok {
|
||||
e[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// textSize estime l'encombrement d'un texte (Excalidraw le recalcule à l'affichage).
|
||||
func textSize(text string, fontSize, lineHeight float64) (w, h float64) {
|
||||
lines := strings.Split(text, "\n")
|
||||
longest := 0
|
||||
for _, l := range lines {
|
||||
longest = max(longest, utf8.RuneCountInString(l))
|
||||
}
|
||||
return math.Ceil(float64(longest) * fontSize * 0.6), math.Ceil(float64(len(lines)) * fontSize * lineHeight)
|
||||
}
|
||||
|
||||
var containers = map[string]bool{"rectangle": true, "ellipse": true, "diamond": true, "arrow": true}
|
||||
|
||||
// normalize valide et complète les éléments ; ils sont modifiés en place.
|
||||
func normalize(els []any) ([]any, error) {
|
||||
now := time.Now().UnixMilli()
|
||||
byID := map[string]element{}
|
||||
var out []any
|
||||
var labels, arrows []element
|
||||
for i, raw := range els {
|
||||
e, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("élément %d : objet attendu", i)
|
||||
}
|
||||
typ, _ := e["type"].(string)
|
||||
if typ == "" {
|
||||
return nil, fmt.Errorf("élément %d : champ type manquant", i)
|
||||
}
|
||||
if id, _ := e["id"].(string); id == "" {
|
||||
e["id"] = randID()
|
||||
}
|
||||
if _, dup := byID[e["id"].(string)]; dup {
|
||||
return nil, fmt.Errorf("élément %d : id %q en double", i, e["id"])
|
||||
}
|
||||
byID[e["id"].(string)] = e
|
||||
base(e, now)
|
||||
switch typ {
|
||||
case "text":
|
||||
if err := text(e); err != nil {
|
||||
return nil, fmt.Errorf("élément %d : %w", i, err)
|
||||
}
|
||||
case "arrow", "line":
|
||||
if typ == "arrow" {
|
||||
setDefault(e, "roundness", map[string]any{"type": 2})
|
||||
}
|
||||
arrows = append(arrows, e)
|
||||
case "rectangle", "diamond":
|
||||
setDefault(e, "roundness", map[string]any{"type": 3})
|
||||
}
|
||||
setDefault(e, "roundness", nil)
|
||||
if _, ok := e["label"]; ok && containers[typ] {
|
||||
labels = append(labels, e)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
for _, c := range labels {
|
||||
fit(c)
|
||||
}
|
||||
for _, a := range arrows {
|
||||
if err := bindArrow(a, byID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
linear(a)
|
||||
}
|
||||
for _, c := range labels {
|
||||
t, err := label(c, byID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base(t, now)
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func base(e element, now int64) {
|
||||
setDefault(e, "x", 0.0)
|
||||
setDefault(e, "y", 0.0)
|
||||
if e["type"] != "text" && e["type"] != "arrow" && e["type"] != "line" {
|
||||
setDefault(e, "width", 100.0)
|
||||
setDefault(e, "height", 100.0)
|
||||
}
|
||||
setDefault(e, "angle", 0)
|
||||
setDefault(e, "strokeColor", "#1e1e1e")
|
||||
setDefault(e, "backgroundColor", "transparent")
|
||||
setDefault(e, "fillStyle", "solid")
|
||||
setDefault(e, "strokeWidth", 2)
|
||||
setDefault(e, "strokeStyle", "solid")
|
||||
setDefault(e, "roughness", 1)
|
||||
setDefault(e, "opacity", 100)
|
||||
setDefault(e, "groupIds", []any{})
|
||||
setDefault(e, "frameId", nil)
|
||||
setDefault(e, "boundElements", nil)
|
||||
setDefault(e, "link", nil)
|
||||
setDefault(e, "locked", false)
|
||||
setDefault(e, "isDeleted", false)
|
||||
setDefault(e, "seed", randInt())
|
||||
setDefault(e, "version", 1)
|
||||
setDefault(e, "versionNonce", randInt())
|
||||
setDefault(e, "updated", now)
|
||||
}
|
||||
|
||||
func text(e element) error {
|
||||
s, ok := e["text"].(string)
|
||||
if !ok {
|
||||
return errors.New("texte sans champ text")
|
||||
}
|
||||
setDefault(e, "originalText", s)
|
||||
setDefault(e, "fontSize", 20)
|
||||
setDefault(e, "fontFamily", 5) // Excalifont
|
||||
setDefault(e, "textAlign", "left")
|
||||
setDefault(e, "verticalAlign", "top")
|
||||
setDefault(e, "containerId", nil)
|
||||
setDefault(e, "autoResize", true)
|
||||
setDefault(e, "lineHeight", 1.25)
|
||||
w, h := textSize(s, numOr(e, "fontSize", 20), numOr(e, "lineHeight", 1.25))
|
||||
setDefault(e, "width", w)
|
||||
setDefault(e, "height", h)
|
||||
return nil
|
||||
}
|
||||
|
||||
func addBound(c element, id, typ string) {
|
||||
list, _ := c["boundElements"].([]any)
|
||||
for _, b := range list {
|
||||
if m, ok := b.(map[string]any); ok && m["id"] == id {
|
||||
return
|
||||
}
|
||||
}
|
||||
c["boundElements"] = append(list, map[string]any{"id": id, "type": typ})
|
||||
}
|
||||
|
||||
// label transforme le raccourci "label" d'un conteneur en élément texte lié.
|
||||
func label(c element, byID map[string]element) (element, error) {
|
||||
l, _ := c["label"].(map[string]any)
|
||||
delete(c, "label")
|
||||
s, _ := l["text"].(string)
|
||||
if s == "" {
|
||||
return nil, fmt.Errorf("élément %v : label sans texte", c["id"])
|
||||
}
|
||||
id := c["id"].(string) + "-label"
|
||||
if _, dup := byID[id]; dup {
|
||||
id = randID()
|
||||
}
|
||||
fontSize := numOr(l, "fontSize", 20)
|
||||
w, h := textSize(s, fontSize, 1.25)
|
||||
cx, cy := center(c)
|
||||
t := element{
|
||||
"id": id, "type": "text", "text": s, "originalText": s,
|
||||
"fontSize": fontSize, "fontFamily": numOr(l, "fontFamily", 5),
|
||||
"textAlign": "center", "verticalAlign": "middle", "containerId": c["id"],
|
||||
"autoResize": true, "lineHeight": 1.25, "roundness": nil,
|
||||
"x": cx - w/2, "y": cy - h/2, "width": w, "height": h,
|
||||
"strokeColor": c["strokeColor"],
|
||||
}
|
||||
if col, ok := l["strokeColor"].(string); ok {
|
||||
t["strokeColor"] = col
|
||||
}
|
||||
addBound(c, id, "text")
|
||||
byID[id] = t
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// fit agrandit une forme (centre conservé) pour que son label y tienne.
|
||||
func fit(c element) {
|
||||
l, _ := c["label"].(map[string]any)
|
||||
s, _ := l["text"].(string)
|
||||
if s == "" || c["type"] == "arrow" {
|
||||
return
|
||||
}
|
||||
tw, th := textSize(s, numOr(l, "fontSize", 20), 1.25)
|
||||
k := 1.0 // part utilisable de la forme pour le texte
|
||||
switch c["type"] {
|
||||
case "ellipse":
|
||||
k = 1 / math.Sqrt2
|
||||
case "diamond":
|
||||
k = 0.5
|
||||
}
|
||||
w, h := numOr(c, "width", 100), numOr(c, "height", 100)
|
||||
nw, nh := math.Max(w, math.Ceil((tw+20)/k)), math.Max(h, math.Ceil((th+20)/k))
|
||||
c["x"] = numOr(c, "x", 0) - (nw-w)/2
|
||||
c["y"] = numOr(c, "y", 0) - (nh-h)/2
|
||||
c["width"], c["height"] = nw, nh
|
||||
}
|
||||
|
||||
func center(e element) (float64, float64) {
|
||||
x, y := numOr(e, "x", 0), numOr(e, "y", 0)
|
||||
if pts, ok := e["points"].([]any); ok && len(pts) >= 2 {
|
||||
// Milieu du segment central d'une flèche.
|
||||
i := len(pts) / 2
|
||||
a, b := point(pts[i-1]), point(pts[i])
|
||||
return x + (a[0]+b[0])/2, y + (a[1]+b[1])/2
|
||||
}
|
||||
return x + numOr(e, "width", 0)/2, y + numOr(e, "height", 0)/2
|
||||
}
|
||||
|
||||
func point(v any) [2]float64 {
|
||||
p, _ := v.([]any)
|
||||
var out [2]float64
|
||||
for i := 0; i < 2 && i < len(p); i++ {
|
||||
out[i], _ = p[i].(float64)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// edge renvoie le point du bord de la forme e dans la direction de (tx, ty).
|
||||
func edge(e element, tx, ty, gap float64) (float64, float64) {
|
||||
cx, cy := center(e)
|
||||
dx, dy := tx-cx, ty-cy
|
||||
if dx == 0 && dy == 0 {
|
||||
return cx, cy
|
||||
}
|
||||
hw, hh := numOr(e, "width", 0)/2+gap, numOr(e, "height", 0)/2+gap
|
||||
var t float64
|
||||
switch e["type"] {
|
||||
case "ellipse":
|
||||
t = 1 / math.Sqrt(dx*dx/(hw*hw)+dy*dy/(hh*hh))
|
||||
case "diamond":
|
||||
t = 1 / (math.Abs(dx)/hw + math.Abs(dy)/hh)
|
||||
default:
|
||||
t = math.Min(hw/math.Max(math.Abs(dx), 1e-9), hh/math.Max(math.Abs(dy), 1e-9))
|
||||
}
|
||||
return cx + dx*t, cy + dy*t
|
||||
}
|
||||
|
||||
// bindArrow applique les raccourcis start/end d'une flèche.
|
||||
func bindArrow(a element, byID map[string]element) error {
|
||||
var ends [2]element
|
||||
for i, k := range []string{"start", "end"} {
|
||||
ref, ok := a[k].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delete(a, k)
|
||||
id, _ := ref["id"].(string)
|
||||
target, ok := byID[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("flèche %v : %s vers l'élément inconnu %q", a["id"], k, id)
|
||||
}
|
||||
ends[i] = target
|
||||
a[k+"Binding"] = map[string]any{"elementId": id, "focus": 0, "gap": 4}
|
||||
addBound(target, a["id"].(string), "arrow")
|
||||
}
|
||||
if _, has := a["points"]; has || ends[0] == nil || ends[1] == nil {
|
||||
return nil
|
||||
}
|
||||
sx, sy := center(ends[0])
|
||||
ex, ey := center(ends[1])
|
||||
x1, y1 := edge(ends[0], ex, ey, 4)
|
||||
x2, y2 := edge(ends[1], sx, sy, 4)
|
||||
a["x"], a["y"] = x1, y1
|
||||
a["points"] = []any{[]any{0.0, 0.0}, []any{x2 - x1, y2 - y1}}
|
||||
return nil
|
||||
}
|
||||
|
||||
// linear complète une flèche ou une ligne : points et encombrement.
|
||||
func linear(e element) {
|
||||
pts, ok := e["points"].([]any)
|
||||
if !ok || len(pts) < 2 {
|
||||
pts = []any{[]any{0.0, 0.0}, []any{numOr(e, "width", 100), numOr(e, "height", 0)}}
|
||||
e["points"] = pts
|
||||
}
|
||||
minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
|
||||
for _, p := range pts {
|
||||
q := point(p)
|
||||
minX, maxX = math.Min(minX, q[0]), math.Max(maxX, q[0])
|
||||
minY, maxY = math.Min(minY, q[1]), math.Max(maxY, q[1])
|
||||
}
|
||||
e["width"], e["height"] = maxX-minX, maxY-minY
|
||||
setDefault(e, "lastCommittedPoint", nil)
|
||||
setDefault(e, "startBinding", nil)
|
||||
setDefault(e, "endBinding", nil)
|
||||
setDefault(e, "startArrowhead", nil)
|
||||
if e["type"] == "arrow" {
|
||||
setDefault(e, "endArrowhead", "arrow")
|
||||
setDefault(e, "elbowed", false)
|
||||
} else {
|
||||
setDefault(e, "endArrowhead", nil)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user