use std::path::Path;
use resvg::usvg;
pub fn flatten_text_to_paths(svg: &str, font_dir: Option<&Path>) -> Result<String, String> {
if !contains_text_element(svg) {
return Ok(svg.to_string());
}
let mut options = usvg::Options::default();
options.fontdb_mut().load_system_fonts();
if let Some(dir) = font_dir {
options.fontdb_mut().load_fonts_dir(dir);
}
let tree = usvg::Tree::from_str(svg, &options)
.map_err(|e| format!("Failed to parse SVG for text-to-path flattening: {e}"))?;
Ok(tree.to_string(&usvg::WriteOptions::default()))
}
fn contains_text_element(svg: &str) -> bool {
let bytes = svg.as_bytes();
let needle = b"<text";
let mut i = 0;
while i + needle.len() < bytes.len() {
if &bytes[i..i + needle.len()] == needle {
let next = bytes[i + needle.len()];
if next == b' '
|| next == b'>'
|| next == b'\t'
|| next == b'\n'
|| next == b'\r'
|| next == b'/'
{
return true;
}
}
i += 1;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flatten_text_to_paths_removes_text_element() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="80">
<rect width="200" height="80" fill="#ffffff"/>
<text x="20" y="50" font-size="24" fill="#000000">Hi</text>
</svg>"##;
let out = flatten_text_to_paths(svg, None).expect("flatten");
assert!(
!out.contains("<text "),
"text element should be removed: {out}"
);
}
#[test]
fn flatten_text_to_paths_returns_err_on_invalid_svg_with_text() {
let result = flatten_text_to_paths("<text>broken without root", None);
assert!(result.is_err(), "got: {result:?}");
}
#[test]
fn flatten_text_to_paths_passes_text_free_svg_through_unchanged() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><circle cx="50" cy="50" r="20" fill="blue"/></svg>"#;
let out = flatten_text_to_paths(svg, None).expect("flatten");
assert_eq!(out, svg);
}
#[test]
fn contains_text_element_matches_real_text_tags() {
assert!(contains_text_element("<text>foo</text>"));
assert!(contains_text_element("<text x=\"0\">foo</text>"));
assert!(contains_text_element("<text\nx=\"0\">foo</text>"));
assert!(contains_text_element("<text/>"));
}
#[test]
fn contains_text_element_ignores_attribute_substrings() {
assert!(!contains_text_element(r#"<rect id="textbox"/>"#));
assert!(!contains_text_element(r#"<g class="text-layer"/>"#));
assert!(!contains_text_element("<svg></svg>"));
}
#[test]
fn flatten_text_to_paths_does_not_panic_on_empty_or_whitespace_text() {
let svgs = [
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><text x="10" y="20"></text></svg>"##,
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><text x="10" y="20"> </text></svg>"##,
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><text/></svg>"##,
];
for svg in svgs {
let result = flatten_text_to_paths(svg, None);
assert!(
result.is_ok(),
"empty/whitespace text panicked or errored for {svg}: {result:?}"
);
}
}
#[test]
fn flatten_text_to_paths_does_not_panic_with_nonexistent_font_dir() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><text x="10" y="20" font-size="14">Hi</text></svg>"##;
let result =
flatten_text_to_paths(svg, Some(Path::new("/nonexistent-path-xyz-blueprinter")));
let _ = result;
}
#[test]
fn contains_text_element_handles_truncated_input_at_text_tag_boundary() {
assert!(!contains_text_element("<text"));
assert!(!contains_text_element("<tex"));
assert!(!contains_text_element("<t"));
assert!(!contains_text_element("<"));
assert!(!contains_text_element(""));
}
#[test]
fn flatten_text_to_paths_preserves_non_text_elements_alongside_text() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="80">
<rect x="10" y="10" width="30" height="20" fill="blue"/>
<text x="50" y="50" font-size="14">Hi</text>
</svg>"##;
let out = flatten_text_to_paths(svg, None).expect("flatten");
assert!(
out.contains("<path"),
"should contain at least one path: {out}"
);
assert!(!out.contains("<text "), "text should be flattened: {out}");
}
}