iced_math 0.4.0

Native LaTeX math widget for Iced 0.14 — pure Rust, zero JS
//! Box tree → SVG byte stream.

use std::fmt::Write;

use crate::boxer::{Box as MBox, BoxKind, Child, Point};
use crate::font;

/// Padding (in px) added on every side of the SVG viewport. Box extents are
/// the ideal ink bounds, but rasterizers antialias a fraction of a pixel
/// beyond them; without a margin the bottom row of a glyph sitting exactly on
/// the box edge (e.g. a fraction denominator, depth ≈ 0) gets clipped.
const PAD: f32 = 1.0;

pub fn emit(root: &MBox, fill: crate::Color) -> Vec<u8> {
    let w = root.width.max(0.0) + 2.0 * PAD;
    let h = (root.height + root.depth).max(0.0) + 2.0 * PAD;
    let mut out = String::new();
    let _ = write!(
        &mut out,
        r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">"#,
        w = w,
        h = h
    );
    // Default black → emit exactly as before (no group, no fill attr) so existing
    // snapshots are unchanged. Non-default → one inheriting <g fill> wrapper.
    let wrap = fill != crate::Color::BLACK;
    if wrap {
        let _ = write!(&mut out, r#"<g fill="{}">"#, fill.hex());
    }
    walk(&mut out, root, Point { x: PAD, y: PAD });
    if wrap {
        out.push_str("</g>");
    }
    out.push_str("</svg>");
    out.into_bytes()
}

fn walk(out: &mut String, b: &MBox, origin: Point) {
    match &b.kind {
        BoxKind::Glyph {
            glyph_id,
            font_size,
        } => {
            let s = font_size / font::units_per_em();
            let path_d = font::outline_path(*glyph_id);
            if path_d.is_empty() {
                return;
            }
            // Glyph's baseline sits at y = origin.y + b.height (parent baseline at y=height in y-down).
            // SVG path data is in y-up font design units. Transform matrix(s 0 0 -s ox oy) maps
            // (px_design, py_design_up) → (ox + s*px, oy + (-s)*py) = (ox + s*px, oy - s*py).
            // With ox = origin.x, oy = baseline_y, point at (0,0) in design space lands at baseline.
            let baseline_y = origin.y + b.height;
            let _ = write!(
                out,
                r#"<path transform="matrix({s} 0 0 {neg_s} {ox} {oy})" d="{d}"/>"#,
                s = s,
                neg_s = -s,
                ox = origin.x,
                oy = baseline_y,
                d = path_d
            );
        }
        BoxKind::Rule { thickness } => {
            let _ = write!(
                out,
                r#"<rect x="{x}" y="{y}" width="{w}" height="{h}"/>"#,
                x = origin.x,
                y = origin.y,
                w = b.width,
                h = thickness
            );
        }
        BoxKind::HBox(children) | BoxKind::VBox(children) => {
            for Child { offset, child } in children {
                walk(
                    out,
                    child,
                    Point {
                        x: origin.x + offset.x,
                        y: origin.y + offset.y,
                    },
                );
            }
        }
        BoxKind::Empty => {}
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ir::Style;
    use crate::{boxer, parse, Color};

    #[test]
    fn emits_well_formed_svg_for_atom() {
        let ir = parse::to_ir("x", 16.0, Style::Text).unwrap();
        let b = boxer::layout(&ir, Style::Text);
        let bytes = emit(&b, Color::BLACK);
        let s = std::str::from_utf8(&bytes).unwrap();
        assert!(s.starts_with("<svg"));
        assert!(s.ends_with("</svg>"));
        assert!(s.contains("<path"), "atom should emit at least one path");
    }

    #[test]
    fn emits_rect_for_frac_rule() {
        let ir = parse::to_ir(r"\frac{1}{2}", 16.0, Style::Text).unwrap();
        let b = boxer::layout(&ir, Style::Text);
        let bytes = emit(&b, Color::BLACK);
        let s = std::str::from_utf8(&bytes).unwrap();
        assert!(s.contains("<rect"), "frac should emit a rule rect");
    }
}