///|
/// 多画板项目:真实原型有多个页面(登录→首页→详情→设置),
/// 画板之间有导航流(Flow),共享设计令牌与组件库。
pub(all) struct ArtboardMeta {
id : String
name : String
width : Double
height : Double
mut doc : Document
}
///|
pub(all) struct FlowEdge {
from : String // artboard id
to : String
trigger : String // "tap:login" / "swipe:left" 等
}
///|
pub(all) struct Project {
mut artboards : Map[String, ArtboardMeta]
mut flows : Array[FlowEdge]
mut tokens : DesignTokens
mut components : Map[String, ComponentDef]
mut user_components : Map[String, UserComponentDef]
/// 组件多态状态:node_id → 状态定义(default/pressed/disabled/… 的补丁集)。
/// 存于节点名标记,会话内可变模型由此重建;作用于渲染前的节点变换。
mut node_states : Map[String, ComponentStateDef]
mut revision : Int
/// 令牌自定义覆盖(set_token 产生):随 canonical MBT frontmatter
/// `tokens:` 段往返;load 时在主题恢复后叠加应用。
mut token_overrides : Map[String, String]?
/// 当前主题名(light/dark/nord/…):随 canonical MBT frontmatter 往返,
/// 决定渲染层画布底色等 tokens 派生视觉。节点 fill 已是字面量,
/// 由 apply_theme 的角色重着色负责,加载路径只恢复 tokens 不再重着色。
mut active_theme : String
/// 宿主批注透传(issue #20):canonical 扫描时收集的非 moonviz 顶层
/// HTML 注释。写路径再生 canonical 时原样回写;clone/replace_with 随行。
mut doc_comments : Array[DocComment]
/// frontmatter 未知键透传(issue #20 下半场):canonical 扫描时收集的
/// 非引擎权威键行(如顶层 extra_section / moonviz 下 deepdesign_ink),
/// 写路径原样回写;clone/replace_with 随行。
mut extra_front_matter : Array[String]
/// issue #24:当前生效的 design spec(JSON 文本;空 = 未注入)。
/// norms 的档位判定以 spec 为准(内置仅补 spec 未覆盖的项)。
mut design_spec : String
}
///|
/// 文档级宿主批注(issue #20):引擎零语义,原文透传。
/// anchor 为其后最近的画板 id/name(None = 文档级,序列化在文末)。
pub(all) struct DocComment {
text : String
anchor : String?
}
///|
pub fn Project::new() -> Project {
{
artboards: Map::new(),
flows: [],
tokens: DesignTokens::default(),
components: builtin_components(),
user_components: Map::new(),
node_states: Map::new(),
revision: 1,
active_theme: "light",
token_overrides: None,
doc_comments: [],
extra_front_matter: [],
design_spec: "",
}
}
///|
/// 深拷贝当前项目状态(会话路径的失败回滚用:克隆上应用 op,通过门检
/// 查才 replace_with 回会话,apply 失败不污染会话)。
pub fn Project::clone(self : Project) -> Project {
// 必须深拷贝:replace_with 是 Map 引用赋值,浅拷贝会与原 Project 共享
// artboards 等容器——在克隆上的任何 op 会直接改到原体会话。
let copy = Project::new()
for k in self.artboards.keys() {
match self.artboards.get(k) {
Some(ab) =>
copy.artboards.set(k, {
id: ab.id,
name: ab.name,
width: ab.width,
height: ab.height,
doc: ab.doc.clone(),
})
None => ()
}
}
for f in self.flows.iter() {
copy.flows.push(f)
}
copy.tokens = DesignTokens::default()
for role in self.tokens.colors.keys() {
match self.tokens.colors.get(role) {
Some(v) => copy.tokens.colors.set(role, v)
None => ()
}
}
for k in self.tokens.spacing.keys() {
match self.tokens.spacing.get(k) {
Some(v) => copy.tokens.spacing.set(k, v)
None => ()
}
}
for k in self.tokens.radii.keys() {
match self.tokens.radii.get(k) {
Some(v) => copy.tokens.radii.set(k, v)
None => ()
}
}
for k in self.tokens.typography.keys() {
match self.tokens.typography.get(k) {
Some(v) => copy.tokens.typography.set(k, v)
None => ()
}
}
for cid in self.components.keys() {
match self.components.get(cid) {
Some(def) => copy.components.set(cid, def)
None => ()
}
}
for uid in self.user_components.keys() {
match self.user_components.get(uid) {
Some(def) => copy.user_components.set(uid, def)
None => ()
}
}
for nid in self.node_states.keys() {
match self.node_states.get(nid) {
// ComponentStateDef 值对象深拷贝:states Map 与 mut current 若共享,
// 克隆上的 set-state op 会原地写穿原会话(别名污染)
Some(def) => {
let states_copy : Map[String, NodePatch] = Map::new()
for st in def.states.keys() {
match def.states.get(st) {
Some(patch) => states_copy.set(st, patch)
None => ()
}
}
copy.node_states.set(nid, {
node_id: def.node_id,
states: states_copy,
current: def.current,
})
}
None => ()
}
}
copy.revision = self.revision
copy.active_theme = self.active_theme
match self.token_overrides {
Some(ov) => {
let ov_copy : Map[String, String] = Map::new()
for role in ov.keys() {
match ov.get(role) {
Some(v) => ov_copy.set(role, v)
None => ()
}
}
copy.token_overrides = Some(ov_copy)
}
None => copy.token_overrides = None
}
// 宿主批注随快照深拷贝(issue #20):apply 失败回滚语义下,注释与
// 文档状态同生共死——克隆上不带注释会让通过门检查的替换悄悄丢批注。
copy.doc_comments = []
for cm in self.doc_comments.iter() {
copy.doc_comments.push({ text: cm.text, anchor: cm.anchor, })
}
copy.extra_front_matter = []
for line in self.extra_front_matter.iter() {
copy.extra_front_matter.push(line)
}
copy.design_spec = self.design_spec
copy
}
///|
/// 用从唯一 MBT 源码重建的项目快照原子替换临时计算缓存。
///
/// Studio/CLI 不会持久化这个 Project;每次载入都应来自 `.mbt.md`,它只是
/// 当前命令批次内的可变计算模型。
pub fn Project::replace_with(self : Project, source : Project) -> Unit {
self.artboards = source.artboards
self.flows = source.flows
self.tokens = source.tokens
self.components = source.components
// 会话级注册表同样必须随快照替换:漏掉它们会让 load 后重建的
// 组件状态表与用户组件注册表凭空消失(前者是状态多态的行为来源)。
self.user_components = source.user_components
self.node_states = source.node_states
self.revision = source.revision
self.active_theme = source.active_theme
self.token_overrides = source.token_overrides
self.doc_comments = source.doc_comments
self.extra_front_matter = source.extra_front_matter
self.design_spec = source.design_spec
}
// ---------------------------------------------------------------------------
// 画板管理
// ---------------------------------------------------------------------------
///|
pub fn Project::create_artboard(
self : Project,
name~ : String,
width? : Double = 390.0,
height? : Double = 844.0,
) -> String {
let id = sanitize_id(name)
let doc = Document::new({ width, height, })
self.artboards.set(id, { id, name, width, height, doc, })
self.revision = self.revision + 1
id
}
///|
/// 在画板上放置组件实例
///|
/// place 后尺寸直设(issue #18):AgentGate 在最终 bbox 上评估,消除
/// 「默认尺寸 place → update 修正」的中间态拒绝(真实 LLM run 111 次
/// 拒绝的主根因)。w/h ≤ 0 表示不覆盖(沿用组件默认尺寸)。
fn apply_place_size(
doc : Document,
instance_id : String,
w : Double,
h : Double,
) -> Unit {
if w > 0.0 || h > 0.0 {
let _ = doc.update_node(instance_id, fn(x) {
{
..x,
width: if w > 0.0 {
Fixed(w)
} else {
x.width
},
height: if h > 0.0 {
Fixed(h)
} else {
x.height
},
}
})
}
}
///|
pub fn Project::place_component(
self : Project,
artboard~ : String,
component_id~ : String,
instance_id~ : String,
variant? : String = "",
parent_id? : String = "",
x? : Double = 0.0,
y? : Double = 0.0,
w? : Double = 0.0,
h? : Double = 0.0,
args? : Map[String, String] = Map::new(),
) -> Result[String, String] {
match self.artboards.get(artboard) {
None => Err("unknown_artboard: \{artboard}")
Some(ab) =>
// 用户组件优先:自定义组件与内置同权,同名时自定义遮蔽内置
match self.user_components.get(component_id) {
Some(udef) =>
match
place_user_component(
ab.doc,
udef,
instance_id,
variant~,
args~,
dx=x,
dy=y,
) {
Err(error) => Err(error)
Ok(root_id) => {
apply_place_size(ab.doc, root_id, w, h)
self.revision = self.revision + 1
// node 透传实例真实根节点 id(instance::):
// 调用方据此做 move/update/query(见 usercomp.mbt 的 id 命名契约)。
Ok(
"{\"ok\":true,\"op\":\"place\",\"node\":\"\{root_id}\",\"instance\":\"\{instance_id}\",\"component\":\"\{component_id}\",\"source\":\"user\",\"artboard\":\"\{artboard}\"}",
)
}
}
None =>
match self.components.get(component_id) {
None => Err("unknown_component: \{component_id}")
Some(def) => {
// 未知变体与用户组件路径同权报错(usercomp.mbt 同名检查):
// 拼写错误不再被静默回退吞掉
let v = if variant == "" { def.default_variant } else { variant }
if v != def.default_variant && def.variants.contains(v) == false {
// 错误即文档(对齐 constrain 词表先例):真实 run 里模型最常犯的是
// 漏写 variant 槽——坐标串滑进变体槽(如 `place ab rect id 200 100`
// 的 "200" 被当变体名),只回 `unknown_variant:200` 模型要再烧
// 2-3 轮试错才摸到语法。消息就地携带合法变体与语法模板。
let mut names = ""
for k in def.variants.keys() {
names = names + k + " "
}
return Err(
"unknown_variant:\{v}(组件 \{component_id} 合法变体:\{names}默认:\{def.default_variant})——place 语法:place <板> <组件> [variant|-] [x] [y] [w] [h] [k=v...],不需要变体时该槽写 -",
)
}
let node = instantiate_component(
def,
instance_id,
variant~,
tokens=self.tokens,
)
// 统一边界策略:负坐标夹回 0;右上不夹紧——按默认尺寸夹紧会在
// 先 place 后 resize 的流程里留下被夹坏的坐标(模板 2×2 网格因此
// 重叠)。越界由 contained_in_parent 谓词显式报告为视觉债。
let safe_x = if x < 0.0 { 0.0 } else { x }
let safe_y = if y < 0.0 { 0.0 } else { y }
let placed = {
..node,
x: AlignStart(safe_x),
y: AlignStart(safe_y),
}
let pid = if parent_id == "" { "root" } else { parent_id }
match pid {
"root" => {
let _ = ab.doc.insert_node(placed, "root")
apply_place_size(ab.doc, instance_id, w, h)
self.revision = self.revision + 1
Ok(
"{\"ok\":true,\"op\":\"place\",\"node\":\"\{instance_id}\",\"x\":\{safe_x},\"y\":\{safe_y},\"artboard\":\"\{artboard}\"}",
)
}
_ =>
match ab.doc.insert_node(placed, pid) {
true => {
apply_place_size(ab.doc, instance_id, w, h)
self.revision = self.revision + 1
Ok(
"{\"ok\":true,\"op\":\"place\",\"node\":\"\{instance_id}\",\"x\":\{safe_x},\"y\":\{safe_y},\"parent\":\"\{pid}\"}",
)
}
false => Err("unknown_parent: \{pid}")
}
}
}
}
}
}
}
///|
fn size_value(spec : SizeSpec, fallback : Double) -> Double {
match spec {
Fixed(v) => v
_ => fallback
}
}
///|
/// 删除画板:至少保留一个画板;同时清理引用该画板的交互流。
/// 节点随画板 doc 整体移除,无需逐个 delete。
pub fn Project::delete_artboard(
self : Project,
id~ : String,
) -> Result[String, String] {
if self.artboards.size() <= 1 {
return Err("cannot_delete_last_artboard")
}
match self.artboards.get(id) {
None => Err("unknown_artboard: \{id}")
Some(_) => {
self.artboards.remove(id)
let kept : Array[FlowEdge] = self.flows.filter(f => {
f.from != id && f.to != id
})
let removed_flows = self.flows.length() - kept.length()
self.flows = kept
self.revision = self.revision + 1
Ok(
"{\"ok\":true,\"op\":\"delete_artboard\",\"artboard\":\"\{id}\",\"flows_removed\":\{removed_flows}}",
)
}
}
}
///|
fn max_coord(v : Double) -> Double {
if v < 0.0 {
0.0
} else {
v
}
}
///|
fn clamp_coord(v : Double, lo : Double, hi : Double) -> Double {
if v < lo {
lo
} else if v > hi {
hi
} else {
v
}
}
// ---------------------------------------------------------------------------
// 导航流
// ---------------------------------------------------------------------------
///|
pub fn Project::add_flow(
self : Project,
from~ : String,
to~ : String,
trigger? : String = "tap",
) -> Unit {
// 审查修复:canonical 含 flows 段——变更必须推进 revision 以失效
// 会话级 canon_cache(否则新导航边不出现在 canonical 里)
self.flows.push({ from, to, trigger, })
self.revision = self.revision + 1
}
///|
///|
/// 删除单条导航边(issue #12):画错一条边不再需要整板删除重建。
/// trigger 写法与 add_flow 的调用约定对齐("tap:")。
pub fn Project::remove_flow(
self : Project,
from~ : String,
to~ : String,
trigger~ : String,
) -> Result[Unit, String] {
let before = self.flows.length()
self.flows = self.flows.filter(fn(f) {
!(f.from == from && f.to == to && f.trigger == trigger)
})
if self.flows.length() == before {
Err("unknown_flow: \{from} -> \{to} (\{trigger})")
} else {
self.revision = self.revision + 1
Ok(())
}
}
///|
pub fn Project::flow_json(self : Project) -> String {
let parts : Array[String] = []
for e in self.flows.iter() {
parts.push(
"{\"from\":\"\{e.from}\",\"to\":\"\{e.to}\",\"trigger\":\"\{e.trigger}\"}",
)
}
let j = join_str4(parts)
"[\{j}]"
}
///|
fn join_str4(parts : Array[String]) -> String {
let sb = StringBuilder::new()
for i in 0.. 0 {
sb.write_string(",")
}
sb.write_string(parts[i])
}
sb.to_string()
}
// ---------------------------------------------------------------------------
// 查询
// ---------------------------------------------------------------------------
///|
pub fn Project::list_artboards_json(self : Project) -> String {
let parts : Array[String] = []
for id in self.artboards.keys() {
match self.artboards.get(id) {
Some(ab) => {
let nv = ab.doc.nodes.size()
parts.push(
"{\"id\":\"\{ab.id}\",\"name\":\"\{ab.name}\",\"width\":\{ab.width},\"height\":\{ab.height},\"nodes\":\{nv}}",
)
}
None => ()
}
}
let j = join_str4(parts)
"[\{j}]"
}
///|
/// 按条件查询画板中的节点
pub fn Project::query_nodes(
self : Project,
artboard~ : String,
kind? : String = "",
component? : String = "",
text_contains? : String = "",
) -> Result[String, String] {
match self.artboards.get(artboard) {
None => Err("unknown_artboard: \{artboard}")
Some(ab) => {
let results : Array[String] = []
let layout = solve(ab.doc)
for id in ab.doc.nodes.keys() {
match ab.doc.nodes.get(id) {
Some(n) => {
let kind_match = kind == "" || kind_of(n) == kind
let comp_match = component == "" || n.component == component
let text_match = text_contains == "" ||
n.text.contains(text_contains)
if kind_match && comp_match && text_match && id != "root" {
match layout.rect_of(id) {
Some(r) => {
// parent 字段(issue #13):图层树的父子关系此前在 wasm
// 各读面均不可达;root 顶层节点的 parent 即 "root"
let parent = match ab.doc.find_parent(id) {
Some(p) => p
None => ""
}
results.push(query_node_json(id, n, r, parent))
}
None => ()
}
}
}
None => ()
}
}
Ok("[\{join_str4(results)}]")
}
}
}
///|
/// 单个节点的查询 JSON。
/// 必须与 NodeStyle / Node 的完整字段保持同步:Studio 属性面板、
/// 图层名与布局控件都从这里取值;漏字段会让引擎已支持的能力在界面上不可达。
fn query_node_json(id : String, n : Node, r : Rect, parent : String) -> String {
let layout_json = match n.layout {
Some(l) =>
"{\"dir\":\"" +
(match l.dir {
Vertical => "vertical"
Horizontal => "horizontal"
}) +
"\",\"gap\":" +
l.gap.to_string() +
",\"justify\":\"" +
(match l.justify {
Start => "start"
Center => "center"
End => "end"
}) +
"\",\"padding\":" +
l.padding.top.to_string() +
"}"
None => "null"
}
let out = StringBuilder::new()
out.write_string("{\"id\":\"" + id + "\",")
out.write_string("\"parent\":\"" + json_escape_field(parent) + "\",")
out.write_string("\"kind\":\"" + kind_of(n) + "\",")
out.write_string("\"component\":\"" + json_escape_field(n.component) + "\",")
out.write_string("\"name\":\"" + json_escape_field(n.name) + "\",")
out.write_string("\"text\":\"" + json_escape_field(n.text) + "\",")
out.write_string(
"\"rect\":{\"x\":" +
r.x.to_string() +
",\"y\":" +
r.y.to_string() +
",\"w\":" +
r.width.to_string() +
",\"h\":" +
r.height.to_string() +
"},",
)
out.write_string("\"constraint\":\"" + n.constraint + "\",")
out.write_string("\"layout\":" + layout_json + ",")
out.write_string("\"width_mode\":\"" + size_mode(n.width) + "\",")
out.write_string("\"height_mode\":\"" + size_mode(n.height) + "\",")
out.write_string("\"style\":{")
out.write_string("\"fill\":\"" + n.style.fill + "\",")
out.write_string("\"text_color\":\"" + n.style.text_color + "\",")
out.write_string("\"stroke\":\"" + n.style.stroke + "\",")
out.write_string("\"stroke_width\":" + n.style.stroke_width.to_string() + ",")
out.write_string("\"radius\":" + n.style.corner_radius.to_string() + ",")
out.write_string("\"opacity\":" + n.style.opacity.to_string() + ",")
out.write_string("\"font_size\":" + n.style.font_size.to_string() + ",")
out.write_string("\"weight\":\"" + n.style.font_weight + "\",")
out.write_string("\"shadow\":\"" + n.style.shadow + "\",")
out.write_string("\"rotate\":" + n.style.rotate.to_string() + ",")
out.write_string("\"blur\":" + n.style.blur.to_string() + ",")
out.write_string("\"blend\":\"" + n.style.blend + "\",")
out.write_string("\"line\":" + n.style.line_height.to_string() + ",")
out.write_string("\"tracking\":" + n.style.tracking.to_string() + ",")
out.write_string("\"align\":\"" + n.style.text_align + "\",")
out.write_string("\"italic\":\"" + n.style.font_style + "\",")
out.write_string("\"dash\":\"" + n.style.stroke_dash + "\",")
out.write_string(
"\"visible\":" + (if n.style.visible { "true" } else { "false" }),
)
out.write_string("}}")
out.to_string()
}
///|
/// 尺寸规格 → 模式名(供属性面板回显)
fn size_mode(s : SizeSpec) -> String {
match s {
Fixed(_) => "fixed"
Fill => "fill"
Hug => "hug"
}
}
///|
fn is_frame_kind(n : Node) -> Bool {
match n.kind {
Frame => true
_ => false
}
}
///|
fn is_rect_kind(n : Node) -> Bool {
match n.kind {
Rect => true
_ => false
}
}
///|
fn is_text_kind(n : Node) -> Bool {
match n.kind {
Text => true
_ => false
}
}
///|
fn kind_of(n : Node) -> String {
match n.kind {
Frame => "frame"
Group => "group"
Rect => "rect"
Text => "text"
Image => "image"
}
}
///|
/// JSON 字符串字段转义(query 输出用)
fn json_escape_field(s : String) -> String {
let sb = StringBuilder::new()
for c in s.iter() {
match c {
'"' => sb.write_string("\\\"")
'\\' => sb.write_string("\\\\")
'\n' => sb.write_string("\\n")
'\t' => sb.write_string("\\t")
_ => sb.write_char(c)
}
}
sb.to_string()
}
// ---------------------------------------------------------------------------
// 设计 Lint(超越不崩谓词的质量检查)
// ---------------------------------------------------------------------------
///|
pub(all) struct DesignLint {
rule : String
severity : String // "error" | "warning" | "info"
node_id : String
message : String
suggestion : String
}
///|
/// 设计质量检查:对比度/触控目标/间距/一致性
pub fn Project::lint(
self : Project,
artboard~ : String,
) -> Result[String, String] {
match self.artboards.get(artboard) {
None => Err("unknown_artboard: \{artboard}")
Some(ab) => {
let layout = solve(ab.doc)
let lints : Array[String] = []
for id in ab.doc.nodes.keys() {
match ab.doc.nodes.get(id) {
Some(n) => {
if id == "root" {
continue
}
match layout.rect_of(id) {
Some(r) => {
// L1: 触控目标(最小 44×44)
if r.width < 44.0 || r.height < 44.0 {
if n.component == "button" ||
n.component == "text_input" ||
is_rect_kind(n) {
lints.push(
lint_json(
"touch_target",
"warning",
id,
"触控目标 \{r.width.to_int()}×\{r.height.to_int()} 小于 44×44",
"增大到至少 44×44 以满足无障碍标准",
),
)
}
}
// L2: 文字对比度
if is_text_kind(n) || n.text != "" {
let fg = n.style.text_color
let bg = if n.style.fill != "none" && n.style.fill != "" {
n.style.fill
} else {
"#FFFFFF"
}
let ratio = contrast_ratio(fg, bg)
if ratio < 4.5 {
lints.push(
lint_json(
"contrast",
"error",
id,
"文字对比度 \{ratio.to_string()} 低于 WCAG AA 4.5:1",
"调整 text_color 或背景色以提高对比度",
),
)
}
}
// L3: 间距一致性(子节点间距应统一)
if is_frame_kind(n) && n.children.length() > 1 {
match n.layout {
Some(l) =>
if l.gap < 4.0 && n.children.length() > 2 {
lints.push(
lint_json(
"spacing",
"info",
id,
"容器内 \{n.children.length()} 个子元素间距仅 \{l.gap},可能过于紧凑",
"建议间距 ≥ 8(令牌 md)",
),
)
}
None => ()
}
}
// L4: 空容器
if is_frame_kind(n) &&
n.children.length() == 0 &&
n.component != "divider" {
lints.push(
lint_json(
"empty_container",
"warning",
id,
"容器「\{n.name}」无子元素",
"添加内容或删除空容器",
),
)
}
}
None => ()
}
}
None => ()
}
}
Ok("[\{join_str4(lints)}]")
}
}
}
///|
fn lint_json(
rule : String,
severity : String,
node_id : String,
msg : String,
suggestion : String,
) -> String {
"{\"rule\":\"\{rule}\",\"severity\":\"\{severity}\",\"node_id\":\"\{node_id}\",\"message\":\"\{msg}\",\"suggestion\":\"\{suggestion}\"}"
}
///|
/// WCAG 相对亮度
fn relative_luminance(hex : String) -> Double {
let r = hex_val(hex, 0) / 255.0
let g = hex_val(hex, 2) / 255.0
let b = hex_val(hex, 4) / 255.0
let f = fn(c : Double) -> Double {
if c <= 0.03928 {
c / 12.92
} else {
Double::pow((c + 0.055) / 1.055, 2.4)
}
}
0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
}
///|
fn hex_val(hex : String, offset : Int) -> Double {
let body = if hex.length() > 0 && hex.get_char(0) == Some('#') {
hex.substring(start=1)
} else {
hex
}
if body.length() < offset + 2 {
return 128.0
}
let hi = hex_digit_val(body.get_char(offset))
let lo = hex_digit_val(body.get_char(offset + 1))
(hi * 16 + lo).to_double()
}
///|
fn hex_digit_val(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 {
8
}
}
None => 8
}
}
///|
/// WCAG 对比度
pub fn contrast_ratio(fg : String, bg : String) -> Double {
let l1 = relative_luminance(fg)
let l2 = relative_luminance(bg)
let lighter = if l1 > l2 { l1 } else { l2 }
let darker = if l1 > l2 { l2 } else { l1 }
(lighter + 0.05) / (darker + 0.05)
}
// ---------------------------------------------------------------------------
// 修复建议
// ---------------------------------------------------------------------------
///|
/// 针对不崩谓词违规给出可执行的修复补丁
pub fn Project::suggest_fix(
self : Project,
artboard~ : String,
node_id~ : String,
) -> Result[String, String] {
match self.artboards.get(artboard) {
None => Err("unknown_artboard")
Some(ab) => {
let violations = validate(ab.doc)
let targeted : Array[String] = []
for v in violations.iter() {
if v.node_id == node_id {
let suggestion : String = match v.predicate {
ContainedInParent =>
"缩小 width/height 或改用 Fill(自适应父容器),或增大父容器尺寸"
NoSiblingOverlap =>
"调整 x/y 位置避免与兄弟重叠,或改用栈布局自动排列"
ConstraintSolvable =>
"检查尺寸规格组合(Fill 需有界父容器),简化为 Fixed"
_ => "检查节点结构"
}
targeted.push(
"{\"predicate\":\"\{v.predicate.name()}\",\"node_id\":\"\{v.node_id}\",\"detail\":\"\{v.detail}\",\"fix\":\"\{suggestion}\"}",
)
}
}
if targeted.length() == 0 {
Ok("{\"ok\":true,\"message\":\"节点 \{node_id} 无需修复\"}")
} else {
Ok("{\"ok\":false,\"fixes\":[\{join_str4(targeted)}]}")
}
}
}
}
// ---------------------------------------------------------------------------
// 项目导出
// ---------------------------------------------------------------------------
///|
pub fn Project::export_svg(
self : Project,
artboard~ : String,
) -> Result[String, String] {
match self.artboards.get(artboard) {
None => Err("unknown_artboard")
Some(ab) =>
Ok(
render_svg(
ab.doc,
surface=self.tokens.resolve_color("surface"),
frame=self.tokens.resolve_color("outline"),
),
)
}
}
///|
fn sanitize_id(name : String) -> String {
let sb = StringBuilder::new()
for c in name.iter() {
let code = c.to_int()
if (code >= 97 && code <= 122) || (code >= 48 && code <= 57) || code == 95 {
sb.write_char(c)
} else if code >= 65 && code <= 90 {
sb.write_char(Char::from_int(code + 32))
} else {
sb.write_char('_')
}
}
sb.to_string()
}