termcinema-engine 0.1.0

🧠 Core typewriter-style terminal animation engine (SVG renderer) for termcinema
Documentation
use crate::{LayoutSpec, StyleSpec, VerticalAlign, resolve_embedded_font};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use std::{borrow::Cow, fs, path::Path};

// ─────────────── 🧱 Constants ───────────────

/// Base unit for string-based indentation (used in pretty-printed SVG).
const SPACE: &str = "  ";

/// Extra padding to avoid clipping descenders when vertically centered.
const EXTRA_PADDING: u32 = 10;

/// Default fallback canvas width (in pixels).
pub(crate) const FALLBACK_WIDTH: u32 = 600;

/// Character width as a ratio of font size (for layout estimation).
pub(crate) const CHAR_WIDTH_RATIO: f32 = 0.6;

/// Line height as a ratio of font size (for vertical spacing).
pub(crate) const LINE_HEIGHT_RATIO: f32 = 1.4;

// ─────────────── 🛠️ XML + String Utils ───────────────

/// Returns a repeated space string used for indentation.
pub(crate) fn indent(level: usize) -> String {
    SPACE.repeat(level)
}

/// Escapes a single character for use in XML text nodes.
pub(crate) fn escape_char_xml(ch: char) -> Cow<'static, str> {
    match ch {
        '&' => Cow::Borrowed("&amp;"),
        '<' => Cow::Borrowed("&lt;"),
        '>' => Cow::Borrowed("&gt;"),
        '"' => Cow::Borrowed("&quot;"),
        '\'' => Cow::Borrowed("&apos;"),
        _ => Cow::Owned(ch.to_string()),
    }
}

/// Escapes a full string to be safely embedded in XML.
///
/// Converts special characters into named HTML/XML entities.
pub(crate) fn escape_xml(s: &str) -> String {
    let mut out = String::with_capacity(s.len());

    for ch in s.chars() {
        match escape_char_xml(ch) {
            Cow::Borrowed(escaped) => out.push_str(escaped),
            Cow::Owned(owned) => out.push_str(&owned),
        }
    }

    out
}

// ─────────────── 🖋 SVG Tag Builders ───────────────

/// Constructs the opening `<svg>` tag with layout and style settings.
///
/// Applies optional padding and vertical buffer if the layout is center-aligned.
pub(crate) fn build_svg_header(
    layout: &LayoutSpec,
    vw: u32,
    vh: u32,
    font_family: &str,
    font_size: u32,
    fill_color: &str,
    background_color: &str,
) -> String {
    let width = vw;

    let needs_extra_buffer = matches!(layout.v_align, Some(VerticalAlign::Middle));
    let height = if needs_extra_buffer {
        vh + font_size + EXTRA_PADDING
    } else {
        vh
    };

    format!(
        r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {vw} {vh}" font-family="{font}" font-size="{fs}" fill="{fg}" style="background:{bg}">"#,
        w = width,
        h = height,
        vw = vw,
        vh = vh,
        font = font_family,
        fs = font_size,
        fg = fill_color,
        bg = background_color,
    ) + "\n"
}

/// Constructs the closing `</svg>` tag.
pub(crate) fn build_svg_footer() -> &'static str {
    "</svg>\n"
}

/// Wraps raw CSS in a `<style>` tag with proper indentation.
fn build_svg_style_tag(css: &str) -> String {
    let indent = indent(1);
    let mut out = String::new();
    out.push_str(&format!("{indent}<style>\n"));
    out.push_str(css);
    out.push_str(&format!("{indent}</style>\n"));
    out
}

// ─────────────── 🔤 Font Embedding ───────────────

/// Reads a `.ttf` font file and encodes it as base64 for inline embedding.
///
/// Returns a full `@font-face` CSS declaration as a string.
fn embed_font_base64(ttf_path: &str, font_name: &str) -> Option<String> {
    let root_relative = Path::new(env!("CARGO_MANIFEST_DIR")).join(ttf_path);
    if !root_relative.exists() {
        eprintln!("⚠️ Font file not found: {}", root_relative.display());
        return None;
    }

    let bytes = fs::read(root_relative).ok()?;
    let encoded = STANDARD.encode(bytes);

    Some(format!(
        "    @font-face {{\
       \n      font-family: '{}';\
       \n      src: url(data:font/ttf;base64,{}) format('truetype');\
       \n    }}\
       \n",
        font_name, encoded,
    ))
}

/// Injects an embedded font into the SVG and adjusts the style accordingly.
///
/// Returns a new [`StyleSpec`] with updated `font-family`, and a `<style>` tag string.
/// If the font is not found or embedding fails, falls back silently.
pub(crate) fn inject_embed_font_css(style: &StyleSpec) -> (StyleSpec, String) {
    if let Some(meta) = resolve_embedded_font(&style.font_family) {
        let mut new_style = style.clone();
        new_style.font_family = meta.css_family.to_string();

        if let Some(css) = embed_font_base64(meta.path, meta.css_family) {
            let style_tag = build_svg_style_tag(&css);
            return (new_style, style_tag);
        }

        return (new_style, String::new());
    }

    let css = format!(
        "    svg, text, tspan, .cursor {{ font-family: '{}', monospace; }}\n",
        style.font_family
    );
    let style_tag = build_svg_style_tag(&css);

    (style.clone(), style_tag)
}