// SVG Import 機能
///|
/// インポート結果(要素と検証結果)
pub struct ImportResult {
elements : Array[@model.Element]
is_moonlight : Bool // Moonlight形式かどうか
plain_svg_ids : Array[String] // プレーンSVG要素のID(移動のみ可能)
}
///|
/// SVG文字列をパースして検証付きでインポート
pub fn import_svg_validated(
svg_str : String,
new_id : () -> String,
) -> ImportResult {
let svg_el = parse_svg_string(svg_str)
if @js.is_nullish(svg_el) {
return { elements: [], is_moonlight: false, plain_svg_ids: [] }
}
// Moonlight形式かチェック
let is_moonlight = not(
@js.is_nullish(get_attribute(svg_el, "data-moonlight")),
)
let children = get_svg_children(svg_el)
let elements : Array[@model.Element] = []
let plain_svg_ids : Array[String] = []
let id_map : Array[(String, String)] = [] // (old_id, new_id) for Moonlight format
// 子要素をパース
let children_array : Array[@js.Any] = children.cast()
for child in children_array {
let tag = get_tag_name(child)
// defs, rect(背景) はスキップ
if tag == "defs" {
continue
}
let el_opt = if is_moonlight {
parse_moonlight_element(child, new_id, id_map)
} else {
parse_plain_svg_element(child, new_id)
}
if el_opt is Some(el) {
elements.push(el)
// プレーンSVG要素はIDを記録
if not(is_moonlight) {
plain_svg_ids.push(el.id)
}
}
}
// Moonlight形式の場合、接続情報のIDを更新
if is_moonlight {
update_connection_ids(elements, id_map)
}
{ elements, is_moonlight, plain_svg_ids }
}
///|
/// SVG文字列をパースして Element 配列を返す
pub fn import_svg_elements(
svg_str : String,
new_id : () -> String,
) -> Array[@model.Element] {
let svg_el = parse_svg_string(svg_str)
if @js.is_nullish(svg_el) {
return []
}
// Moonlight形式かチェック
let is_moonlight = not(
@js.is_nullish(get_attribute(svg_el, "data-moonlight")),
)
let children = get_svg_children(svg_el)
let elements : Array[@model.Element] = []
let id_map : Array[(String, String)] = [] // (old_id, new_id) for Moonlight format
// 子要素をパース
let children_array : Array[@js.Any] = children.cast()
for child in children_array {
let tag = get_tag_name(child)
// defs, rect(背景) はスキップ
if tag == "defs" {
continue
}
let el_opt = if is_moonlight {
parse_moonlight_element(child, new_id, id_map)
} else {
parse_plain_svg_element(child, new_id)
}
if el_opt is Some(el) {
elements.push(el)
}
}
// Moonlight形式の場合、接続情報のIDを更新
if is_moonlight {
update_connection_ids(elements, id_map)
}
elements
}
///|
/// Moonlight形式の要素をパース
fn parse_moonlight_element(
el : @js.Any,
new_id : () -> String,
id_map : Array[(String, String)],
) -> @model.Element? {
let tag = get_tag_name(el)
// 基本属性
let old_id_raw = get_attribute(el, "data-id")
let old_id = if @js.is_nullish(old_id_raw) {
""
} else {
let s : String = old_id_raw.cast()
s
}
// 新しいIDを生成してマップに追加
let el_id = new_id()
if old_id != "" {
id_map.push((old_id, el_id))
}
// parent_id
let parent_id_raw = get_attribute(el, "data-parent-id")
let parent_id : String? = if @js.is_nullish(parent_id_raw) {
None
} else {
let s : String = parent_id_raw.cast()
if s == "" {
None
} else {
Some(s)
}
}
// 接続情報
let connections = parse_connections(el)
// スタイル
let style = parse_style(el)
// 図形をパース
let shape_opt = parse_shape(el, tag)
match shape_opt {
Some(shape_data) => {
let (shape, x, y) = shape_data
Some({
id: el_id,
x,
y,
shape,
style,
transform: None,
parent_id,
connections,
})
}
None => None
}
}
///|
/// プレーンSVG要素をパース
fn parse_plain_svg_element(
el : @js.Any,
new_id : () -> String,
) -> @model.Element? {
let tag = get_tag_name(el)
let el_id = new_id()
let style = parse_style(el)
let shape_opt = parse_shape(el, tag)
match shape_opt {
Some(shape_data) => {
let (shape, x, y) = shape_data
Some({
id: el_id,
x,
y,
shape,
style,
transform: None,
parent_id: None,
connections: None,
})
}
None => None
}
}
///|
/// 図形をパース(タグ名に基づいて)
fn parse_shape(
el : @js.Any,
tag : String,
) -> (@model.ShapeType, Double, Double)? {
match tag {
"rect" => {
// 背景用rect(data-idがなく、width/height が 100%)はスキップ
let width_raw = get_attribute(el, "width")
if not(@js.is_nullish(width_raw)) {
let width_str : String = width_raw.cast()
if width_str == "100%" {
return None
}
}
let x = parse_attr_double(el, "x", 0.0)
let y = parse_attr_double(el, "y", 0.0)
let w = parse_attr_double(el, "width", 100.0)
let h = parse_attr_double(el, "height", 100.0)
let rx = parse_attr_double_opt(el, "rx")
let ry = parse_attr_double_opt(el, "ry")
Some((@model.Rect(w, h, rx, ry), x, y))
}
"circle" => {
let cx = parse_attr_double(el, "cx", 0.0)
let cy = parse_attr_double(el, "cy", 0.0)
let r = parse_attr_double(el, "r", 50.0)
Some((@model.Circle(r), cx, cy))
}
"ellipse" => {
let cx = parse_attr_double(el, "cx", 0.0)
let cy = parse_attr_double(el, "cy", 0.0)
let rx = parse_attr_double(el, "rx", 50.0)
let ry = parse_attr_double(el, "ry", 30.0)
Some((@model.Ellipse(rx, ry), cx, cy))
}
"line" => {
let x1 = parse_attr_double(el, "x1", 0.0)
let y1 = parse_attr_double(el, "y1", 0.0)
let x2 = parse_attr_double(el, "x2", 100.0)
let y2 = parse_attr_double(el, "y2", 100.0)
Some((@model.Line(x2, y2), x1, y1))
}
"text" => {
// NOTE: エディタは text-anchor="middle", dominant-baseline="middle" で描画するため、
// 外部SVGをインポートする場合は座標変換が必要。
// 現状はエディタで生成したSVGの再読み込みを想定し、変換は行わない。
// TODO: 外部SVG対応時に座標変換処理を実装する
let x = parse_attr_double(el, "x", 0.0)
let y = parse_attr_double(el, "y", 0.0)
let content = get_text_content(el)
let font_size = parse_attr_double_opt(el, "font-size")
Some((@model.Text(content, font_size), x, y))
}
"polyline" => {
let points_raw = get_attribute(el, "points")
if @js.is_nullish(points_raw) {
return None
}
let points_str : String = points_raw.cast()
let points = parse_points(points_str)
if points.length() == 0 {
return None
}
let first = points[0]
Some((@model.Polyline(points), first.x, first.y))
}
"path" => {
let d_raw = get_attribute(el, "d")
if @js.is_nullish(d_raw) {
return None
}
let d : String = d_raw.cast()
// pathの始点・終点を抽出
let (start_x, start_y, end_x, end_y) = parse_path_endpoints(d)
Some((@model.Path(d, start_x, start_y, end_x, end_y), start_x, start_y))
}
_ => None
}
}
///|
/// 属性から Double をパース
fn parse_attr_double(el : @js.Any, name : String, default : Double) -> Double {
let raw = get_attribute(el, name)
if @js.is_nullish(raw) {
return default
}
let s : String = raw.cast()
match parse_double(s) {
Some(v) => v
None => default
}
}
///|
/// 属性から Double をパース(オプション)
fn parse_attr_double_opt(el : @js.Any, name : String) -> Double? {
let raw = get_attribute(el, name)
if @js.is_nullish(raw) {
return None
}
let s : String = raw.cast()
parse_double(s)
}
///|
/// スタイル属性をパース
fn parse_style(el : @js.Any) -> @model.Style {
let fill_raw = get_attribute(el, "fill")
let fill : String? = if @js.is_nullish(fill_raw) {
None
} else {
let s : String = fill_raw.cast()
if s == "none" {
None
} else {
Some(s)
}
}
let stroke_raw = get_attribute(el, "stroke")
let stroke : String? = if @js.is_nullish(stroke_raw) {
None
} else {
Some(stroke_raw.cast())
}
let stroke_width = parse_attr_double_opt(el, "stroke-width")
let opacity = parse_attr_double_opt(el, "opacity")
let dasharray_raw = get_attribute(el, "stroke-dasharray")
let stroke_dasharray : String? = if @js.is_nullish(dasharray_raw) {
None
} else {
Some(dasharray_raw.cast())
}
// マーカー
let marker_start_raw = get_attribute(el, "marker-start")
let marker_start : @model.ArrowType? = if @js.is_nullish(marker_start_raw) {
None
} else {
Some(@model.Arrow)
}
let marker_end_raw = get_attribute(el, "marker-end")
let marker_end : @model.ArrowType? = if @js.is_nullish(marker_end_raw) {
None
} else {
Some(@model.Arrow)
}
// font-family を取得
let font_family_raw = get_attribute(el, "font-family")
let font_family : String? = if @js.is_nullish(font_family_raw) {
None
} else {
let ff : String = font_family_raw.cast()
Some(ff)
}
{
fill,
stroke,
stroke_width,
opacity,
stroke_dasharray,
marker_start,
marker_end,
font_family,
}
}
///|
/// 接続情報をパース
fn parse_connections(el : @js.Any) -> @model.LineConnections? {
let start_raw = get_attribute(el, "data-connection-start")
let end_raw = get_attribute(el, "data-connection-end")
if @js.is_nullish(start_raw) && @js.is_nullish(end_raw) {
return None
}
let start = if @js.is_nullish(start_raw) {
None
} else {
let s : String = start_raw.cast()
parse_connection_string(s)
}
let end_ = if @js.is_nullish(end_raw) {
None
} else {
let s : String = end_raw.cast()
parse_connection_string(s)
}
Some({ start, end: end_ })
}
///|
/// 接続文字列をパース (format: "element-id:anchor")
fn parse_connection_string(s : String) -> @model.Connection? {
// "element-id:anchor" 形式
let parts : Array[String] = s
.split(":")
.map(fn(sv) { sv.to_string() })
.collect()
if parts.length() != 2 {
return None
}
let element_id = parts[0]
let anchor_str = parts[1]
match @model.parse_anchor(anchor_str) {
Some(anchor) => Some({ element_id, anchor })
None => None
}
}
///|
/// points 属性をパース
fn parse_points(s : String) -> Array[@model.Point] {
let points : Array[@model.Point] = []
let pairs : Array[String] = s
.split(" ")
.map(fn(sv) { sv.to_string() })
.collect()
for pair in pairs {
let coords : Array[String] = pair
.split(",")
.map(fn(sv) { sv.to_string() })
.collect()
if coords.length() == 2 {
if parse_double(coords[0]) is Some(x) {
if parse_double(coords[1]) is Some(y) {
points.push({ x, y })
}
}
}
}
points
}
///|
/// 接続情報のIDを更新(古いID → 新しいID)
fn update_connection_ids(
elements : Array[@model.Element],
id_map : Array[(String, String)],
) -> Unit {
// IDマップから新しいIDを検索
let find_new_id = fn(old_id : String) -> String {
for mapping in id_map {
let (o, n) = mapping
if o == old_id {
return n
}
}
old_id
}
// 各要素の接続情報とparent_idを更新
for i = 0; i < elements.length(); i = i + 1 {
let el = elements[i]
// parent_id を更新
let new_parent_id = match el.parent_id {
Some(pid) => Some(find_new_id(pid))
None => None
}
// connections を更新
let new_connections = match el.connections {
Some(conns) => {
let new_start : @model.Connection? = match conns.start {
Some(conn) => {
let c : @model.Connection = {
element_id: find_new_id(conn.element_id),
anchor: conn.anchor,
}
Some(c)
}
None => None
}
let new_end : @model.Connection? = match conns.end {
Some(conn) => {
let c : @model.Connection = {
element_id: find_new_id(conn.element_id),
anchor: conn.anchor,
}
Some(c)
}
None => None
}
let lc : @model.LineConnections = { start: new_start, end: new_end }
Some(lc)
}
None => None
}
elements[i] = {
..el,
parent_id: new_parent_id,
connections: new_connections,
}
}
}
///|
/// SVG path d属性から始点・終点を抽出
/// 返り値: (start_x, start_y, end_x, end_y)
fn parse_path_endpoints(d : String) -> (Double, Double, Double, Double) {
let start_x : Ref[Double] = { val: 0.0 }
let start_y : Ref[Double] = { val: 0.0 }
let end_x : Ref[Double] = { val: 0.0 }
let end_y : Ref[Double] = { val: 0.0 }
let current_x : Ref[Double] = { val: 0.0 }
let current_y : Ref[Double] = { val: 0.0 }
let found_start : Ref[Bool] = { val: false }
let numbers = extract_path_numbers(d)
let commands = extract_path_commands(d)
let num_idx : Ref[Int] = { val: 0 }
for cmd in commands {
let c = cmd
match c {
'M' | 'm' =>
if num_idx.val + 1 < numbers.length() {
let x = numbers[num_idx.val]
let y = numbers[num_idx.val + 1]
num_idx.val = num_idx.val + 2
if c == 'M' {
current_x.val = x
current_y.val = y
} else {
current_x.val = current_x.val + x
current_y.val = current_y.val + y
}
if not(found_start.val) {
start_x.val = current_x.val
start_y.val = current_y.val
found_start.val = true
}
end_x.val = current_x.val
end_y.val = current_y.val
}
'L' | 'l' =>
if num_idx.val + 1 < numbers.length() {
let x = numbers[num_idx.val]
let y = numbers[num_idx.val + 1]
num_idx.val = num_idx.val + 2
if c == 'L' {
current_x.val = x
current_y.val = y
} else {
current_x.val = current_x.val + x
current_y.val = current_y.val + y
}
end_x.val = current_x.val
end_y.val = current_y.val
}
'C' | 'c' =>
if num_idx.val + 5 < numbers.length() {
let x = numbers[num_idx.val + 4]
let y = numbers[num_idx.val + 5]
num_idx.val = num_idx.val + 6
if c == 'C' {
current_x.val = x
current_y.val = y
} else {
current_x.val = current_x.val + x
current_y.val = current_y.val + y
}
end_x.val = current_x.val
end_y.val = current_y.val
}
'Q' | 'q' =>
if num_idx.val + 3 < numbers.length() {
let x = numbers[num_idx.val + 2]
let y = numbers[num_idx.val + 3]
num_idx.val = num_idx.val + 4
if c == 'Q' {
current_x.val = x
current_y.val = y
} else {
current_x.val = current_x.val + x
current_y.val = current_y.val + y
}
end_x.val = current_x.val
end_y.val = current_y.val
}
'S' | 's' =>
if num_idx.val + 3 < numbers.length() {
let x = numbers[num_idx.val + 2]
let y = numbers[num_idx.val + 3]
num_idx.val = num_idx.val + 4
if c == 'S' {
current_x.val = x
current_y.val = y
} else {
current_x.val = current_x.val + x
current_y.val = current_y.val + y
}
end_x.val = current_x.val
end_y.val = current_y.val
}
'T' | 't' =>
if num_idx.val + 1 < numbers.length() {
let x = numbers[num_idx.val]
let y = numbers[num_idx.val + 1]
num_idx.val = num_idx.val + 2
if c == 'T' {
current_x.val = x
current_y.val = y
} else {
current_x.val = current_x.val + x
current_y.val = current_y.val + y
}
end_x.val = current_x.val
end_y.val = current_y.val
}
'H' | 'h' =>
if num_idx.val < numbers.length() {
let x = numbers[num_idx.val]
num_idx.val = num_idx.val + 1
if c == 'H' {
current_x.val = x
} else {
current_x.val = current_x.val + x
}
end_x.val = current_x.val
end_y.val = current_y.val
}
'V' | 'v' =>
if num_idx.val < numbers.length() {
let y = numbers[num_idx.val]
num_idx.val = num_idx.val + 1
if c == 'V' {
current_y.val = y
} else {
current_y.val = current_y.val + y
}
end_x.val = current_x.val
end_y.val = current_y.val
}
'Z' | 'z' => {
current_x.val = start_x.val
current_y.val = start_y.val
end_x.val = current_x.val
end_y.val = current_y.val
}
_ => ()
}
}
(start_x.val, start_y.val, end_x.val, end_y.val)
}
///|
fn extract_path_commands(d : String) -> Array[Char] {
let commands : Array[Char] = []
for c in d {
match c {
'M'
| 'm'
| 'L'
| 'l'
| 'H'
| 'h'
| 'V'
| 'v'
| 'C'
| 'c'
| 'S'
| 's'
| 'Q'
| 'q'
| 'T'
| 't'
| 'A'
| 'a'
| 'Z'
| 'z' => commands.push(c)
_ => ()
}
}
commands
}
///|
fn extract_path_numbers(d : String) -> Array[Double] {
let numbers : Array[Double] = []
let current : Array[Char] = []
let in_number : Ref[Bool] = { val: false }
for c in d {
match c {
'0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '.' | '-' => {
if c == '-' && in_number.val && current.length() > 0 {
let s = String::from_array(current)
if parse_double(s) is Some(n) {
numbers.push(n)
}
current.clear()
}
current.push(c)
in_number.val = true
}
_ => {
if in_number.val && current.length() > 0 {
let s = String::from_array(current)
if parse_double(s) is Some(n) {
numbers.push(n)
}
current.clear()
}
in_number.val = false
}
}
}
if current.length() > 0 {
let s = String::from_array(current)
if parse_double(s) is Some(n) {
numbers.push(n)
}
}
numbers
}