use crate::compat::{String, Vec};
use crate::core::{Rect, Size};
use crate::render::{PaintBackend, RenderContext, SvgPaintBackend};
use crate::widget::{Draw, Widget};
pub fn render_to_svg<W: Draw + Widget>(widget: &mut W) -> String {
let geom = widget.geometry();
render_widget_to_svg(widget, geom)
}
pub fn render_widget_to_svg<T: Draw + ?Sized>(widget: &mut T, geometry: Rect) -> String {
render_widget_to_svg_on(widget, geometry, crate::core::Color::WHITE)
}
pub fn render_widget_to_svg_on<T: Draw + ?Sized>(
widget: &mut T,
geometry: Rect,
backdrop: crate::core::Color,
) -> String {
let size = Size::new(geometry.width, geometry.height);
let mut backend = SvgPaintBackend::new(size);
backend.begin_frame(backdrop);
let mut ctx = RenderContext::new(&mut backend);
widget.draw(&mut ctx);
backend.end_frame();
backend.finish()
}
pub fn text_ink_box(svg: &str) -> Option<(i32, i32, i32, i32)> {
text_ink_boxes(svg).first().copied()
}
pub fn text_ink_boxes(svg: &str) -> Vec<(i32, i32, i32, i32)> {
let mut boxes = Vec::new();
for line in svg.lines() {
if !line.contains("<path") {
continue;
}
let Some(d) = attribute_str(line, "d") else {
continue;
};
if !is_text_path(d) {
continue;
}
if let Some(bounds) = path_bounds(d) {
boxes.push(bounds);
}
}
boxes
}
fn is_text_path(d: &str) -> bool {
let mut closed = 0usize;
let mut stepped = 0usize;
for byte in d.bytes() {
match byte {
b'h' | b'v' | b'H' | b'V' => stepped += 1,
b'z' | b'Z' => closed += 1,
b'L' | b'l' | b'C' | b'c' | b'Q' | b'q' | b'A' | b'a' | b'S' | b's' | b'T' | b't' => {
return false
}
_ => {}
}
}
stepped >= 2 && closed >= 1
}
pub fn text_subpath_count(svg: &str) -> usize {
svg.lines()
.filter(|line| line.contains("<path"))
.filter_map(|line| attribute_str(line, "d"))
.map(|d| d.matches('M').count())
.sum()
}
pub fn first_shape_bounds(svg: &str) -> Option<(i32, i32, i32, i32)> {
svg.lines()
.filter(|line| line.contains("<path"))
.filter_map(|line| attribute_str(line, "d"))
.filter(|d| !is_text_path(d))
.find_map(path_bounds)
}
fn path_bounds(d: &str) -> Option<(i32, i32, i32, i32)> {
let bytes = d.as_bytes();
let mut i = 0usize;
let mut bounds: Option<(i32, i32, i32, i32)> = None;
let mut cursor: Option<(f32, f32)> = None;
while i < bytes.len() {
let command = bytes[i];
i += 1;
match command {
b'M' | b'L' => {
let (x, y, next) = number_pair(d, i)?;
i = next;
cursor = Some((x, y));
include(&mut bounds, x, y);
}
b'm' | b'l' => {
let (dx, dy, next) = number_pair(d, i)?;
i = next;
let (x, y) = cursor?;
let point = (x + dx, y + dy);
cursor = Some(point);
include(&mut bounds, point.0, point.1);
}
b'h' | b'v' => {
let (delta, next) = number(d, i)?;
i = next;
let (x, y) = cursor?;
let point = if command == b'h' { (x + delta, y) } else { (x, y + delta) };
cursor = Some(point);
include(&mut bounds, point.0, point.1);
}
b'H' | b'V' => {
let (value, next) = number(d, i)?;
i = next;
let (x, y) = cursor?;
let point = if command == b'H' { (value, y) } else { (x, value) };
cursor = Some(point);
include(&mut bounds, point.0, point.1);
}
b'z' | b'Z' | b' ' | b',' | b'\t' | b'\n' => {}
_ => {
if let Some((_, next)) = number(d, i) {
i = next;
}
}
}
}
bounds
}
fn include(bounds: &mut Option<(i32, i32, i32, i32)>, x: f32, y: f32) {
let (x, y) = (x.floor() as i32, y.floor() as i32);
*bounds = Some(match *bounds {
None => (x, y, x, y),
Some((left, top, right, bottom)) => (left.min(x), top.min(y), right.max(x), bottom.max(y)),
});
}
fn number(text: &str, at: usize) -> Option<(f32, usize)> {
let bytes = text.as_bytes();
let mut i = at;
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b',') {
i += 1;
}
let start = i;
if i < bytes.len() && (bytes[i] == b'-' || bytes[i] == b'+') {
i += 1;
}
let digits_start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i < bytes.len() && bytes[i] == b'.' {
i += 1;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
}
if i == digits_start {
return None;
}
text[start..i].parse().ok().map(|value: f32| (value, i))
}
fn number_pair(text: &str, at: usize) -> Option<(f32, f32, usize)> {
let (first, after_first) = number(text, at)?;
let (second, after_second) = number(text, after_first)?;
Some((first, second, after_second))
}
fn attribute_str<'a>(line: &'a str, name: &str) -> Option<&'a str> {
let key = format!("{name}=\"");
let at = line.find(&key)? + key.len();
let end = line[at..].find('"')? + at;
Some(&line[at..end])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compat::MiniToString;
use crate::core::Font;
use crate::core::Rect;
use crate::widget::Button;
#[test]
fn render_widget_to_svg_produces_valid_svg() {
let mut btn = Button::new("OK".to_string(), Rect::new(0, 0, 80, 30));
let svg = render_widget_to_svg(&mut btn, Rect::new(0, 0, 80, 30));
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
assert!(svg.contains("width=\"80\""));
assert!(svg.contains("height=\"30\""));
}
#[test]
fn render_widget_to_svg_contains_elements() {
let mut btn = Button::new("Click Me".to_string(), Rect::new(0, 0, 120, 40));
let svg = render_widget_to_svg(&mut btn, Rect::new(0, 0, 120, 40));
assert!(svg.contains("fill=") || svg.contains("stroke="));
}
#[test]
fn render_to_svg_wrapper_works() {
let mut btn = Button::new("OK".to_string(), Rect::new(0, 0, 80, 30));
let svg = render_to_svg(&mut btn);
assert!(svg.starts_with("<svg"));
assert!(svg.contains("width=\"80\""));
}
#[test]
fn the_emitted_path_reproduces_the_glyph_box_exactly() {
let _theme_guard = crate::style::theme_test_guard();
use crate::render::text::estimate_cluster_advance;
for size in [11.0f32, 12.0, 13.0, 14.0, 20.0, 48.0] {
let font = Font::new("Arial", size, false, false);
let origin = crate::core::Point::new(10, 53);
let mut backend = crate::render::SvgPaintBackend::new(Size::new(400, 160));
{
use crate::render::RenderContext;
let mut context = RenderContext::new(&mut backend);
context.draw_text(
origin,
"Sample",
&font,
crate::core::Color::BLACK,
crate::core::HorizontalAlignment::Left,
);
}
let document = backend.finish();
let (left, top, right, bottom) =
text_ink_box(&document).expect("a text path was emitted");
let height = font.size().max(1.0).round() as i32;
assert_eq!(top, origin.y, "size {size}: the glyph box top edge");
assert_eq!(bottom, origin.y + height, "size {size}: the glyph box bottom edge");
assert_eq!(left, origin.x, "size {size}: left-aligned ink starts at the origin");
let advance: i32 = "Sample"
.chars()
.map(|ch| estimate_cluster_advance(&ch.to_string(), size, 1.0).round() as i32)
.sum();
assert!(
right - left <= advance,
"size {size}: ink width {} exceeds the {advance}px advance",
right - left
);
assert!(right > left, "size {size}: the string drew no ink at all");
}
}
#[test]
fn text_subpath_count_separates_a_drawn_string_from_an_empty_one() {
let _theme_guard = crate::style::theme_test_guard();
let font = Font::new("Arial", 14.0, false, false);
let paint = |text: &str| {
let mut backend = crate::render::SvgPaintBackend::new(Size::new(200, 60));
{
use crate::render::RenderContext;
let mut context = RenderContext::new(&mut backend);
context.draw_text(
crate::core::Point::new(4, 4),
text,
&font,
crate::core::Color::BLACK,
crate::core::HorizontalAlignment::Left,
);
}
backend.finish()
};
assert_eq!(text_subpath_count(&paint("")), 0);
assert_eq!(text_subpath_count(&paint(" ")), 0);
let one = text_subpath_count(&paint("O"));
let two = text_subpath_count(&paint("OO"));
assert!(one > 0, "a drawn glyph has set bits");
assert_eq!(two, one * 2, "the pen advanced so the second O is a second glyph");
}
#[test]
fn reading_text_geometry_from_a_document_without_text_is_none() {
assert_eq!(text_ink_box(r#"<rect x="0" y="5" width="1" height="1" />"#), None);
assert_eq!(text_ink_box(r##"<path d="" fill="#000" />"##), None);
assert_eq!(text_ink_box(r##"<path fill="#000" />"##), None);
assert_eq!(text_subpath_count(r#"<rect x="0" y="1" width="2" height="3" />"#), 0);
assert_eq!(text_ink_box("<path d=\"M0 0h1v1h-1z\" fill=\"#000\" />"), Some((0, 0, 1, 1)));
}
}