///|
/// 设计系统反向提取:从现有设计反推组件库和令牌。
///
/// 这是"设计考古学"——Agent 拿到一个已有原型,引擎自动:
/// 1. 扫描所有颜色 → 聚类 → 推断语义令牌名(primary/surface/error)
/// 2. 扫描所有间距 → 识别间距体系(4/8/12/16/24)
/// 3. 扫描所有字号 → 映射到标准字号阶(h1=32/h2=24/body=14/caption=12)
/// 4. 检测重复结构 → 推断为同一"组件"的不同实例
/// 5. 输出完整设计系统(可复用于创建新页面)
///
/// Agent 得到的不只是一份分析报告——是一套可以直接 `place_component` 的
/// 令牌表和组件定义。
// ---------------------------------------------------------------------------
// 提取结果
// ---------------------------------------------------------------------------
pub(all) struct ExtractedToken {
name : String // "primary" / "surface" / "accent_1"
value : String // "#4B6BFB"
usage_count : Int // 被多少节点使用
confidence : Double // 0-1(推断置信度)
}
///|
pub(all) struct ExtractedSpacing {
value : Double // 8.0
usage_count : Int
suggested_name : String // "md"
}
///|
pub(all) struct ExtractedFontSize {
value : Double // 24.0
usage_count : Int
suggested_name : String // "h2"
}
///|
pub(all) struct ExtractedComponent {
inferred_name : String // "button" / "card" / "input"
instance_count : Int
common_fill : String
common_radius : Double
common_height : Double
sample_ids : Array[String]
}
///|
pub(all) struct ExtractedDesignSystem {
color_tokens : Array[ExtractedToken]
spacing_tokens : Array[ExtractedSpacing]
font_tokens : Array[ExtractedFontSize]
components : Array[ExtractedComponent]
total_nodes : Int
total_colors : Int
total_sizes : Int
}
// ---------------------------------------------------------------------------
// 主入口:从项目提取设计系统
// ---------------------------------------------------------------------------
///|
pub fn Project::extract_design_system(
self : Project,
artboard~ : String,
) -> Result[ExtractedDesignSystem, String] {
match self.artboards.get(artboard) {
None => Err("unknown_artboard")
Some(ab) => {
let doc = ab.doc
// ── 1. 颜色提取 ──
let color_usage : Map[String, Int] = Map::new()
for id in doc.nodes.keys() {
match doc.nodes.get(id) {
Some(n) => {
// fill
if n.style.fill != "none" && n.style.fill != "" {
let c = n.style.fill
match color_usage.get(c) {
Some(cnt) => color_usage.set(c, cnt + 1)
None => color_usage.set(c, 1)
}
}
// text_color
if n.style.text_color != "" && n.style.text_color != "#111111" {
let tc = n.style.text_color
match color_usage.get(tc) {
Some(cnt) => color_usage.set(tc, cnt + 1)
None => color_usage.set(tc, 1)
}
}
}
None => ()
}
}
// 聚类并推断语义名
let color_tokens = infer_color_tokens(color_usage)
// ── 2. 间距提取 ──
let spacing_usage : Map[Double, Int] = Map::new()
for id in doc.nodes.keys() {
match doc.nodes.get(id) {
Some(n) =>
match n.layout {
Some(l) => {
let g = l.gap
if g > 0.0 {
match spacing_usage.get(g) {
Some(cnt) => spacing_usage.set(g, cnt + 1)
None => spacing_usage.set(g, 1)
}
}
// padding
let p = (
l.padding.top +
l.padding.bottom +
l.padding.left +
l.padding.right
) /
4.0
if p > 0.0 {
match spacing_usage.get(p) {
Some(cnt) => spacing_usage.set(p, cnt + 1)
None => spacing_usage.set(p, 1)
}
}
}
None => ()
}
None => ()
}
}
let spacing_tokens = infer_spacing_tokens(spacing_usage)
// ── 3. 字号提取 ──
let font_usage : Map[Double, Int] = Map::new()
for id in doc.nodes.keys() {
match doc.nodes.get(id) {
Some(n) => {
let fs = n.style.font_size
if fs > 0.0 {
match font_usage.get(fs) {
Some(cnt) => font_usage.set(fs, cnt + 1)
None => font_usage.set(fs, 1)
}
}
}
None => ()
}
}
let font_tokens = infer_font_tokens(font_usage)
// ── 4. 组件模式检测 ──
let components = detect_component_patterns(doc)
Ok({
color_tokens,
spacing_tokens,
font_tokens,
components,
total_nodes: doc.nodes.size() - 1,
total_colors: color_usage.size(),
total_sizes: font_usage.size(),
})
}
}
}
// ---------------------------------------------------------------------------
// 颜色令牌推断
// ---------------------------------------------------------------------------
///|
fn infer_color_tokens(usage : Map[String, Int]) -> Array[ExtractedToken] {
let tokens : Array[ExtractedToken] = []
// 按使用频率排序
let sorted : Array[(String, Int)] = []
for hex in usage.keys() {
match usage.get(hex) {
Some(cnt) => sorted.push((hex, cnt))
None => ()
}
}
// 简单冒泡排序(按 count 降序)
for i in 0.. sorted[i].1 {
let tmp = sorted[i]
sorted[i] = sorted[j]
sorted[j] = tmp
}
}
}
// 推断语义名
let semantic_names = [
"primary", "on_primary", "surface", "surface_variant", "error", "text_primary",
"text_secondary", "outline",
]
for i in 0..= 8 {
// 超过 8 个就标为 accent_N
let name = "accent_" + (i - 7).to_string()
tokens.push({
name,
value: sorted[i].0,
usage_count: sorted[i].1,
confidence: 0.6,
})
} else {
// 基于颜色特征推断语义名
let (r, g, b) = parse_rgb(sorted[i].0)
let brightness = (r.to_double() + g.to_double() + b.to_double()) / 3.0
let is_dark = brightness < 100.0
let is_light = brightness > 200.0
let inferred : String = if is_light && i < 4 {
// 浅色高频 → surface
if sorted[i].0 == "#FFFFFF" || sorted[i].0 == "#FFF" {
"surface"
} else {
"surface_variant"
}
} else if is_dark && sorted[i].1 > 3 {
// 深色高频 → 可能是 primary 或 text
let sat = color_saturation(r, g, b)
if sat > 0.3 {
"primary"
} else {
"text_primary"
}
} else if sorted[i].1 > 2 {
"secondary"
} else {
semantic_names[i]
}
tokens.push({
name: inferred,
value: sorted[i].0,
usage_count: sorted[i].1,
confidence: if i < 3 {
0.9
} else if i < 6 {
0.7
} else {
0.5
},
})
}
}
tokens
}
///|
fn parse_rgb(hex : String) -> (Int, Int, Int) {
let body = if hex.length() > 0 && hex.get_char(0) == Some('#') {
hex.substring(start=1)
} else {
hex
}
if body.length() < 6 {
return (0, 0, 0)
}
(hex_val4(body, 0), hex_val4(body, 2), hex_val4(body, 4))
}
///|
fn hex_val4(s : String, offset : Int) -> Int {
if s.length() < offset + 2 {
return 0
}
let hi = hex_digit4(s.get_char(offset))
let lo = hex_digit4(s.get_char(offset + 1))
hi * 16 + lo
}
///|
fn hex_digit4(c : Char?) -> Int {
match c {
Some(ch) => {
let code = ch.to_int()
if code >= 48 && code <= 57 {
code - 48
} else if code >= 97 && code <= 102 {
code - 87
} else if code >= 65 && code <= 70 {
code - 55
} else {
0
}
}
None => 0
}
}
///|
fn color_saturation(r : Int, g : Int, b : Int) -> Double {
let max_v = max3(r, g, b).to_double()
let min_v = min3(r, g, b).to_double()
if max_v == 0.0 {
return 0.0
}
(max_v - min_v) / max_v
}
///|
fn max3(a : Int, b : Int, c : Int) -> Int {
let m = if a > b { a } else { b }
if m > c {
m
} else {
c
}
}
///|
fn min3(a : Int, b : Int, c : Int) -> Int {
let m = if a < b { a } else { b }
if m < c {
m
} else {
c
}
}
// ---------------------------------------------------------------------------
// 间距令牌推断
// ---------------------------------------------------------------------------
///|
fn infer_spacing_tokens(usage : Map[Double, Int]) -> Array[ExtractedSpacing] {
let tokens : Array[ExtractedSpacing] = []
// 标准间距名映射
let name_map : Array[(Double, String)] = [
(2.0, "xs"),
(4.0, "sm"),
(8.0, "md"),
(12.0, "lg"),
(16.0, "xl"),
(24.0, "xxl"),
(32.0, "xxxl"),
(48.0, "huge"),
]
for v in usage.keys() {
match usage.get(v) {
Some(cnt) => {
// 找最近的标准名
let mut best_name = "custom_" + v.to_int().to_string()
for pair in name_map.iter() {
if pair.0 == v {
best_name = pair.1
break
}
}
tokens.push({ value: v, usage_count: cnt, suggested_name: best_name, })
}
None => ()
}
}
tokens
}
// ---------------------------------------------------------------------------
// 字号令牌推断
// ---------------------------------------------------------------------------
///|
fn infer_font_tokens(usage : Map[Double, Int]) -> Array[ExtractedFontSize] {
let tokens : Array[ExtractedFontSize] = []
// 标准字号映射
let name_map : Array[(Double, String)] = [
(10.0, "overline"),
(12.0, "caption"),
(14.0, "body"),
(16.0, "h4"),
(20.0, "h3"),
(24.0, "h2"),
(32.0, "h1"),
(48.0, "display"),
]
for v in usage.keys() {
match usage.get(v) {
Some(cnt) => {
let mut best_name = "custom_" + v.to_int().to_string()
for pair in name_map.iter() {
if pair.0 == v {
best_name = pair.1
break
}
}
tokens.push({ value: v, usage_count: cnt, suggested_name: best_name, })
}
None => ()
}
}
tokens
}
// ---------------------------------------------------------------------------
// 组件模式检测
// ---------------------------------------------------------------------------
///|
fn detect_component_patterns(doc : Document) -> Array[ExtractedComponent] {
let components : Array[ExtractedComponent] = []
// 按 (kind, component, fill, radius) 分组
let groups : Map[String, Array[String]] = Map::new()
let styles : Map[String, (String, Double, Double)] = Map::new() // group_key → (fill, radius, height)
for id in doc.nodes.keys() {
match doc.nodes.get(id) {
Some(n) =>
if id != "root" {
let key = kind_str(n) +
"|" +
n.component +
"|" +
n.style.fill +
"|" +
n.style.corner_radius.to_int().to_string()
match groups.get(key) {
Some(arr) => arr.push(id)
None => {
let arr : Array[String] = [id]
groups.set(key, arr)
}
}
if !styles.contains(key) {
styles.set(key, (n.style.fill, n.style.corner_radius, 0.0))
}
}
None => ()
}
}
// 只有 ≥2 个实例的才算"组件模式"
for key in groups.keys() {
match groups.get(key) {
Some(ids) =>
if ids.length() >= 2 {
match styles.get(key) {
Some((fill, radius, _h)) => {
// 从 key 提取组件名
let parts : Array[StringView] = []
for p2 in key.split("|") {
parts.push(p2)
}
let comp_name = if parts.length() >= 2 {
parts[1].to_string()
} else {
"unknown"
}
components.push({
inferred_name: comp_name,
instance_count: ids.length(),
common_fill: fill,
common_radius: radius,
common_height: 44.0, // 简化:取第一个的高度
sample_ids: ids.copy(),
})
}
None => ()
}
}
None => ()
}
}
components
}
// ---------------------------------------------------------------------------
// 输出 JSON
// ---------------------------------------------------------------------------
///|
pub fn ExtractedDesignSystem::to_json(self : ExtractedDesignSystem) -> String {
let sb = StringBuilder::new()
sb.write_string("{\n")
// 概要
sb.write_string(" \"summary\": {\n")
let tn = self.total_nodes.to_string()
let tc = self.total_colors.to_string()
let ts = self.total_sizes.to_string()
sb.write_string(" \"total_nodes\": " + tn + ",\n")
sb.write_string(" \"total_colors\": " + tc + ",\n")
sb.write_string(" \"total_sizes\": " + ts + "\n")
sb.write_string(" },\n")
// 颜色令牌
sb.write_string(" \"color_tokens\": [\n")
for i in 0.. 0 {
sb.write_string(",\n")
}
let t = self.color_tokens[i]
let uc = t.usage_count.to_string()
let cf = t.confidence.to_string()
sb.write_string(
" {\"name\": \"" +
t.name +
"\", \"value\": \"" +
t.value +
"\", \"usage\": " +
uc +
", \"confidence\": " +
cf +
"}",
)
}
sb.write_string("\n ],\n")
// 组件模式
sb.write_string(" \"components\": [\n")
for i in 0.. 0 {
sb.write_string(",\n")
}
let c = self.components[i]
let ic = c.instance_count.to_string()
sb.write_string(
" {\"name\": \"" +
c.inferred_name +
"\", \"instances\": " +
ic +
", \"fill\": \"" +
c.common_fill +
"\"}",
)
}
sb.write_string("\n ]\n")
sb.write_string("}")
sb.to_string()
}
///|
/// 人类可读摘要
pub fn ExtractedDesignSystem::summary(self : ExtractedDesignSystem) -> String {
let sb = StringBuilder::new()
sb.write_string("Extracted Design System:\n")
sb.write_string(
" Colors: " + self.color_tokens.length().to_string() + " tokens\n",
)
for t in self.color_tokens.iter() {
sb.write_string(
" " +
t.name +
" = " +
t.value +
" (used " +
t.usage_count.to_string() +
"x)\n",
)
}
sb.write_string(
" Spacing: " + self.spacing_tokens.length().to_string() + " values\n",
)
for t in self.spacing_tokens.iter() {
sb.write_string(
" " + t.suggested_name + " = " + t.value.to_int().to_string() + "px\n",
)
}
sb.write_string(
" Typography: " + self.font_tokens.length().to_string() + " sizes\n",
)
for t in self.font_tokens.iter() {
sb.write_string(
" " + t.suggested_name + " = " + t.value.to_int().to_string() + "px\n",
)
}
sb.write_string(
" Components: " + self.components.length().to_string() + " patterns\n",
)
for c in self.components.iter() {
sb.write_string(
" " +
c.inferred_name +
" (" +
c.instance_count.to_string() +
" instances)\n",
)
}
sb.to_string()
}
///|
fn kind_str(n : Node) -> String {
match n.kind {
Frame => "frame"
Group => "group"
Rect => "rect"
Text => "text"
Image => "image"
}
}
///|
/// 令牌名 → CSS 自定义属性片(小写;非字母数字归一为 -,首尾/连续 dash 压缩)
fn design_slug(name : String) -> String {
let lower = name.to_lower()
let sb = StringBuilder::new()
let mut prev_dash = true
for c in lower.iter() {
if is_ascii_alnum(c) {
sb.write_char(c)
prev_dash = false
} else if !prev_dash {
sb.write_char('-')
prev_dash = true
}
}
let s = sb.to_string()
let mut end = s.length()
while end > 0 {
match s.get_char(end - 1) {
Some('-') => end = end - 1
_ => break
}
}
s.substring(start=0, end=end)
}
///|
/// 设计系统导出:把反向提取结果打包为可直接消费的形态——tokens JSON
/// (机器)+ CSS Custom Properties(前端直用)。与 extract_design_system
/// 同源同判据,这是导出形态,不是新的提取口径。
pub fn Project::export_design_system(
self : Project,
artboard~ : String,
) -> Result[String, String] {
match self.extract_design_system(artboard~) {
Err(e) => Err(e)
Ok(ds) => {
let sb = StringBuilder::new()
sb.write_string(
"/* MoonViz design tokens — extracted from \{artboard} */\n:root {\n",
)
// 语义名推断可能把不同值归到同名(两个色都叫 surface)——CSS 同名
// 自定义属性后者静默覆盖前者,令牌交付物不允许丢值:slug 冲突加序号。
// 去重键带组前缀:CSS 变量名自带 --ds-{group}- 前缀,跨组同 slug
// 本不冲突(18px 同为间距与字号时各自叫 --ds-spacing-*/--ds-font-*),
// 只在组内去重——跨组合表会把无冲突的令牌强行改名成 -2。
let used : Map[String, Bool] = Map::new()
fn emit_var(sb : StringBuilder, used : Map[String, Bool], group : String, raw : String, value : String) -> Unit {
let base = design_slug(raw)
let mut slug = base
let mut n = 2
while used.contains("\{group}:\{slug}") {
slug = "\{base}-\{n}"
n = n + 1
}
used.set("\{group}:\{slug}", true)
sb.write_string(" --ds-\{group}-\{slug}: \{value};\n")
}
for t in ds.color_tokens.iter() {
emit_var(sb, used, "color", t.name, t.value)
}
for t in ds.spacing_tokens.iter() {
emit_var(sb, used, "spacing", t.suggested_name, "\{t.value.to_int()}px")
}
for t in ds.font_tokens.iter() {
emit_var(sb, used, "font", t.suggested_name, "\{t.value.to_int()}px")
}
sb.write_string("}\n")
let css = sb.to_string()
Ok(
"{\"ok\":true,\"op\":\"export-design-system\",\"artboard\":\"\{json_escape(artboard)}\",\"counts\":{\"colors\":\{ds.color_tokens.length()},\"spacing\":\{ds.spacing_tokens.length()},\"fonts\":\{ds.font_tokens.length()}},\"tokens\":\{ds.to_json()},\"css\":\"\{json_escape(css)}\"}",
)
}
}
}