/// 渲染桥接层:从文档模型生成 SVG。
/// 渲染层不承担正确性责任——能走到这里的文档都已通过(或被人类路线明确
/// 接受为)验证层的谓词检查。
///
/// v2 现代视觉:elevation 阴影令牌、系统字体栈、多行文本、浅底衬托白卡。
///|
/// 文档 → SVG 字符串(根矩形即画布尺寸,浅底)
///
/// surface 为画布底色令牌值:主题切换后由 Project 侧传入当前主题的
/// surface(如 nord 的 #2E3440);默认浅色 #F6F7F9 保持既有文档兼容。
pub fn render_svg(
doc : Document,
surface? : String = "#F6F7F9",
frame? : String = "#C9CFDA",
) -> String {
let layout = solve(doc)
let root_rect : Rect = match layout.rect_of(doc.root_id) {
Some(r) => r
None => Rect::new(0.0, 0.0, 390.0, 844.0)
}
let sb = StringBuilder::new()
sb.write_string(
"")
sb.to_string()
}
///|
fn render_node(
sb : StringBuilder,
doc : Document,
id : String,
layout : SolvedLayout,
surface~ : String,
frame~ : String,
) -> Unit {
match doc.nodes.get(id) {
None => ()
// 隐藏节点:整棵子树不参与渲染(文档中仍保留,可随时 visible=true 恢复)
Some(n) if !n.style.visible => ()
Some(n) =>
match layout.rect_of(id) {
None => ()
Some(r) => {
// 每个节点包一层 :编辑器据此把 DOM 元素与场景图节点关联
sb.write_string("")
// issue #22:radius=999 只在方形节点上是圆;非方形必须钳制为
// min(r, min(w,h)/2) 并同时输出 rx/ry,否则 SVG 各自半边钳制
// 产出极端椭圆(420×40 搜索框 rx=210/ry=20)。
let rr = if n.style.corner_radius > r.width / 2.0 ||
n.style.corner_radius > r.height / 2.0 {
if r.width < r.height { r.width / 2.0 } else { r.height / 2.0 }
} else {
n.style.corner_radius
}
match n.kind {
Text => render_text_node(sb, n, r)
Image => {
// text 承载 src:http(s)/data URL → 真 (圆角裁剪);
// 空/非 URL → 占位渲染(浅灰底 + 山形/太阳图标)
let src = xml_escape(n.text)
if has_url_prefix(src) {
if n.style.corner_radius > 0.0 {
sb.write_string(
"",
)
sb.write_string(
"",
)
} else {
sb.write_string(
"",
)
}
} else {
// 图片占位:浅灰底 + 山形/太阳图标 + 细边框,任何尺寸下可辨识
sb.write_string(
"",
)
let ic = r.width / 6.0
if ic > 2.0 {
let cx = r.x + r.width / 2.0
let cy = r.y + r.height / 2.0
let iw = ic * 2.4
let ih = ic * 1.5
sb.write_string(
"",
)
sb.write_string(
"",
)
}
}
}
_ => {
// Frame / Group / Rect
sb.write_string(
" 0.0 {
n.style.stroke_width
} else {
1.0
}
sb.write_string(
" stroke=\"\{n.style.stroke}\" stroke-width=\"\{sw}\"",
)
match dash_array(n.style.stroke_dash) {
Some(da) => sb.write_string(" stroke-dasharray=\"\{da}\"")
None => ()
}
} else if n.style.fill == "none" && n.text == "" {
// 无填充无文字的容器:细虚线边界,避免"幽灵组件"融进背景
sb.write_string(
" stroke=\"\{frame}\" stroke-width=\"1\" stroke-dasharray=\"4 3\"",
)
}
if n.style.opacity != 1.0 {
sb.write_string(" opacity=\"\{n.style.opacity}\"")
}
// 单一 filter 属性:shadow+blur 同设时引用合并滤镜 def(fx_,
// collect_gradients 生成)——输出两个同名属性是 XML 非良构,
// 浏览器取首个导致 blur 静默丢失,严格解析器直接拒绝整个 SVG
if n.style.shadow != "none" && n.style.blur > 0.0 {
sb.write_string(" filter=\"url(#fx_\{svg_id_tag(n.id)})\"")
} else if n.style.shadow != "none" {
sb.write_string(" filter=\"url(#\{n.style.shadow})\"")
} else if n.style.blur > 0.0 {
sb.write_string(" filter=\"url(#blur_\{svg_id_tag(n.id)})\"")
}
// 单一 transform 属性:变换列表从右向左作用,
// flip 矩阵写在左侧即左乘 rotate(先转后翻)
let transforms : Array[String] = []
if n.style.flip != "none" {
let cx = r.x + r.width / 2.0
let cy = r.y + r.height / 2.0
let m = match n.style.flip {
"h" => "-1,1"
"v" => "1,-1"
_ => "-1,-1"
}
transforms.push(
"translate(\{cx} \{cy}) scale(\{m}) translate(\{-cx} \{-cy})",
)
}
if n.style.rotate != 0.0 {
let cx = r.x + r.width / 2.0
let cy = r.y + r.height / 2.0
transforms.push("rotate(\{n.style.rotate} \{cx} \{cy})")
}
if transforms.length() > 0 {
sb.write_string(" transform=\"\{jstr8(transforms, " ")}\"")
}
sb.write_string("/>")
// 带文字的形状(按钮 / 输入框占位符):默认水平垂直居中,
// align=left/right 时改为左/右对齐到内边距
if n.text != "" {
let baseline = r.y + (r.height + n.style.font_size * 0.7) / 2.0
let pad = 12.0
let (tx, anchor) = match n.style.text_align {
"left" => (r.x + pad, " start")
"right" => (r.x + r.width - pad, " end")
_ => (r.x + r.width / 2.0, " middle")
}
sb.write_string(
"\{xml_escape(n.text)}",
)
}
}
}
for cid in n.children.iter() {
render_node(sb, doc, cid, layout, surface~, frame~)
}
sb.write_string("")
}
}
}
}
///|
/// 文本节点:支持 \n 多行、自定义行高、字距
fn render_text_node(sb : StringBuilder, n : Node, r : Rect) -> Unit {
let lines = split_lines(n.text)
let lh = n.style.font_size * n.style.line_height
let first_baseline = r.y + n.style.font_size * 1.0
// 水平对齐:left(默认,x 起点)/ center / right(x 终点 + text-anchor)
let (tx, anchor) = match n.style.text_align {
"center" => (r.x + r.width / 2.0, " text-anchor=\"middle\"")
"right" => (r.x + r.width, " text-anchor=\"end\"")
_ => (r.x, "")
}
sb.write_string(
"\{xml_escape(lines[0])}")
} else {
sb.write_string(">")
for i in 0..\{xml_escape(lines[i])}",
)
}
sb.write_string("")
}
}
///|
/// 描边线型 → SVG stroke-dasharray 值;solid 返回 None(不输出该属性)
fn dash_array(dash : String) -> String? {
match dash {
"dashed" => Some("6 4")
"dotted" => Some("1.5 3")
_ => None
}
}
///|
/// elevation 阴影令牌参数 (dx, dy, stdDeviation, flood-opacity)——
/// 与 render_svg 顶部 defs 的 e1/e2/e3 定义一一对应;未知令牌取 e2 档兜底
fn shadow_token_params(tok : String) -> (Double, Double, Double, Double) {
match tok {
"e1" => (0.0, 1.0, 2.0, 0.07)
"e2" => (0.0, 4.0, 10.0, 0.10)
"e3" => (0.0, 14.0, 28.0, 0.16)
_ => (0.0, 4.0, 10.0, 0.10)
}
}
///|
/// 按 \n 拆行(无换行返回单元素数组)
fn split_lines(s : String) -> Array[String] {
let out : Array[String] = []
let cur = StringBuilder::new()
for c in s.iter() {
if c == '\n' {
out.push(cur.to_string())
cur.reset()
} else {
cur.write_char(c)
}
}
out.push(cur.to_string())
out
}
///|
fn svg_kind_of(n : Node) -> String {
match n.kind {
Frame => "frame"
Group => "group"
Rect => "rect"
Text => "text"
Image => "image"
}
}
///|
/// Paint DSL 引用:"#hex" 原样;"linear:#a:#b@deg"/"radial:#a:#b" → url(#g_tag)
fn paint_ref(fill : String, node_id : String) -> String {
if has_prefix(fill, "linear:") || has_prefix(fill, "radial:") {
return "url(#g_\{svg_id_tag(node_id)})"
}
fill
}
///|
/// 文字色渐变引用(def 以 _t 后缀注册)
fn paint_ref_t(color : String, node_id : String) -> String {
if has_prefix(color, "linear:") || has_prefix(color, "radial:") {
return "url(#g_\{svg_id_tag(node_id)}_t)"
}
color
}
///|
/// 节点 id → SVG id 安全 tag
///|
/// 图片 src 判定:http(s)/data URL 才渲染真 ,其余(含空)走占位
fn has_url_prefix(s : String) -> Bool {
let http = "http://"
let https = "https://"
let data = "data:"
if s.length() >= http.length() &&
s.substring(start=0, end=http.length()) == http {
true
} else if s.length() >= https.length() &&
s.substring(start=0, end=https.length()) == https {
true
} else {
s.length() >= data.length() &&
s.substring(start=0, end=data.length()) == data
}
}
///|
fn svg_id_tag(id : String) -> String {
let sb = StringBuilder::new()
for c in id.iter() {
let code = c.to_int()
if (code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122) {
sb.write_char(c)
} else {
sb.write_char('_')
}
}
sb.to_string()
}
///|
/// 遍历文档收集全部渐变,生成 段
fn collect_gradients(doc : Document) -> String {
let sb = StringBuilder::new()
for id in doc.nodes.keys() {
match doc.get(id) {
None => ()
Some(n) => {
let g = gradient_def(n.style.fill, id)
if g != "" {
sb.write_string(g)
}
// 文字色也可能是渐变
let tg = gradient_def(n.style.text_color, id + "_t")
if tg != "" {
sb.write_string(tg)
}
}
}
}
// blur 滤镜 defs;shadow+blur 同设时生成合并滤镜(先模糊、再对模糊结果投阴影)
for id in doc.nodes.keys() {
match doc.get(id) {
None => ()
Some(n) => {
let has_blur = n.style.blur > 0.0
let has_shadow = n.style.shadow != "none"
if has_blur && has_shadow {
let (dx, dy, std, op) = shadow_token_params(n.style.shadow)
sb.write_string(
"",
)
} else if has_blur {
sb.write_string(
"",
)
}
}
}
}
let inner = sb.to_string()
if inner == "" {
""
} else {
"\{inner}"
}
}
///|
/// 单个 Paint DSL → 渐变 def;非渐变返回 ""
fn has_prefix(s : String, pre : String) -> Bool {
if s.length() < pre.length() {
return false
}
s.substring(start=0, end=pre.length()) == pre
}
///|
fn gradient_def(paint : String, id : String) -> String {
if has_prefix(paint, "linear:") {
let body = paint.substring(start=7, end=paint.length())
let (c1, c2, deg) = parse_two_colors_angle(body)
let tag = "g_\{svg_id_tag(id)}"
// 角度 → (x1,y1,x2,y2):0°=→ 90°=↓
let rad = deg * 3.14159265 / 180.0
let x2 = (0.5 + 0.5 * rad_cos(rad)).to_string()
let y2 = (0.5 + 0.5 * rad_sin(rad)).to_string()
let x1 = (0.5 - 0.5 * rad_cos(rad)).to_string()
let y1 = (0.5 - 0.5 * rad_sin(rad)).to_string()
return ""
}
if has_prefix(paint, "radial:") {
let body = paint.substring(start=7, end=paint.length())
let (c1, c2, _deg) = parse_two_colors_angle(body)
let tag = "g_\{svg_id_tag(id)}"
return ""
}
""
}
///|
/// "a@45" / "a" / "a:b@45" → (c1, c2, deg)
fn parse_two_colors_angle(body : String) -> (String, String, Double) {
// 先拆角度
let mut color_part = body
let mut deg = 90.0
let at = index_of_at(body)
if at >= 0 {
color_part = body.substring(start=0, end=at)
deg = parse_deg(body.substring(start=at + 1, end=body.length()))
}
let parts = split_char(color_part, ':')
let c1 = if parts.length() > 0 { parts[0] } else { "#cccccc" }
let c2 = if parts.length() > 1 { parts[1] } else { c1 }
(c1, c2, deg)
}
///|
/// 找 @ 首次出现位置,未找到 -1
fn index_of_at(s : String) -> Int {
let mut i = 0
for c in s.iter() {
if c == '@' {
return i
}
i = i + 1
}
-1
}
///|
fn parse_deg(s : String) -> Double {
let mut v = 0.0
for c in s.iter() {
let code = c.to_int()
if code >= 48 && code <= 57 {
v = v * 10.0 + (code - 48).to_double()
}
}
v
}
///|
fn split_char(s : String, sep : Char) -> Array[String] {
let out : Array[String] = []
let cur = StringBuilder::new()
for c in s.iter() {
if c == sep {
out.push(cur.to_string())
cur.reset()
} else {
cur.write_char(c)
}
}
out.push(cur.to_string())
out
}
///|
fn rad_cos(r : Double) -> Double {
// cos 的泰勒近似(-π..π 精度足够渲染视觉)
let x = wrap_angle(r)
let x2 = x * x
1.0 - x2 / 2.0 + x2 * x2 / 24.0 - x2 * x2 * x2 / 720.0
}
///|
fn rad_sin(r : Double) -> Double {
let x = wrap_angle(r)
let x2 = x * x
x - x * x2 / 6.0 + x * x2 * x2 / 120.0
}
///|
fn wrap_angle(r : Double) -> Double {
// 归一到 -π..π
let pi = 3.14159265
let mut x = r
while x > pi {
x = x - 2.0 * pi
}
while x < -pi {
x = x + 2.0 * pi
}
x
}
///|
fn xml_escape(s : String) -> String {
let sb = StringBuilder::new()
for c in s.iter() {
match c {
'&' => sb.write_string("&")
'<' => sb.write_string("<")
'>' => sb.write_string(">")
_ => sb.write_char(c)
}
}
sb.to_string()
}