use crate::core::{extract_control_primitives, extract_style_primitives};
use crate::render::{
CHAR_WIDTH_RATIO, FALLBACK_WIDTH, LINE_HEIGHT_RATIO, build_svg_footer, build_svg_header,
escape_char_xml, indent, inject_embed_font_css,
};
use crate::{
ContentSpec, ControlSpec, CursorSpec, LayoutSpec, StyleSpec, TextAlign, VerticalAlign,
};
pub(crate) fn render_typing_svg(
content: &ContentSpec,
style: &StyleSpec,
layout: &LayoutSpec,
cursor: &CursorSpec,
control: &ControlSpec,
) -> String {
let lines = compute_text_lines(content);
let (frame_delay, fade_duration, start_delay) = extract_control_primitives(control);
let (font_size, font_family, fill_color, background_color) = extract_style_primitives(style);
let (padding, line_height, char_spacing, total_text_height) =
compute_layout_metrics(layout, lines.len(), font_size);
let (vw, vh) = compute_canvas_size(layout, &lines, total_text_height, char_spacing, padding);
let start_y = compute_vertical_offset(layout, total_text_height, vh, padding, font_size);
let svg_header = build_svg_header(
layout,
vw,
vh,
font_family,
font_size,
fill_color,
background_color,
);
let (style, font_face) = inject_embed_font_css(style);
let line_origins = compute_line_origins(&lines, layout, vw, start_y, line_height, char_spacing);
let mut cursor_path: Vec<(u32, u32)> = vec![];
let svg_body = build_svg_text_block(
&lines,
&line_origins,
char_spacing,
&mut cursor_path,
frame_delay,
fade_duration,
start_delay,
);
let svg_cursor = build_svg_cursor_path(&style, cursor, &cursor_path, frame_delay, start_delay);
let svg_footer = build_svg_footer();
let estimated_size =
svg_header.len() + font_face.len() + svg_body.len() + svg_cursor.len() + svg_footer.len();
let mut svg = String::with_capacity(estimated_size);
svg.push_str(&svg_header);
svg.push_str(&font_face);
svg.push_str(&svg_body);
svg.push_str(&svg_cursor);
svg.push_str(svg_footer);
svg
}
fn build_tspan_animate(ch: char, x: u32, y: u32, begin_ms: u32, char_duration: u32) -> String {
format!(
"{indent_tspan}<tspan x=\"{x}\" y=\"{y}\" style=\"opacity:0\">\n\
{indent_animate}<animate attributeName=\"opacity\" from=\"0\" to=\"1\" begin=\"{begin}ms\" dur=\"{duration}ms\" fill=\"freeze\" />\n\
{indent_text}{}\n\
{indent_tspan}</tspan>\n",
escape_char_xml(ch),
x = x,
y = y,
begin = begin_ms,
duration = char_duration,
indent_tspan = indent(2),
indent_animate = indent(3),
indent_text = indent(3),
)
}
fn build_svg_text_block(
lines: &[Vec<char>],
line_origins: &[(u32, u32)],
char_spacing: u32,
cursor_path: &mut Vec<(u32, u32)>,
frame_delay: u32,
fade_duration: u32,
start_delay: u32,
) -> String {
let mut out = String::new();
out.push_str(&format!(
"{indent}<text text-anchor=\"start\" dominant-baseline=\"middle\">\n",
indent = indent(1)
));
let mut global_index = 0;
for (i, line_chars) in lines.iter().enumerate() {
let (mut current_x, y) = line_origins[i];
for ch in line_chars {
let begin = global_index * frame_delay + start_delay;
cursor_path.push((current_x, y));
out.push_str(&build_tspan_animate(
*ch,
current_x,
y,
begin,
fade_duration,
));
current_x += char_spacing;
global_index += 1;
}
}
out.push_str(&format!("{indent}</text>\n", indent = indent(1)));
out
}
fn build_svg_cursor_path(
style: &StyleSpec,
cursor: &CursorSpec,
path: &[(u32, u32)],
frame_delay: u32,
start_delay: u32,
) -> String {
if path.is_empty() {
return String::new();
}
let mut x_values: Vec<String> = vec![path[0].0.to_string()];
let mut y_values: Vec<String> = vec![path[0].1.to_string()];
let mut key_times: Vec<String> = vec!["0.0".to_string()];
for (i, (x, y)) in path.iter().enumerate() {
let offset = if i == path.len() - 1 {
8
} else {
cursor.offset_x
};
x_values.push((x + offset).to_string());
y_values.push(y.to_string());
let t = (i + 1) as f32 / path.len().max(1) as f32;
key_times.push(format!("{:.3}", t));
}
let total_duration = frame_delay * path.len() as u32;
let cursor_color = cursor.color.as_deref().unwrap_or_else(|| {
style
.text_color
.as_deref()
.unwrap_or(crate::DEFAULT_STYLE_TEXT_COLOR)
});
format!(
"{indent_1}<style>\n\
{indent_2}.cursor {{\n\
{indent_3}animation: blink {blink:.2}s steps(1, start) infinite;\n\
{indent_2}}}\n\
{indent_2}@keyframes blink {{\n\
{indent_3}0%, 100% {{\n\
{indent_4}opacity: {opacity};\n\
{indent_3}}}\n\
{indent_3}50% {{\n\
{indent_4}opacity: 0;\n\
{indent_3}}}\n\
{indent_2}}}\n\
{indent_1}</style>\n\
{indent_1}<text id=\"cursor\" class=\"cursor\" font-family=\"{font_family}\" font-size=\"{font_size}\" fill=\"{color}\" dominant-baseline=\"middle\">{c}</text>\n\
{indent_1}<animate href=\"#cursor\" attributeName=\"x\" values=\"{x_vals}\" keyTimes=\"{key_times}\" begin=\"{delay}ms\" dur=\"{dur}ms\" calcMode=\"discrete\" fill=\"freeze\" />\n\
{indent_1}<animate href=\"#cursor\" attributeName=\"y\" values=\"{y_vals}\" keyTimes=\"{key_times}\" begin=\"{delay}ms\" dur=\"{dur}ms\" calcMode=\"discrete\" fill=\"freeze\" />\n",
indent_1 = indent(1),
indent_2 = indent(2),
indent_3 = indent(3),
indent_4 = indent(4),
x_vals = x_values.join(";"),
y_vals = y_values.join(";"),
key_times = key_times.join(";"),
delay = start_delay,
dur = total_duration,
font_size = style.font_size,
font_family = style.font_family,
color = cursor_color,
blink = cursor.blink_ms as f32 / 1000.0,
opacity = cursor.opacity,
c = &cursor.char,
)
}
fn compute_text_lines(content: &ContentSpec) -> Vec<Vec<char>> {
content
.text
.lines()
.map(|line| line.chars().collect())
.collect()
}
fn compute_layout_metrics(
layout: &LayoutSpec,
line_count: usize,
font_size: u32,
) -> (u32, u32, u32, u32) {
let padding = layout.padding.unwrap_or(0);
let line_height = (font_size as f32 * LINE_HEIGHT_RATIO).round() as u32;
let char_spacing = (font_size as f32 * CHAR_WIDTH_RATIO).round() as u32;
let total_text_height = line_count as u32 * line_height;
(padding, line_height, char_spacing, total_text_height)
}
fn compute_canvas_size(
layout: &LayoutSpec,
lines: &[Vec<char>],
total_text_height: u32,
char_spacing: u32,
padding: u32,
) -> (u32, u32) {
let calculated_width = lines
.iter()
.map(|line| char_spacing * line.len() as u32 + padding * 2)
.max()
.unwrap_or(FALLBACK_WIDTH);
let vw = layout.width.unwrap_or(calculated_width);
let vh = total_text_height + padding * 2;
(vw, vh)
}
fn compute_vertical_offset(
layout: &LayoutSpec,
total_text_height: u32,
canvas_height: u32,
padding: u32,
font_size: u32,
) -> u32 {
match layout.v_align.clone().unwrap_or(VerticalAlign::Middle) {
VerticalAlign::Top => padding.max(font_size),
VerticalAlign::Bottom => canvas_height.saturating_sub(total_text_height + padding),
VerticalAlign::Middle => (canvas_height.saturating_sub(total_text_height)) / 2,
}
}
fn compute_line_origins(
lines: &[Vec<char>],
layout: &LayoutSpec,
width: u32,
start_y: u32,
line_height: u32,
char_spacing: u32,
) -> Vec<(u32, u32)> {
let align = layout.align.unwrap_or(TextAlign::Left);
let padding = layout.padding.unwrap_or(0);
lines
.iter()
.enumerate()
.map(|(i, line)| {
let y = start_y + (i as u32 * line_height);
let line_width = (char_spacing * line.len() as u32).min(width);
let x = match align {
TextAlign::Left => padding,
TextAlign::Center => (width - line_width) / 2,
TextAlign::Right => width.saturating_sub(padding + line_width),
};
(x, y)
})
.collect()
}