use anyhow::Result;
use quick_xml::events::{BytesStart, Event};
use quick_xml::{Reader, Writer};
use usvg::roxmltree::Document;
use crate::get_font_for_locale;
const PAGE_CONTENT_WIDTH_PT: f64 = (21.0 - 2.0 * 2.0) / 2.54 * 72.0;
fn normalize_font_size_pt(size_pt: u32) -> u32 {
match size_pt {
0..=16 => size_pt.max(1),
17..=20 => ((size_pt as f64) * 0.88).round() as u32,
_ => ((size_pt as f64) * 0.8).round() as u32,
}
.max(1)
}
#[derive(Debug, Clone)]
pub struct EditableSvgGroup {
primitives: Vec<EditablePrimitive>,
text_boxes: Vec<EditableTextBox>,
width_pt: f64,
height_pt: f64,
locale: String,
}
#[derive(Debug, Clone)]
enum EditablePrimitive {
Rect {
x_pt: f64,
y_pt: f64,
w_pt: f64,
h_pt: f64,
rx_pt: f64,
fill: Option<String>,
stroke: Option<String>,
stroke_width_pt: f64,
},
Ellipse {
x_pt: f64,
y_pt: f64,
w_pt: f64,
h_pt: f64,
fill: Option<String>,
stroke: Option<String>,
stroke_width_pt: f64,
},
Line {
x1_pt: f64,
y1_pt: f64,
x2_pt: f64,
y2_pt: f64,
stroke: Option<String>,
stroke_width_pt: f64,
},
PolyShape {
x_pt: f64,
y_pt: f64,
w_pt: f64,
h_pt: f64,
points: Vec<(f64, f64)>,
closed: bool,
fill: Option<String>,
stroke: Option<String>,
stroke_width_pt: f64,
},
PathShape {
x_pt: f64,
y_pt: f64,
w_pt: f64,
h_pt: f64,
path: String,
fill: Option<String>,
stroke: Option<String>,
stroke_width_pt: f64,
},
}
#[derive(Debug, Clone)]
struct EditableTextBox {
x_pt: f64,
y_pt: f64,
w_pt: f64,
h_pt: f64,
text: String,
font_size_pt: u32,
font_family: Option<String>,
color: String,
bold: bool,
italic: bool,
underline: bool,
strike: bool,
align: String,
rotation_deg: f64,
}
#[derive(Debug, Clone, Default)]
struct TextStyle {
font_size: f64,
font_family: Option<String>,
fill: Option<String>,
stroke: Option<String>,
stroke_width: f64,
bold: bool,
italic: bool,
underline: bool,
strike: bool,
text_anchor: String,
dominant_baseline: String,
}
#[derive(Debug, Clone)]
struct TextLine {
x: f64,
y: f64,
text: String,
}
#[derive(Debug, Clone, Copy)]
struct Transform2D {
a: f64,
b: f64,
c: f64,
d: f64,
e: f64,
f: f64,
}
impl Default for Transform2D {
fn default() -> Self {
Self::identity()
}
}
impl Transform2D {
const fn identity() -> Self {
Self {
a: 1.0,
b: 0.0,
c: 0.0,
d: 1.0,
e: 0.0,
f: 0.0,
}
}
fn multiply(self, rhs: Self) -> Self {
Self {
a: self.a * rhs.a + self.c * rhs.b,
b: self.b * rhs.a + self.d * rhs.b,
c: self.a * rhs.c + self.c * rhs.d,
d: self.b * rhs.c + self.d * rhs.d,
e: self.a * rhs.e + self.c * rhs.f + self.e,
f: self.b * rhs.e + self.d * rhs.f + self.f,
}
}
fn apply(self, x: f64, y: f64) -> (f64, f64) {
(
self.a * x + self.c * y + self.e,
self.b * x + self.d * y + self.f,
)
}
fn scale_x(self) -> f64 {
(self.a * self.a + self.b * self.b).sqrt()
}
fn scale_y(self) -> f64 {
(self.c * self.c + self.d * self.d).sqrt()
}
fn rotation_deg(self) -> f64 {
self.b.atan2(self.a).to_degrees()
}
}
impl EditableSvgGroup {
pub fn from_svg_string(svg_code: String, alt_text: String, locale: String) -> Result<Self> {
let (svg_w, svg_h) = get_svg_size(&svg_code);
let width_pt = PAGE_CONTENT_WIDTH_PT;
let height_pt = if svg_w > 0.0 {
width_pt * svg_h / svg_w
} else {
width_pt
};
let scale_x = if svg_w > 0.0 { width_pt / svg_w } else { 1.0 };
let scale_y = if svg_h > 0.0 { height_pt / svg_h } else { 1.0 };
let primitives = extract_primitives(&svg_code, scale_x, scale_y);
let text_boxes = extract_text_boxes(&svg_code, scale_x, scale_y);
let _ = alt_text;
Ok(Self {
primitives,
text_boxes,
width_pt,
height_pt,
locale,
})
}
pub fn generate_xml(&self) -> String {
self.generate_xml_with_offset(0)
}
pub fn shape_count(&self) -> usize {
self.primitives.len() + self.text_boxes.len()
}
pub fn generate_xml_with_offset(&self, start_index: usize) -> String {
let group_width = format_pt(self.width_pt);
let group_height = format_pt(self.height_pt);
let line_twips = ((self.height_pt + 6.0).max(1.0) * 20.0).round() as u32;
let mut xml = format!(
"<w:p>\
<w:pPr>\
<w:spacing w:before=\"0\" w:after=\"0\" w:line=\"{line_twips}\" w:lineRule=\"exact\"/>\
</w:pPr>\
<w:r>\
<w:pict>\
<v:group style=\"position:relative;width:{group_width}pt;height:{group_height}pt;\" coordorigin=\"0,0\" coordsize=\"{group_width},{group_height}\">"
);
for (index, primitive) in self.primitives.iter().enumerate() {
xml.push_str(&primitive.generate_xml(start_index + index));
}
for (index, text_box) in self.text_boxes.iter().enumerate() {
xml.push_str(
&text_box.generate_xml(start_index + index + self.primitives.len(), &self.locale),
);
}
xml.push_str(
r#"
</v:group>
</w:pict>
</w:r>
</w:p>"#,
);
xml
}
}
impl EditablePrimitive {
fn generate_xml(&self, index: usize) -> String {
match self {
EditablePrimitive::Rect {
x_pt,
y_pt,
w_pt,
h_pt,
rx_pt,
fill,
stroke,
stroke_width_pt,
} => {
let filled = if fill.is_some() { "t" } else { "f" };
let stroked = if stroke.is_some() { "t" } else { "f" };
let style = format!(
"position:absolute;left:{}pt;top:{}pt;width:{}pt;height:{}pt;z-index:{};",
format_pt(*x_pt),
format_pt(*y_pt),
format_pt(w_pt.max(1.0)),
format_pt(h_pt.max(1.0)),
index + 1
);
let strokeweight = format_pt(stroke_width_pt.max(0.75));
if *rx_pt > 0.0 {
let arcsize = ((rx_pt / w_pt.max(1.0)) * 2.0).clamp(0.0, 1.0);
format!(
r#"<v:roundrect id="editable-svg-rect-{index}" filled="{filled}" stroked="{stroked}" style="{style}" arcsize="{arcsize}"{fill_attr}{stroke_attr}{stroke_weight_attr}></v:roundrect>"#,
index = index,
filled = filled,
stroked = stroked,
style = style,
arcsize = format_pt(arcsize * 100.0) + "%",
fill_attr = fill
.as_ref()
.map(|c| format!(" fillcolor=\"#{}\"", c))
.unwrap_or_default(),
stroke_attr = stroke
.as_ref()
.map(|c| format!(" strokecolor=\"#{}\"", c))
.unwrap_or_default(),
stroke_weight_attr = if stroke.is_some() {
format!(r#" strokeweight="{}pt""#, strokeweight)
} else {
String::new()
},
)
} else {
format!(
r#"<v:rect id="editable-svg-rect-{index}" filled="{filled}" stroked="{stroked}" style="{style}"{fill_attr}{stroke_attr}{stroke_weight_attr}></v:rect>"#,
index = index,
filled = filled,
stroked = stroked,
style = style,
fill_attr = fill
.as_ref()
.map(|c| format!(" fillcolor=\"#{}\"", c))
.unwrap_or_default(),
stroke_attr = stroke
.as_ref()
.map(|c| format!(" strokecolor=\"#{}\"", c))
.unwrap_or_default(),
stroke_weight_attr = if stroke.is_some() {
format!(r#" strokeweight="{}pt""#, strokeweight)
} else {
String::new()
},
)
}
}
EditablePrimitive::Ellipse {
x_pt,
y_pt,
w_pt,
h_pt,
fill,
stroke,
stroke_width_pt,
} => format!(
r#"<v:oval id="editable-svg-ellipse-{index}" filled="{filled}" stroked="{stroked}" style="position:absolute;left:{left}pt;top:{top}pt;width:{width}pt;height:{height}pt;z-index:{z};"{fill_attr}{stroke_attr}{stroke_weight_attr}></v:oval>"#,
index = index,
filled = if fill.is_some() { "t" } else { "f" },
stroked = if stroke.is_some() { "t" } else { "f" },
left = format_pt(*x_pt),
top = format_pt(*y_pt),
width = format_pt(w_pt.max(1.0)),
height = format_pt(h_pt.max(1.0)),
z = index + 1,
fill_attr = fill
.as_ref()
.map(|c| format!(" fillcolor=\"#{}\"", c))
.unwrap_or_default(),
stroke_attr = stroke
.as_ref()
.map(|c| format!(" strokecolor=\"#{}\"", c))
.unwrap_or_default(),
stroke_weight_attr = if stroke.is_some() {
format!(
r#" strokeweight="{}pt""#,
format_pt(stroke_width_pt.max(0.75))
)
} else {
String::new()
},
),
EditablePrimitive::Line {
x1_pt,
y1_pt,
x2_pt,
y2_pt,
stroke,
stroke_width_pt,
} => format!(
r#"<v:line id="editable-svg-line-{index}" from="{x1},{y1}" to="{x2},{y2}" style="position:absolute;z-index:{z};" stroked="{stroked}"{stroke_attr}{stroke_weight_attr}></v:line>"#,
index = index,
x1 = format!("{}pt", format_pt(*x1_pt)),
y1 = format!("{}pt", format_pt(*y1_pt)),
x2 = format!("{}pt", format_pt(*x2_pt)),
y2 = format!("{}pt", format_pt(*y2_pt)),
z = index + 1,
stroked = if stroke.is_some() { "t" } else { "f" },
stroke_attr = stroke
.as_ref()
.map(|c| format!(" strokecolor=\"#{}\"", c))
.unwrap_or_default(),
stroke_weight_attr = if stroke.is_some() {
format!(
r#" strokeweight="{}pt""#,
format_pt(stroke_width_pt.max(0.75))
)
} else {
String::new()
},
),
EditablePrimitive::PolyShape {
x_pt,
y_pt,
w_pt,
h_pt,
points,
closed,
fill,
stroke,
stroke_width_pt,
} => {
let path = vml_path(points, *closed);
let filled = if *closed && fill.is_some() { "t" } else { "f" };
let stroked = if stroke.is_some() { "t" } else { "f" };
format!(
r#"<v:shape id="editable-svg-poly-{index}" filled="{filled}" stroked="{stroked}" style="position:absolute;left:{left}pt;top:{top}pt;width:{width}pt;height:{height}pt;z-index:{z};" coordorigin="0,0" coordsize="{coord_w},{coord_h}" path="{path}"{fill_attr}{stroke_attr}{stroke_weight_attr}></v:shape>"#,
index = index,
filled = filled,
stroked = stroked,
left = format_pt(*x_pt),
top = format_pt(*y_pt),
width = format_pt(w_pt.max(1.0)),
height = format_pt(h_pt.max(1.0)),
z = index + 1,
coord_w = format_pt(w_pt.max(1.0)),
coord_h = format_pt(h_pt.max(1.0)),
path = path,
fill_attr = fill
.as_ref()
.map(|c| format!(" fillcolor=\"#{}\"", c))
.unwrap_or_default(),
stroke_attr = stroke
.as_ref()
.map(|c| format!(" strokecolor=\"#{}\"", c))
.unwrap_or_default(),
stroke_weight_attr = if stroke.is_some() {
format!(
r#" strokeweight="{}pt""#,
format_pt(stroke_width_pt.max(0.75))
)
} else {
String::new()
},
)
}
EditablePrimitive::PathShape {
x_pt,
y_pt,
w_pt,
h_pt,
path,
fill,
stroke,
stroke_width_pt,
} => {
let filled = if fill.is_some() { "t" } else { "f" };
let stroked = if stroke.is_some() { "t" } else { "f" };
format!(
r#"<v:shape id="editable-svg-path-{index}" filled="{filled}" stroked="{stroked}" style="position:absolute;left:{left}pt;top:{top}pt;width:{width}pt;height:{height}pt;z-index:{z};" coordorigin="0,0" coordsize="{coord_w},{coord_h}" path="{path}"{fill_attr}{stroke_attr}{stroke_weight_attr}></v:shape>"#,
index = index,
filled = filled,
stroked = stroked,
left = format_pt(*x_pt),
top = format_pt(*y_pt),
width = format_pt(w_pt.max(1.0)),
height = format_pt(h_pt.max(1.0)),
z = index + 1,
coord_w = format_pt(w_pt.max(1.0)),
coord_h = format_pt(h_pt.max(1.0)),
path = path,
fill_attr = fill
.as_ref()
.map(|c| format!(" fillcolor=\"#{}\"", c))
.unwrap_or_default(),
stroke_attr = stroke
.as_ref()
.map(|c| format!(" strokecolor=\"#{}\"", c))
.unwrap_or_default(),
stroke_weight_attr = if stroke.is_some() {
format!(
r#" strokeweight="{}pt""#,
format_pt(stroke_width_pt.max(0.75))
)
} else {
String::new()
},
)
}
}
}
}
impl EditableTextBox {
fn generate_xml(&self, index: usize, locale: &str) -> String {
let font_name = self
.font_family
.clone()
.unwrap_or_else(|| get_font_for_locale(locale));
let alignment = match self.align.as_str() {
"ctr" => "center",
"r" => "right",
_ => "left",
};
let paragraphs = self
.text
.lines()
.map(|line| {
let line = if line.trim().is_empty() { " " } else { line };
let mut rpr = format!(
r#"<w:rPr><w:rFonts w:ascii="{font}" w:hAnsi="{font}" w:eastAsia="{font}"/><w:sz w:val="{size}"/><w:color w:val="{color}"/>"#,
font = escape_xml(&font_name),
size = self.font_size_pt.max(1) * 2,
color = self.color,
);
if self.bold {
rpr.push_str("<w:b/>");
}
if self.italic {
rpr.push_str("<w:i/>");
}
if self.underline {
rpr.push_str(r#"<w:u w:val="single"/>"#);
}
if self.strike {
rpr.push_str("<w:strike/>");
}
rpr.push_str("</w:rPr>");
format!(
r#"<w:p><w:pPr><w:jc w:val="{alignment}"/></w:pPr><w:r>{rpr}<w:t xml:space="preserve">{text}</w:t></w:r></w:p>"#,
alignment = alignment,
rpr = rpr,
text = escape_xml(line),
)
})
.collect::<String>();
format!(
r#"
<v:rect id="editable-svg-text-{index}" filled="f" stroked="f" style="position:absolute;left:{left}pt;top:{top}pt;width:{width}pt;height:{height}pt;z-index:{z};{rotation}">
<v:textbox inset="0pt,0pt,0pt,0pt">
<w:txbxContent>{paragraphs}</w:txbxContent>
</v:textbox>
<w10:wrap type="none"/>
</v:rect>"#,
index = index,
left = format_pt(self.x_pt),
top = format_pt(self.y_pt),
width = format_pt(self.w_pt.max(1.0)),
height = format_pt(self.h_pt.max(1.0)),
z = index + 1,
rotation = if self.rotation_deg.abs() > 0.01 {
format!("rotation:{};", format_pt(self.rotation_deg))
} else {
String::new()
},
paragraphs = paragraphs,
)
}
}
fn extract_text_boxes(svg_code: &str, scale_x: f64, scale_y: f64) -> Vec<EditableTextBox> {
let doc = match Document::parse(svg_code) {
Ok(doc) => doc,
Err(_) => return vec![],
};
doc.descendants()
.filter(|node| node.is_element() && node.tag_name().name() == "text")
.filter_map(|text_node| {
let style = inherited_text_style(&text_node);
let transform = node_transform(&text_node);
let page_scale = (scale_x + scale_y) / 2.0;
let x = text_node
.attribute("x")
.and_then(parse_svg_number)
.unwrap_or(0.0);
let y = text_node
.attribute("y")
.and_then(parse_svg_number)
.unwrap_or(0.0);
let lines = collect_text_lines(&text_node, &style, x, y);
if lines.is_empty() {
return None;
}
let font_size = if style.font_size > 0.0 {
style.font_size
} else {
14.0
};
let max_width = lines
.iter()
.map(|line| estimate_text_width(&line.text, font_size))
.fold(0.0_f64, f64::max)
* 1.2;
let first_y = lines.first().map(|line| line.y).unwrap_or(y);
let last_y = lines.last().map(|line| line.y).unwrap_or(y);
let total_h = if lines.len() > 1 {
(last_y - first_y).abs() + font_size * 1.5
} else {
font_size * 1.5
};
let anchor_x = lines.first().map(|line| line.x).unwrap_or(x);
let local_box_y = if matches!(style.dominant_baseline.as_str(), "central" | "middle") {
first_y - total_h / 2.0
} else {
first_y - font_size * 0.85
};
let local_box_x = match style.text_anchor.as_str() {
"middle" => anchor_x - max_width / 2.0,
"end" => anchor_x - max_width,
_ => anchor_x,
};
let local_corners = [
(local_box_x, local_box_y),
(local_box_x + max_width, local_box_y),
(local_box_x, local_box_y + total_h),
(local_box_x + max_width, local_box_y + total_h),
];
let transformed = local_corners.map(|(px, py)| transform.apply(px, py));
let center_local = (local_box_x + max_width / 2.0, local_box_y + total_h / 2.0);
let center = transform.apply(center_local.0, center_local.1);
let width_svg = distance_between(transformed[0], transformed[1]).max(1.0);
let height_svg = distance_between(transformed[0], transformed[2]).max(1.0);
let width_pt = width_svg * page_scale;
let height_pt = height_svg * page_scale;
let rotation_deg = transform.rotation_deg();
Some(EditableTextBox {
x_pt: center.0 * scale_x - width_pt / 2.0,
y_pt: center.1 * scale_y - height_pt / 2.0,
w_pt: width_pt,
h_pt: height_pt,
text: lines
.iter()
.map(|line| line.text.clone())
.collect::<Vec<_>>()
.join("\n"),
font_size_pt: normalize_font_size_pt(
((font_size * transform.scale_y().max(0.0001) * page_scale).round() as u32)
.max(1),
),
font_family: style.font_family.clone(),
color: style.fill.unwrap_or_else(|| "000000".to_string()),
bold: style.bold,
italic: style.italic,
underline: style.underline,
strike: style.strike,
align: match style.text_anchor.as_str() {
"middle" => "ctr".to_string(),
"end" => "r".to_string(),
_ => "l".to_string(),
},
rotation_deg,
})
})
.collect()
}
fn extract_primitives(svg_code: &str, scale_x: f64, scale_y: f64) -> Vec<EditablePrimitive> {
let doc = match Document::parse(svg_code) {
Ok(doc) => doc,
Err(_) => return vec![],
};
let mut out = Vec::new();
for node in doc.descendants().filter(|node| node.is_element()) {
let style = parse_style(&node, None);
let transform = node_transform(&node);
match node.tag_name().name() {
"rect" => {
let x = node
.attribute("x")
.and_then(parse_svg_number)
.unwrap_or(0.0);
let y = node
.attribute("y")
.and_then(parse_svg_number)
.unwrap_or(0.0);
let w = node
.attribute("width")
.and_then(parse_svg_number)
.unwrap_or(0.0);
let h = node
.attribute("height")
.and_then(parse_svg_number)
.unwrap_or(0.0);
let rx = node
.attribute("rx")
.and_then(parse_svg_number)
.or_else(|| node.attribute("ry").and_then(parse_svg_number))
.unwrap_or(0.0);
if w > 0.0 && h > 0.0 {
let corners = vec![
transform.apply(x, y),
transform.apply(x + w, y),
transform.apply(x, y + h),
transform.apply(x + w, y + h),
];
if !transform_is_axis_aligned(transform) {
let (min_x, min_y, max_x, max_y) = bbox_from_points(&corners);
let rel_points = vec![
(
(corners[0].0 - min_x) * scale_x,
(corners[0].1 - min_y) * scale_y,
),
(
(corners[1].0 - min_x) * scale_x,
(corners[1].1 - min_y) * scale_y,
),
(
(corners[3].0 - min_x) * scale_x,
(corners[3].1 - min_y) * scale_y,
),
(
(corners[2].0 - min_x) * scale_x,
(corners[2].1 - min_y) * scale_y,
),
];
out.push(EditablePrimitive::PolyShape {
x_pt: min_x * scale_x,
y_pt: min_y * scale_y,
w_pt: (max_x - min_x) * scale_x,
h_pt: (max_y - min_y) * scale_y,
points: rel_points,
closed: true,
fill: style.fill.clone(),
stroke: style.stroke.clone(),
stroke_width_pt: style.stroke_width
* transform.scale_x().max(transform.scale_y())
* scale_x.max(scale_y),
});
continue;
}
let (min_x, min_y, max_x, max_y) = bbox_from_points(&corners);
out.push(EditablePrimitive::Rect {
x_pt: min_x * scale_x,
y_pt: min_y * scale_y,
w_pt: (max_x - min_x) * scale_x,
h_pt: (max_y - min_y) * scale_y,
rx_pt: rx * transform.scale_x().max(transform.scale_y()) * scale_x,
fill: style.fill.clone(),
stroke: style.stroke.clone(),
stroke_width_pt: style.stroke_width
* transform.scale_x().max(transform.scale_y())
* scale_x.max(scale_y),
});
}
}
"circle" => {
let cx = node
.attribute("cx")
.and_then(parse_svg_number)
.unwrap_or(0.0);
let cy = node
.attribute("cy")
.and_then(parse_svg_number)
.unwrap_or(0.0);
let r = node
.attribute("r")
.and_then(parse_svg_number)
.unwrap_or(0.0);
if r > 0.0 {
if !transform_is_axis_aligned(transform) {
let points = sample_ellipse_points(cx, cy, r, r, 24)
.into_iter()
.map(|(x, y)| transform.apply(x, y))
.collect::<Vec<_>>();
if let Some(shape) = polyshape_from_points(
&points,
true,
style.fill.clone(),
style.stroke.clone(),
style.stroke_width
* transform.scale_x().max(transform.scale_y())
* scale_x.max(scale_y),
scale_x,
scale_y,
) {
out.push(shape);
continue;
}
}
let points = [
transform.apply(cx - r, cy - r),
transform.apply(cx + r, cy - r),
transform.apply(cx - r, cy + r),
transform.apply(cx + r, cy + r),
];
let (min_x, min_y, max_x, max_y) = bbox_from_points(&points);
out.push(EditablePrimitive::Ellipse {
x_pt: min_x * scale_x,
y_pt: min_y * scale_y,
w_pt: (max_x - min_x) * scale_x,
h_pt: (max_y - min_y) * scale_y,
fill: style.fill.clone(),
stroke: style.stroke.clone(),
stroke_width_pt: style.stroke_width
* transform.scale_x().max(transform.scale_y())
* scale_x.max(scale_y),
});
}
}
"ellipse" => {
let cx = node
.attribute("cx")
.and_then(parse_svg_number)
.unwrap_or(0.0);
let cy = node
.attribute("cy")
.and_then(parse_svg_number)
.unwrap_or(0.0);
let rx = node
.attribute("rx")
.and_then(parse_svg_number)
.unwrap_or(0.0);
let ry = node
.attribute("ry")
.and_then(parse_svg_number)
.unwrap_or(0.0);
if rx > 0.0 && ry > 0.0 {
if !transform_is_axis_aligned(transform) {
let points = sample_ellipse_points(cx, cy, rx, ry, 24)
.into_iter()
.map(|(x, y)| transform.apply(x, y))
.collect::<Vec<_>>();
if let Some(shape) = polyshape_from_points(
&points,
true,
style.fill.clone(),
style.stroke.clone(),
style.stroke_width
* transform.scale_x().max(transform.scale_y())
* scale_x.max(scale_y),
scale_x,
scale_y,
) {
out.push(shape);
continue;
}
}
let points = [
transform.apply(cx - rx, cy - ry),
transform.apply(cx + rx, cy - ry),
transform.apply(cx - rx, cy + ry),
transform.apply(cx + rx, cy + ry),
];
let (min_x, min_y, max_x, max_y) = bbox_from_points(&points);
out.push(EditablePrimitive::Ellipse {
x_pt: min_x * scale_x,
y_pt: min_y * scale_y,
w_pt: (max_x - min_x) * scale_x,
h_pt: (max_y - min_y) * scale_y,
fill: style.fill.clone(),
stroke: style.stroke.clone(),
stroke_width_pt: style.stroke_width
* transform.scale_x().max(transform.scale_y())
* scale_x.max(scale_y),
});
}
}
"line" => {
let x1 = node
.attribute("x1")
.and_then(parse_svg_number)
.unwrap_or(0.0);
let y1 = node
.attribute("y1")
.and_then(parse_svg_number)
.unwrap_or(0.0);
let x2 = node
.attribute("x2")
.and_then(parse_svg_number)
.unwrap_or(0.0);
let y2 = node
.attribute("y2")
.and_then(parse_svg_number)
.unwrap_or(0.0);
let (tx1, ty1) = transform.apply(x1, y1);
let (tx2, ty2) = transform.apply(x2, y2);
out.push(EditablePrimitive::Line {
x1_pt: tx1 * scale_x,
y1_pt: ty1 * scale_y,
x2_pt: tx2 * scale_x,
y2_pt: ty2 * scale_y,
stroke: style.stroke.clone().or(style.fill.clone()),
stroke_width_pt: style.stroke_width
* transform.scale_x().max(transform.scale_y())
* scale_x.max(scale_y),
});
}
"polygon" | "polyline" => {
let points_str = node.attribute("points").unwrap_or("");
let points = parse_points(points_str)
.into_iter()
.map(|(x, y)| transform.apply(x, y))
.collect::<Vec<_>>();
if points.len() >= 2 {
let min_x = points.iter().map(|(x, _)| *x).fold(f64::INFINITY, f64::min);
let min_y = points.iter().map(|(_, y)| *y).fold(f64::INFINITY, f64::min);
let max_x = points
.iter()
.map(|(x, _)| *x)
.fold(f64::NEG_INFINITY, f64::max);
let max_y = points
.iter()
.map(|(_, y)| *y)
.fold(f64::NEG_INFINITY, f64::max);
let w = (max_x - min_x).max(1.0);
let h = (max_y - min_y).max(1.0);
let rel_points = points
.into_iter()
.map(|(x, y)| ((x - min_x) * scale_x, (y - min_y) * scale_y))
.collect::<Vec<_>>();
out.push(EditablePrimitive::PolyShape {
x_pt: min_x * scale_x,
y_pt: min_y * scale_y,
w_pt: w * scale_x,
h_pt: h * scale_y,
points: rel_points,
closed: node.tag_name().name() == "polygon",
fill: style.fill.clone(),
stroke: style.stroke.clone().or(style.fill.clone()),
stroke_width_pt: style.stroke_width
* transform.scale_x().max(transform.scale_y())
* scale_x.max(scale_y),
});
}
}
"path" => {
let d = node.attribute("d").unwrap_or("");
if let Some(subpaths) = parse_path_subpaths(d) {
let transformed = subpaths
.into_iter()
.map(|subpath| ApproxSubpath {
points: subpath
.points
.into_iter()
.map(|(x, y)| transform.apply(x, y))
.collect(),
closed: subpath.closed,
})
.collect::<Vec<_>>();
if let Some(shape) = pathshape_from_subpaths(
&transformed,
style.fill.clone(),
style.stroke.clone().or(style.fill.clone()),
style.stroke_width
* transform.scale_x().max(transform.scale_y())
* scale_x.max(scale_y),
scale_x,
scale_y,
) {
out.push(shape);
}
}
}
_ => {}
}
}
out
}
fn collect_text_lines(
text_node: &usvg::roxmltree::Node<'_, '_>,
parent_style: &TextStyle,
fallback_x: f64,
fallback_y: f64,
) -> Vec<TextLine> {
let tspans: Vec<_> = text_node
.children()
.filter(|node| node.is_element() && node.tag_name().name() == "tspan")
.collect();
if tspans.is_empty() {
let text = text_node.text().unwrap_or("").trim().to_string();
if text.is_empty() {
return vec![];
}
return vec![TextLine {
x: fallback_x,
y: fallback_y,
text,
}];
}
tspans
.into_iter()
.filter_map(|tspan| {
let text = tspan.text().unwrap_or("").trim().to_string();
if text.is_empty() {
return None;
}
let style = parse_style(&tspan, Some(parent_style));
let raw_x = tspan
.attribute("x")
.and_then(parse_svg_number)
.unwrap_or(fallback_x);
let y = tspan
.attribute("y")
.and_then(parse_svg_number)
.unwrap_or(fallback_y);
Some(TextLine {
x: raw_x,
y,
text: if style.font_size > 0.0 { text } else { text },
})
})
.collect()
}
fn node_transform(node: &usvg::roxmltree::Node<'_, '_>) -> Transform2D {
let mut transforms = node
.ancestors()
.filter(|ancestor| ancestor.is_element())
.collect::<Vec<_>>();
transforms.reverse();
transforms
.into_iter()
.fold(Transform2D::identity(), |acc, current| {
if let Some(raw) = current.attribute("transform") {
acc.multiply(parse_transform(raw))
} else {
acc
}
})
}
fn parse_style(node: &usvg::roxmltree::Node<'_, '_>, parent: Option<&TextStyle>) -> TextStyle {
let mut style = parent.cloned().unwrap_or_default();
if let Some(font_size) = node.attribute("font-size").and_then(parse_svg_number) {
style.font_size = font_size;
}
if let Some(font_family) = node.attribute("font-family").and_then(parse_font_family) {
style.font_family = Some(font_family);
}
if let Some(fill) = node.attribute("fill").and_then(normalize_color) {
style.fill = Some(fill);
}
if let Some(stroke) = node.attribute("stroke").and_then(normalize_color) {
style.stroke = Some(stroke);
}
if let Some(stroke_width) = node.attribute("stroke-width").and_then(parse_svg_number) {
style.stroke_width = stroke_width;
}
if let Some(anchor) = node.attribute("text-anchor") {
style.text_anchor = anchor.to_string();
}
if let Some(baseline) = node.attribute("dominant-baseline") {
style.dominant_baseline = baseline.to_string();
}
if matches!(
node.attribute("font-weight"),
Some("bold" | "700" | "800" | "900")
) {
style.bold = true;
}
if let Some(font_style) = node.attribute("font-style") {
style.italic = matches!(font_style, "italic" | "oblique");
}
if let Some(decoration) = node
.attribute("text-decoration")
.or_else(|| node.attribute("text-decoration-line"))
{
style.underline = decoration
.split_whitespace()
.any(|part| part == "underline");
style.strike = decoration
.split_whitespace()
.any(|part| part == "line-through");
}
if let Some(style_attr) = node.attribute("style") {
for pair in style_attr.split(';') {
let mut parts = pair.splitn(2, ':');
let key = parts.next().unwrap_or("").trim();
let value = parts.next().unwrap_or("").trim();
match key {
"font-size" => {
if let Some(font_size) = parse_svg_number(value) {
style.font_size = font_size;
}
}
"font-family" => style.font_family = parse_font_family(value),
"fill" => {
if let Some(fill) = normalize_color(value) {
style.fill = Some(fill);
}
}
"stroke" => {
if let Some(stroke) = normalize_color(value) {
style.stroke = Some(stroke);
}
}
"stroke-width" => {
if let Some(stroke_width) = parse_svg_number(value) {
style.stroke_width = stroke_width;
}
}
"text-anchor" => style.text_anchor = value.to_string(),
"dominant-baseline" => style.dominant_baseline = value.to_string(),
"font-weight" if matches!(value, "bold" | "700" | "800" | "900") => {
style.bold = true;
}
"font-style" => style.italic = matches!(value, "italic" | "oblique"),
"text-decoration" | "text-decoration-line" => {
style.underline = value.split_whitespace().any(|part| part == "underline");
style.strike = value.split_whitespace().any(|part| part == "line-through");
}
_ => {}
}
}
}
if style.font_size <= 0.0 {
style.font_size = parent.map(|p| p.font_size).unwrap_or(14.0);
}
if style.text_anchor.is_empty() {
style.text_anchor = "start".to_string();
}
style
}
fn inherited_text_style(node: &usvg::roxmltree::Node<'_, '_>) -> TextStyle {
let mut ancestors = node
.ancestors()
.filter(|ancestor| ancestor.is_element())
.collect::<Vec<_>>();
ancestors.reverse();
ancestors
.into_iter()
.fold(TextStyle::default(), |style, current| {
parse_style(¤t, Some(&style))
})
}
fn strip_native_nodes(svg_code: &str, node_names: &[&str]) -> String {
let mut reader = Reader::from_str(svg_code);
reader.trim_text(false);
let mut writer = Writer::new(Vec::new());
let mut buf = Vec::new();
let mut skip_depth = 0usize;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
let name = String::from_utf8_lossy(e.local_name().as_ref()).to_string();
if skip_depth > 0 {
skip_depth += 1;
} else if should_strip_element(&name, &e, node_names) {
skip_depth = 1;
} else if writer.write_event(Event::Start(e)).is_err() {
return svg_code.to_owned();
}
}
Ok(Event::Empty(e)) => {
let name = String::from_utf8_lossy(e.local_name().as_ref()).to_string();
if skip_depth == 0
&& !should_strip_element(&name, &e, node_names)
&& writer.write_event(Event::Empty(e)).is_err()
{
return svg_code.to_owned();
}
}
Ok(Event::End(e)) => {
if skip_depth > 0 {
skip_depth -= 1;
} else if writer.write_event(Event::End(e)).is_err() {
return svg_code.to_owned();
}
}
Ok(Event::Eof) => break,
Ok(event) => {
if skip_depth == 0 && writer.write_event(event).is_err() {
return svg_code.to_owned();
}
}
Err(_) => return svg_code.to_owned(),
}
buf.clear();
}
String::from_utf8(writer.into_inner()).unwrap_or_else(|_| svg_code.to_owned())
}
fn get_svg_size(svg_code: &str) -> (f64, f64) {
let doc = match Document::parse(svg_code) {
Ok(doc) => doc,
Err(_) => return (100.0, 100.0),
};
let root = doc.root_element();
if let Some(viewbox) = root.attribute("viewBox") {
let parts: Vec<&str> = viewbox
.split(|ch: char| ch.is_whitespace() || ch == ',')
.filter(|part| !part.is_empty())
.collect();
if parts.len() == 4 {
if let (Ok(width), Ok(height)) = (parts[2].parse::<f64>(), parts[3].parse::<f64>()) {
return (width, height);
}
}
}
let width = root
.attribute("width")
.and_then(parse_svg_number)
.unwrap_or(100.0);
let height = root
.attribute("height")
.and_then(parse_svg_number)
.unwrap_or(100.0);
(width, height)
}
fn parse_svg_number(value: &str) -> Option<f64> {
let value = value.trim().trim_end_matches("px");
if value.ends_with('%') {
return None;
}
value.parse::<f64>().ok()
}
fn parse_transform(value: &str) -> Transform2D {
let mut out = Transform2D::identity();
let mut rest = value.trim();
while let Some(start) = rest.find('(') {
let name = rest[..start].trim();
let tail = &rest[start + 1..];
let Some(end) = tail.find(')') else {
break;
};
let args = parse_number_list(&tail[..end]);
let t = match name {
"matrix" if args.len() >= 6 => Transform2D {
a: args[0],
b: args[1],
c: args[2],
d: args[3],
e: args[4],
f: args[5],
},
"translate" if !args.is_empty() => Transform2D {
e: args[0],
f: args.get(1).copied().unwrap_or(0.0),
..Transform2D::identity()
},
"scale" if !args.is_empty() => Transform2D {
a: args[0],
d: args.get(1).copied().unwrap_or(args[0]),
..Transform2D::identity()
},
"rotate" if !args.is_empty() => {
let angle = args[0].to_radians();
let (sin, cos) = angle.sin_cos();
let rotation = Transform2D {
a: cos,
b: sin,
c: -sin,
d: cos,
e: 0.0,
f: 0.0,
};
if args.len() >= 3 {
let (cx, cy) = (args[1], args[2]);
Transform2D {
e: cx,
f: cy,
..Transform2D::identity()
}
.multiply(rotation)
.multiply(Transform2D {
e: -cx,
f: -cy,
..Transform2D::identity()
})
} else {
rotation
}
}
"skewX" if !args.is_empty() => Transform2D {
c: args[0].to_radians().tan(),
..Transform2D::identity()
},
"skewY" if !args.is_empty() => Transform2D {
b: args[0].to_radians().tan(),
..Transform2D::identity()
},
_ => Transform2D::identity(),
};
out = out.multiply(t);
rest = tail[end + 1..].trim_start();
}
out
}
fn parse_number_list(value: &str) -> Vec<f64> {
tokenize_numeric_list(value)
.into_iter()
.filter_map(|part| part.parse::<f64>().ok())
.collect()
}
fn tokenize_numeric_list(value: &str) -> Vec<String> {
let chars: Vec<char> = value.chars().collect();
let mut tokens = Vec::new();
let mut i = 0usize;
while i < chars.len() {
let ch = chars[i];
if ch.is_whitespace() || ch == ',' {
i += 1;
continue;
}
let start = i;
i += 1;
while i < chars.len() {
let ch = chars[i];
let prev = chars[i - 1];
if ch.is_whitespace() || ch == ',' {
break;
}
if (ch == '-' || ch == '+') && prev != 'e' && prev != 'E' {
break;
}
i += 1;
}
tokens.push(chars[start..i].iter().collect());
}
tokens
}
fn bbox_from_points(points: &[(f64, f64)]) -> (f64, f64, f64, f64) {
let min_x = points.iter().map(|(x, _)| *x).fold(f64::INFINITY, f64::min);
let min_y = points.iter().map(|(_, y)| *y).fold(f64::INFINITY, f64::min);
let max_x = points
.iter()
.map(|(x, _)| *x)
.fold(f64::NEG_INFINITY, f64::max);
let max_y = points
.iter()
.map(|(_, y)| *y)
.fold(f64::NEG_INFINITY, f64::max);
(min_x, min_y, max_x, max_y)
}
fn distance_between(a: (f64, f64), b: (f64, f64)) -> f64 {
let dx = b.0 - a.0;
let dy = b.1 - a.1;
(dx * dx + dy * dy).sqrt()
}
fn polyshape_from_points(
points: &[(f64, f64)],
closed: bool,
fill: Option<String>,
stroke: Option<String>,
stroke_width_pt: f64,
scale_x: f64,
scale_y: f64,
) -> Option<EditablePrimitive> {
if points.len() < 2 {
return None;
}
let (min_x, min_y, max_x, max_y) = bbox_from_points(points);
let rel_points = points
.iter()
.map(|(x, y)| ((x - min_x) * scale_x, (y - min_y) * scale_y))
.collect::<Vec<_>>();
Some(EditablePrimitive::PolyShape {
x_pt: min_x * scale_x,
y_pt: min_y * scale_y,
w_pt: (max_x - min_x).max(1.0) * scale_x,
h_pt: (max_y - min_y).max(1.0) * scale_y,
points: rel_points,
closed,
fill,
stroke,
stroke_width_pt,
})
}
fn transform_is_axis_aligned(transform: Transform2D) -> bool {
transform.b.abs() < 1e-6 && transform.c.abs() < 1e-6
}
fn sample_ellipse_points(cx: f64, cy: f64, rx: f64, ry: f64, segments: usize) -> Vec<(f64, f64)> {
(0..segments)
.map(|i| {
let t = (i as f64 / segments as f64) * std::f64::consts::TAU;
(cx + rx * t.cos(), cy + ry * t.sin())
})
.collect()
}
fn parse_points(value: &str) -> Vec<(f64, f64)> {
let nums = value
.split(|ch: char| ch.is_whitespace() || ch == ',')
.filter(|part| !part.is_empty())
.filter_map(|part| part.parse::<f64>().ok())
.collect::<Vec<_>>();
nums.chunks(2)
.filter_map(|chunk| {
if chunk.len() == 2 {
Some((chunk[0], chunk[1]))
} else {
None
}
})
.collect()
}
#[derive(Debug, Clone)]
struct ApproxSubpath {
points: Vec<(f64, f64)>,
closed: bool,
}
fn parse_path_subpaths(value: &str) -> Option<Vec<ApproxSubpath>> {
let tokens = tokenize_path(value);
if tokens.is_empty() {
return None;
}
let mut i = 0usize;
let mut cmd = ' ';
let mut current = (0.0, 0.0);
let mut start = None::<(f64, f64)>;
let mut subpaths = Vec::<ApproxSubpath>::new();
let mut current_points = Vec::<(f64, f64)>::new();
let mut last_cubic_ctrl = None::<(f64, f64)>;
let mut last_quad_ctrl = None::<(f64, f64)>;
while i < tokens.len() {
if let PathToken::Cmd(next_cmd) = tokens[i] {
cmd = next_cmd;
i += 1;
} else if cmd == ' ' {
return None;
}
match cmd {
'M' | 'm' => {
let mut first = true;
while i + 1 < tokens.len() {
let Some((x, y)) = token_pair(&tokens, i) else {
break;
};
i += 2;
current = if cmd == 'm' {
(current.0 + x, current.1 + y)
} else {
(x, y)
};
if first {
if !current_points.is_empty() {
subpaths.push(ApproxSubpath {
points: std::mem::take(&mut current_points),
closed: false,
});
}
start = Some(current);
current_points.push(current);
first = false;
} else {
current_points.push(current);
}
if i < tokens.len() && matches!(tokens[i], PathToken::Cmd(_)) {
break;
}
}
last_cubic_ctrl = None;
last_quad_ctrl = None;
}
'L' | 'l' => {
while i + 1 < tokens.len() {
let Some((x, y)) = token_pair(&tokens, i) else {
break;
};
i += 2;
current = if cmd == 'l' {
(current.0 + x, current.1 + y)
} else {
(x, y)
};
current_points.push(current);
if i < tokens.len() && matches!(tokens[i], PathToken::Cmd(_)) {
break;
}
}
last_cubic_ctrl = None;
last_quad_ctrl = None;
}
'H' | 'h' => {
while let Some(x) = token_number(&tokens, i) {
i += 1;
current = if cmd == 'h' {
(current.0 + x, current.1)
} else {
(x, current.1)
};
current_points.push(current);
if i < tokens.len() && matches!(tokens[i], PathToken::Cmd(_)) {
break;
}
}
last_cubic_ctrl = None;
last_quad_ctrl = None;
}
'V' | 'v' => {
while let Some(y) = token_number(&tokens, i) {
i += 1;
current = if cmd == 'v' {
(current.0, current.1 + y)
} else {
(current.0, y)
};
current_points.push(current);
if i < tokens.len() && matches!(tokens[i], PathToken::Cmd(_)) {
break;
}
}
last_cubic_ctrl = None;
last_quad_ctrl = None;
}
'C' | 'c' => {
while i + 5 < tokens.len() {
let Some((x1, y1)) = token_pair(&tokens, i) else {
break;
};
let Some((x2, y2)) = token_pair(&tokens, i + 2) else {
break;
};
let Some((x, y)) = token_pair(&tokens, i + 4) else {
break;
};
i += 6;
let c1 = if cmd == 'c' {
(current.0 + x1, current.1 + y1)
} else {
(x1, y1)
};
let c2 = if cmd == 'c' {
(current.0 + x2, current.1 + y2)
} else {
(x2, y2)
};
let end = if cmd == 'c' {
(current.0 + x, current.1 + y)
} else {
(x, y)
};
current_points.extend(sample_cubic_bezier(current, c1, c2, end, 12));
current = end;
last_cubic_ctrl = Some(c2);
last_quad_ctrl = None;
if i < tokens.len() && matches!(tokens[i], PathToken::Cmd(_)) {
break;
}
}
}
'S' | 's' => {
while i + 3 < tokens.len() {
let Some((x2, y2)) = token_pair(&tokens, i) else {
break;
};
let Some((x, y)) = token_pair(&tokens, i + 2) else {
break;
};
i += 4;
let c1 = last_cubic_ctrl
.map(|ctrl| reflect_point(ctrl, current))
.unwrap_or(current);
let c2 = if cmd == 's' {
(current.0 + x2, current.1 + y2)
} else {
(x2, y2)
};
let end = if cmd == 's' {
(current.0 + x, current.1 + y)
} else {
(x, y)
};
current_points.extend(sample_cubic_bezier(current, c1, c2, end, 12));
current = end;
last_cubic_ctrl = Some(c2);
last_quad_ctrl = None;
if i < tokens.len() && matches!(tokens[i], PathToken::Cmd(_)) {
break;
}
}
}
'Q' | 'q' => {
while i + 3 < tokens.len() {
let Some((cx, cy)) = token_pair(&tokens, i) else {
break;
};
let Some((x, y)) = token_pair(&tokens, i + 2) else {
break;
};
i += 4;
let ctrl = if cmd == 'q' {
(current.0 + cx, current.1 + cy)
} else {
(cx, cy)
};
let end = if cmd == 'q' {
(current.0 + x, current.1 + y)
} else {
(x, y)
};
current_points.extend(sample_quadratic_bezier(current, ctrl, end, 10));
current = end;
last_quad_ctrl = Some(ctrl);
last_cubic_ctrl = None;
if i < tokens.len() && matches!(tokens[i], PathToken::Cmd(_)) {
break;
}
}
}
'T' | 't' => {
while i + 1 < tokens.len() {
let Some((x, y)) = token_pair(&tokens, i) else {
break;
};
i += 2;
let ctrl = last_quad_ctrl
.map(|q| reflect_point(q, current))
.unwrap_or(current);
let end = if cmd == 't' {
(current.0 + x, current.1 + y)
} else {
(x, y)
};
current_points.extend(sample_quadratic_bezier(current, ctrl, end, 10));
current = end;
last_quad_ctrl = Some(ctrl);
last_cubic_ctrl = None;
if i < tokens.len() && matches!(tokens[i], PathToken::Cmd(_)) {
break;
}
}
}
'A' | 'a' => {
while i + 6 < tokens.len() {
let rx = token_number(&tokens, i)?;
let ry = token_number(&tokens, i + 1)?;
let x_axis_rotation = token_number(&tokens, i + 2)?;
let large_arc = token_number(&tokens, i + 3)? != 0.0;
let sweep = token_number(&tokens, i + 4)? != 0.0;
let x = token_number(&tokens, i + 5)?;
let y = token_number(&tokens, i + 6)?;
i += 7;
let end = if cmd == 'a' {
(current.0 + x, current.1 + y)
} else {
(x, y)
};
current_points.extend(sample_arc(
current,
rx,
ry,
x_axis_rotation,
large_arc,
sweep,
end,
16,
));
current = end;
last_cubic_ctrl = None;
last_quad_ctrl = None;
if i < tokens.len() && matches!(tokens[i], PathToken::Cmd(_)) {
break;
}
}
}
'Z' | 'z' => {
let closed_points = std::mem::take(&mut current_points);
if !closed_points.is_empty() {
subpaths.push(ApproxSubpath {
points: closed_points,
closed: true,
});
}
if let Some(start_point) = start {
current = start_point;
}
last_cubic_ctrl = None;
last_quad_ctrl = None;
}
_ => return None,
}
}
if !current_points.is_empty() {
subpaths.push(ApproxSubpath {
points: current_points,
closed: false,
});
}
let filtered = subpaths
.into_iter()
.filter(|subpath| subpath.points.len() >= 2)
.collect::<Vec<_>>();
if filtered.is_empty() {
None
} else {
Some(filtered)
}
}
#[derive(Debug, Clone, Copy)]
enum PathToken {
Cmd(char),
Num(f64),
}
fn tokenize_path(value: &str) -> Vec<PathToken> {
let mut tokens = Vec::new();
let chars: Vec<char> = value.chars().collect();
let mut i = 0usize;
while i < chars.len() {
let ch = chars[i];
if ch.is_whitespace() || ch == ',' {
i += 1;
continue;
}
if ch.is_ascii_alphabetic() {
tokens.push(PathToken::Cmd(ch));
i += 1;
continue;
}
let start = i;
i += 1;
while i < chars.len() {
let ch = chars[i];
let prev = chars[i - 1];
if ch.is_whitespace() || ch == ',' {
break;
}
if ch.is_ascii_alphabetic() {
break;
}
if (ch == '-' || ch == '+') && prev != 'e' && prev != 'E' {
break;
}
i += 1;
}
let slice: String = chars[start..i].iter().collect();
if let Ok(num) = slice.parse::<f64>() {
tokens.push(PathToken::Num(num));
} else {
return vec![];
}
}
tokens
}
fn token_pair(tokens: &[PathToken], index: usize) -> Option<(f64, f64)> {
Some((
token_number(tokens, index)?,
token_number(tokens, index + 1)?,
))
}
fn token_number(tokens: &[PathToken], index: usize) -> Option<f64> {
match tokens.get(index)? {
PathToken::Num(value) => Some(*value),
PathToken::Cmd(_) => None,
}
}
fn should_strip_element(name: &str, elem: &BytesStart<'_>, node_names: &[&str]) -> bool {
if !node_names.iter().any(|candidate| candidate == &name) {
return false;
}
if name != "path" {
return true;
}
let attrs = elem
.attributes()
.filter_map(|attr| attr.ok())
.map(|attr| {
(
String::from_utf8_lossy(attr.key.as_ref()).to_string(),
String::from_utf8_lossy(attr.value.as_ref()).to_string(),
)
})
.collect::<Vec<_>>();
let d = attrs
.iter()
.find(|(key, _)| key == "d")
.map(|(_, value)| value.as_str())
.unwrap_or("");
parse_path_subpaths(d).is_some()
}
fn pathshape_from_subpaths(
subpaths: &[ApproxSubpath],
fill: Option<String>,
stroke: Option<String>,
stroke_width_pt: f64,
scale_x: f64,
scale_y: f64,
) -> Option<EditablePrimitive> {
let mut all_points = Vec::new();
for subpath in subpaths {
all_points.extend(subpath.points.iter().copied());
}
if all_points.len() < 2 {
return None;
}
let (min_x, min_y, max_x, max_y) = bbox_from_points(&all_points);
let mut path_parts = Vec::new();
for subpath in subpaths {
if subpath.points.len() < 2 {
continue;
}
let rel = subpath
.points
.iter()
.map(|(x, y)| ((x - min_x) * scale_x, (y - min_y) * scale_y))
.collect::<Vec<_>>();
path_parts.push(vml_path(&rel, subpath.closed));
}
let path = path_parts.join(" ");
Some(EditablePrimitive::PathShape {
x_pt: min_x * scale_x,
y_pt: min_y * scale_y,
w_pt: (max_x - min_x).max(1.0) * scale_x,
h_pt: (max_y - min_y).max(1.0) * scale_y,
path,
fill,
stroke,
stroke_width_pt,
})
}
fn reflect_point(point: (f64, f64), around: (f64, f64)) -> (f64, f64) {
(2.0 * around.0 - point.0, 2.0 * around.1 - point.1)
}
fn sample_cubic_bezier(
p0: (f64, f64),
p1: (f64, f64),
p2: (f64, f64),
p3: (f64, f64),
segments: usize,
) -> Vec<(f64, f64)> {
(1..=segments)
.map(|step| {
let t = step as f64 / segments as f64;
let mt = 1.0 - t;
let x = mt.powi(3) * p0.0
+ 3.0 * mt.powi(2) * t * p1.0
+ 3.0 * mt * t.powi(2) * p2.0
+ t.powi(3) * p3.0;
let y = mt.powi(3) * p0.1
+ 3.0 * mt.powi(2) * t * p1.1
+ 3.0 * mt * t.powi(2) * p2.1
+ t.powi(3) * p3.1;
(x, y)
})
.collect()
}
fn sample_quadratic_bezier(
p0: (f64, f64),
p1: (f64, f64),
p2: (f64, f64),
segments: usize,
) -> Vec<(f64, f64)> {
(1..=segments)
.map(|step| {
let t = step as f64 / segments as f64;
let mt = 1.0 - t;
let x = mt.powi(2) * p0.0 + 2.0 * mt * t * p1.0 + t.powi(2) * p2.0;
let y = mt.powi(2) * p0.1 + 2.0 * mt * t * p1.1 + t.powi(2) * p2.1;
(x, y)
})
.collect()
}
fn sample_arc(
start: (f64, f64),
mut rx: f64,
mut ry: f64,
x_axis_rotation: f64,
large_arc: bool,
sweep: bool,
end: (f64, f64),
min_segments: usize,
) -> Vec<(f64, f64)> {
if rx == 0.0 || ry == 0.0 || start == end {
return vec![end];
}
rx = rx.abs();
ry = ry.abs();
let phi = x_axis_rotation.to_radians();
let (sin_phi, cos_phi) = phi.sin_cos();
let dx2 = (start.0 - end.0) / 2.0;
let dy2 = (start.1 - end.1) / 2.0;
let x1p = cos_phi * dx2 + sin_phi * dy2;
let y1p = -sin_phi * dx2 + cos_phi * dy2;
let lambda = x1p.powi(2) / rx.powi(2) + y1p.powi(2) / ry.powi(2);
if lambda > 1.0 {
let scale = lambda.sqrt();
rx *= scale;
ry *= scale;
}
let numerator = rx.powi(2) * ry.powi(2) - rx.powi(2) * y1p.powi(2) - ry.powi(2) * x1p.powi(2);
let denominator = rx.powi(2) * y1p.powi(2) + ry.powi(2) * x1p.powi(2);
let factor = if denominator.abs() < 1e-9 {
0.0
} else {
(numerator / denominator).max(0.0).sqrt()
};
let sign = if large_arc == sweep { -1.0 } else { 1.0 };
let cxp = sign * factor * (rx * y1p / ry);
let cyp = sign * factor * (-ry * x1p / rx);
let cx = cos_phi * cxp - sin_phi * cyp + (start.0 + end.0) / 2.0;
let cy = sin_phi * cxp + cos_phi * cyp + (start.1 + end.1) / 2.0;
let start_angle = angle_between((1.0, 0.0), ((x1p - cxp) / rx, (y1p - cyp) / ry));
let mut delta_angle = angle_between(
((x1p - cxp) / rx, (y1p - cyp) / ry),
((-x1p - cxp) / rx, (-y1p - cyp) / ry),
);
if !sweep && delta_angle > 0.0 {
delta_angle -= std::f64::consts::TAU;
} else if sweep && delta_angle < 0.0 {
delta_angle += std::f64::consts::TAU;
}
let segments =
min_segments.max((delta_angle.abs() / (std::f64::consts::PI / 8.0)).ceil() as usize);
(1..=segments)
.map(|step| {
let t = start_angle + delta_angle * (step as f64 / segments as f64);
let (sin_t, cos_t) = t.sin_cos();
let x = cos_phi * rx * cos_t - sin_phi * ry * sin_t + cx;
let y = sin_phi * rx * cos_t + cos_phi * ry * sin_t + cy;
(x, y)
})
.collect()
}
fn angle_between(u: (f64, f64), v: (f64, f64)) -> f64 {
let dot = u.0 * v.0 + u.1 * v.1;
let det = u.0 * v.1 - u.1 * v.0;
det.atan2(dot)
}
fn vml_path(points: &[(f64, f64)], closed: bool) -> String {
if points.is_empty() {
return String::new();
}
let mut cmds = Vec::with_capacity(points.len() + 2);
let (x0, y0) = points[0];
cmds.push(format!("m{},{}", round_coord(x0), round_coord(y0)));
if points.len() > 1 {
let rest = points[1..]
.iter()
.map(|(x, y)| format!("{},{}", round_coord(*x), round_coord(*y)))
.collect::<Vec<_>>()
.join(",");
cmds.push(format!("l{}", rest));
}
if closed {
cmds.push("x".to_string());
}
cmds.push("e".to_string());
cmds.join(" ")
}
fn round_coord(value: f64) -> i32 {
value.round() as i32
}
fn normalize_color(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() || value.eq_ignore_ascii_case("none") {
return None;
}
value.strip_prefix('#').map(|v| v.to_uppercase())
}
fn parse_font_family(value: &str) -> Option<String> {
let family = value
.split(',')
.next()
.unwrap_or("")
.trim()
.trim_matches(['\'', '"']);
(!family.is_empty()).then(|| family.to_string())
}
fn estimate_text_width(text: &str, font_size: f64) -> f64 {
let total = text.chars().fold(0.0, |sum, ch| sum + char_width(ch));
total * font_size
}
fn char_width(ch: char) -> f64 {
if ch.is_whitespace() {
0.33
} else if ('\u{3400}'..='\u{9FFF}').contains(&ch)
|| ('\u{3040}'..='\u{30FF}').contains(&ch)
|| ('\u{AC00}'..='\u{D7AF}').contains(&ch)
|| ('\u{F900}'..='\u{FAFF}').contains(&ch)
{
1.0
} else if matches!(ch, 'M' | 'W' | '@' | '#' | '%' | '&') {
0.9
} else if ch.is_ascii_uppercase() {
0.72
} else if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
0.58
} else if matches!(
ch,
'i' | 'l' | 'I' | '1' | '.' | ',' | ':' | ';' | '\'' | '`' | '!' | '|'
) {
0.3
} else if matches!(ch, '(' | ')' | '[' | ']' | '{' | '}') {
0.38
} else if matches!(ch, '-' | '_' | '/' | '\\' | '+' | '*' | '=' | '~' | '^') {
0.46
} else {
0.64
}
}
fn format_pt(value: f64) -> String {
let mut out = format!("{value:.3}");
while out.contains('.') && out.ends_with('0') {
out.pop();
}
if out.ends_with('.') {
out.pop();
}
out
}
fn escape_xml(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::{
bbox_from_points, extract_primitives, extract_text_boxes, parse_path_subpaths,
parse_points, parse_transform, strip_native_nodes, vml_path,
};
#[test]
fn strips_text_nodes_from_svg() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><rect x="0" y="0" width="10" height="10"/><text x="5" y="5">Hi</text></svg>"#;
let stripped = strip_native_nodes(svg, &["text"]);
assert!(stripped.contains("<rect"));
assert!(!stripped.contains("<text"));
assert!(!stripped.contains("Hi"));
}
#[test]
fn extracts_scaled_text_boxes() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50"><text x="10" y="20" style="font-size:10px;fill:#123456">Hello</text></svg>"##;
let boxes = extract_text_boxes(svg, 2.0, 2.0);
assert_eq!(boxes.len(), 1);
assert_eq!(boxes[0].text, "Hello");
assert_eq!(boxes[0].color, "123456");
}
#[test]
fn exports_inherited_svg_font_family_to_word_xml() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><g style="font-family:'Times New Roman'"><text x="10" y="30" style="font-size:20px;font-weight:700;fill:#123456">Font test</text></g></svg>"##;
let group = super::EditableSvgGroup::from_svg_string(
svg.to_string(),
"font test".to_string(),
"zh-CN".to_string(),
)
.expect("parse SVG");
let xml = group.generate_xml();
assert!(xml.contains(r#"w:ascii="Times New Roman""#));
assert!(xml.contains(r#"w:hAnsi="Times New Roman""#));
assert!(xml.contains(r#"w:eastAsia="Times New Roman""#));
assert!(xml.contains("<w:b/>"));
assert!(xml.contains(r#"w:color w:val="123456""#));
}
#[test]
fn exports_svg_text_emphasis_to_word_xml() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><text x="10" y="30" style="font-size:20px;font-style:italic;text-decoration:underline line-through">Styled</text></svg>"#;
let group = super::EditableSvgGroup::from_svg_string(
svg.to_string(),
"style test".to_string(),
"zh-CN".to_string(),
)
.expect("parse SVG");
let xml = group.generate_xml();
assert!(xml.contains("<w:i/>"), "{xml}");
assert!(xml.contains(r#"<w:u w:val="single"/>"#), "{xml}");
assert!(xml.contains("<w:strike/>"), "{xml}");
}
#[test]
fn extracts_basic_primitives() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50"><rect x="10" y="5" width="30" height="20" rx="4" fill="#ABCDEF"/><line x1="0" y1="0" x2="100" y2="50" stroke="#654321" stroke-width="2"/></svg>"##;
let primitives = extract_primitives(svg, 1.0, 1.0);
assert_eq!(primitives.len(), 2);
}
#[test]
fn parses_polygon_points_and_builds_vml_path() {
let points = parse_points("0,0 10,0 10,10 0,10");
assert_eq!(points.len(), 4);
assert_eq!(vml_path(&points, true), "m0,0 l10,0,10,10,0,10 x e");
}
#[test]
fn parses_simple_path_points() {
let path = "M 0 0 L 10 0 L 10 10 Z";
let subpaths = parse_path_subpaths(path).expect("simple path");
assert_eq!(subpaths.len(), 1);
assert_eq!(subpaths[0].points.len(), 3);
assert!(subpaths[0].closed);
}
#[test]
fn parses_transform_and_applies_to_bbox() {
let t = parse_transform("translate(10,20) scale(2,3)");
let points = [t.apply(0.0, 0.0), t.apply(5.0, 4.0)];
let (min_x, min_y, max_x, max_y) = bbox_from_points(&points);
assert_eq!((min_x, min_y, max_x, max_y), (10.0, 20.0, 20.0, 32.0));
}
#[test]
fn parses_curved_path_into_points() {
let path = "M 0 0 C 10 0 10 10 20 10";
let subpaths = parse_path_subpaths(path).expect("curved path");
assert_eq!(subpaths.len(), 1);
assert!(subpaths[0].points.len() > 3);
assert_eq!(subpaths[0].points.first().copied(), Some((0.0, 0.0)));
assert_eq!(subpaths[0].points.last().copied(), Some((20.0, 10.0)));
}
}