termcinema-engine 0.1.0

🧠 Core typewriter-style terminal animation engine (SVG renderer) for termcinema
Documentation
//! SVG renderer for typing-style animations.
//!
//! This module implements the core logic for rendering terminal text
//! as animated SVGs with character-by-character typing effects.
//!
//! It supports:
//! - Per-character fade-in animations using `<animate>`;
//! - Dynamic cursor movement synced with typing timing;
//! - Configurable layout, font, color, and animation parameters;
//! - Vertical and horizontal alignment for multi-line text;
//! - Embedded font support for consistent cross-platform rendering.
//!
//! Used by: [`render_typing_from_text`] via `glue.rs`

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,
};

// ─────────────── 🎬 SVG Main Controller ───────────────

/// Render a typing-style SVG from content and configuration.
///
/// This is the main entry point for producing a self-contained SVG
/// animation that simulates terminal typing output. Each character
/// appears sequentially with optional fade-in and a blinking cursor.
///
/// # Parameters
/// - `content`: Text content to render (multi-line, plain text).
/// - `style`: Visual style (font, color, background).
/// - `layout`: Layout settings (width, height, alignment).
/// - `cursor`: Cursor shape and blinking behavior.
/// - `control`: Timing control (frame delay, fade duration, etc).
///
/// # Returns
/// An SVG document string with embedded animations and styling.
pub(crate) fn render_typing_svg(
    content: &ContentSpec,
    style: &StyleSpec,
    layout: &LayoutSpec,
    cursor: &CursorSpec,
    control: &ControlSpec,
) -> String {
    // Parse input content
    let lines = compute_text_lines(content);

    // Extract control values
    let (frame_delay, fade_duration, start_delay) = extract_control_primitives(control);

    // Extract style fields
    let (font_size, font_family, fill_color, background_color) = extract_style_primitives(style);

    // Compute layout metrics
    let (padding, line_height, char_spacing, total_text_height) =
        compute_layout_metrics(layout, lines.len(), font_size);

    // Determine canvas size
    let (vw, vh) = compute_canvas_size(layout, &lines, total_text_height, char_spacing, padding);

    // Compute starting y-offset
    let start_y = compute_vertical_offset(layout, total_text_height, vh, padding, font_size);

    // Build header and font CSS
    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);

    // Calculate character positions
    let line_origins = compute_line_origins(&lines, layout, vw, start_y, line_height, char_spacing);

    // Build animated <text> block and record cursor path
    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,
    );

    // Build animated cursor layer
    let svg_cursor = build_svg_cursor_path(&style, cursor, &cursor_path, frame_delay, start_delay);

    // Finalize footer
    let svg_footer = build_svg_footer();

    // Assemble all parts
    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
}

// ─────────────── 🎨 Content Rendering ───────────────

/// Build the animated `<text>` block where each character appears sequentially.
///
/// Appends `<tspan>` tags with individual `<animate>` opacity transitions.
/// Also tracks cursor movement path for later animation rendering.
///
/// # Parameters
/// - `lines`: 2D character array split by line.
/// - `line_origins`: Position (x, y) of each line baseline.
/// - `char_spacing`: Horizontal distance between characters.
/// - `cursor_path`: Mutable output vector tracking cursor position per character.
/// - `frame_delay`: Delay between each character (ms).
/// - `fade_duration`: Fade-in duration per character (ms).
/// - `start_delay`: Delay before animation starts.
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),
    )
}

/// Splits the input content into lines of characters.
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
}

/// Build the animated blinking cursor SVG block.
///
/// This function generates a `<style>`, `<text>`, and two `<animate>`
/// elements to simulate a blinking terminal-style cursor that moves
/// along with each typed character.
///
/// # Parameters
/// - `style`: StyleSpec defining font, size, and fallback color.
/// - `cursor`: CursorSpec containing shape, blink speed, offset, etc.
/// - `path`: A sequence of (x, y) cursor positions for each character.
/// - `frame_delay`: Delay between each character (in ms).
/// - `start_delay`: Total delay before animation starts (in ms).
///
/// # Returns
/// An SVG string representing the animated cursor.
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();
    }

    // Initial pause — cursor remains static until `start_delay`.
    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() {
        // Add a visual “jump ahead” with offset during typing,
        // and fine-tune the final frame to align exactly.
        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());

        // Compress full movement into keyTimes from 0 to 1
        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,
    )
}

// ─────────────── 🔧 Helper Functions ───────────────

/// Splits the input content into lines of characters.
fn compute_text_lines(content: &ContentSpec) -> Vec<Vec<char>> {
    content
        .text
        .lines()
        .map(|line| line.chars().collect())
        .collect()
}

/// Computes layout measurements based on font size and line count.
///
/// Returns: `(padding, line_height, char_spacing, total_text_height)`
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)
}

/// Computes the final SVG canvas width and height based on layout or auto-fit logic.
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)
}

/// Computes the Y offset of the first line based on vertical alignment.
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,
    }
}

/// Computes the (x, y) origin of each line, respecting text alignment and padding.
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()
}