webfont-generator 0.11.0

Generate webfonts (SVG, TTF, EOT, WOFF, WOFF2) from SVG icons
Documentation
use std::fmt::Write as _;
use std::io::Error;
#[cfg(feature = "fs")]
use std::path::Path;
use std::sync::Arc;

#[cfg(feature = "fs")]
use crate::input::ResolvedFileGenerationOptions;
use crate::input::{LoadedSvgSource, ResolvedGenerationOptions};
use crate::rendering::css::SharedTemplateData;
#[cfg(feature = "fs")]
use crate::rendering::css::TemplateDependencies;

#[cfg(feature = "fs")]
use crate::rendering::css::template_dependencies;
#[cfg(feature = "fs")]
use crate::rendering::css::{build_css_context_with_fonts_url, render_css_with_context};
#[cfg(feature = "fs")]
use crate::rendering::paths::{path_to_slashes, relative_path};
use crate::rendering::shared::to_io_err;
use handlebars::{
    Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext, RenderErrorReason,
};
use serde_json::{Map, Value};

#[cfg(feature = "fs")]
pub(crate) fn build_html_context(
    options: &ResolvedFileGenerationOptions,
    shared: &SharedTemplateData,
    source_files: &[LoadedSvgSource],
    styles: Option<String>,
) -> Result<Map<String, Value>, Error> {
    let styles = match styles {
        Some(styles) => styles,
        None => {
            let html_fonts_url = html_css_fonts_url(options);
            let css_ctx = build_css_context_with_fonts_url(options, shared, Some(&html_fonts_url));
            render_css_with_context(shared, &css_ctx)?
        }
    };

    Ok(make_ctx(options, shared, source_files, styles))
}

/// Reuse callback-mutated CSS while resolving default font URLs for the HTML destination.
#[cfg(feature = "fs")]
pub(crate) fn build_html_context_with_css(
    options: &ResolvedFileGenerationOptions,
    shared: &SharedTemplateData,
    source_files: &[LoadedSvgSource],
    finalized_css: &Map<String, Value>,
) -> Result<Map<String, Value>, Error> {
    let mut css_ctx = finalized_css.clone();
    let original_css = crate::rendering::css::build_css_context(options, shared);
    // Preserve an explicit callback src override; otherwise rebase URLs for the HTML file.
    if css_ctx.get("src") == original_css.get("src") {
        let html_css =
            build_css_context_with_fonts_url(options, shared, Some(&html_css_fonts_url(options)));
        css_ctx.insert("src".to_owned(), html_css["src"].clone());
    }
    let mut html_ctx = build_html_context(
        options,
        shared,
        source_files,
        Some(render_css_with_context(shared, &css_ctx)?),
    )?;
    for field in ["baseSelector", "classPrefix"] {
        if let Some(value) = finalized_css.get(field) {
            html_ctx.insert(field.to_owned(), value.clone());
        } else {
            html_ctx.remove(field);
        }
    }
    Ok(html_ctx)
}

/// Render HTML using a pre-built Handlebars Context (no serialization).
/// Falls back to the hot-path renderer when no custom template is configured.
pub(crate) fn render_html_with_hbs_context(
    cached_registry: Option<&Handlebars<'static>>,
    hbs_ctx: &handlebars::Context,
    map_ctx: &Map<String, Value>,
) -> Result<String, Error> {
    match cached_registry {
        Some(registry) => registry
            .render_with_context("html", hbs_ctx)
            .map_err(to_io_err),
        None => Ok(render_default_html(map_ctx)),
    }
}

#[cfg(all(test, feature = "fs"))]
fn render_html_with_context(
    options: &ResolvedFileGenerationOptions,
    cached_registry: Option<&Handlebars<'static>>,
    ctx: &Map<String, Value>,
) -> Result<String, Error> {
    match cached_registry {
        Some(registry) => registry.render("html", ctx).map_err(to_io_err),
        None if options.html_template.is_some() => {
            let registry = build_html_registry(options)?
                .ok_or_else(|| to_io_err("HTML template path set but failed to compile"))?;
            registry.render("html", ctx).map_err(to_io_err)
        }
        None => Ok(render_default_html(ctx)),
    }
}

/// Pre-compile the HTML Handlebars template with the removePeriods helper.
/// Returns Ok(None) when no custom template is configured.
/// Returns Err when the template exists but fails to compile.
#[cfg(all(test, feature = "fs"))]
pub(crate) fn build_html_registry(
    options: &ResolvedFileGenerationOptions,
) -> Result<Option<Handlebars<'static>>, Error> {
    Ok(super::files::load_html_template(options)?.registry)
}

pub(crate) struct HtmlTemplate {
    #[cfg(feature = "fs")]
    pub(crate) source: Option<Arc<str>>,
    pub(crate) registry: Option<Handlebars<'static>>,
    #[cfg(feature = "fs")]
    pub(crate) dependencies: TemplateDependencies,
}

pub(crate) fn compile_html_template(source: Option<Arc<str>>) -> Result<HtmlTemplate, Error> {
    let Some(text) = source.as_deref() else {
        return Ok(HtmlTemplate {
            #[cfg(feature = "fs")]
            source,
            registry: None,
            #[cfg(feature = "fs")]
            dependencies: TemplateDependencies::html_default(),
        });
    };
    #[cfg(feature = "fs")]
    let dependencies = template_dependencies(text);
    let mut registry = Handlebars::new();
    registry.register_helper("removePeriods", Box::new(RemovePeriodsHelper));
    registry
        .register_template_string("html", text)
        .map_err(|error| to_io_err(format!("Failed to compile HTML template: {error}")))?;
    Ok(HtmlTemplate {
        #[cfg(feature = "fs")]
        source,
        registry: Some(registry),
        #[cfg(feature = "fs")]
        dependencies,
    })
}

#[inline]
#[cfg(feature = "fs")]
pub(crate) fn render_default_html_with_styles(ctx: &Map<String, Value>, styles: &str) -> String {
    render_default_html_inner(ctx, styles)
}

fn render_default_html(ctx: &Map<String, Value>) -> String {
    render_default_html_inner(ctx, super::ctx_str(ctx, "styles", ""))
}

fn render_default_html_inner(ctx: &Map<String, Value>, styles: &str) -> String {
    let font_name = super::ctx_str(ctx, "fontName", "");
    let base_selector = super::ctx_str(ctx, "baseSelector", ".icon");
    let class_prefix = super::ctx_str(ctx, "classPrefix", "icon-");
    let names = ctx.get("names").and_then(|v| v.as_array());
    let base_class = base_selector.replace('.', "");

    let name_count = names.map_or(0, |n| n.len());
    let mut result = String::with_capacity(512 + name_count * 120);

    _ = write!(
        result,
        "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n\t<meta charset=\"UTF-8\">\n\t<title>{font_name}</title>\n\t<style>\n"
    );
    result.push_str("\t\tbody {\n\t\t\tfont-family: sans-serif;\n\t\t\tmargin: 0;\n\t\t\tpadding: 10px 20px;\n\t\t}\n\n");
    result.push_str("\t\t.preview {\n\t\t\tline-height: 2em;\n\t\t}\n\n");
    result.push_str("\t\t.preview__icon {\n\t\t\tdisplay: inline-block;\n\t\t\twidth: 32px;\n\t\t\ttext-align: center;\n\t\t}\n\n");
    _ = write!(
        result,
        "\t\t{base_selector} {{\n\t\t\tdisplay: inline-block;\n\t\t\tfont-size: 16px;\n\t\t}}\n\n"
    );
    _ = writeln!(result, "\t\t{styles}");
    result.push_str("\t</style>\n</head>\n<body>\n");
    _ = writeln!(result, "\t<h1>{font_name}</h1>");

    if let Some(names) = names {
        for name_value in names {
            let name = name_value.as_str().unwrap_or("");
            _ = write!(
                result,
                "\t<div class=\"preview\">\n\t\t<span class=\"preview__icon\">\n\t\t\t<span class=\"{base_class} {class_prefix}{name}\"></span>\n\t\t</span>\n\t\t<span>{name}</span>\n\t</div>\n"
            );
        }
    }

    result.push_str("</body>\n</html>\n");
    result
}

#[cfg(feature = "fs")]
fn html_css_fonts_url(options: &ResolvedFileGenerationOptions) -> String {
    let html_dir = Path::new(&options.html_dest)
        .parent()
        .unwrap_or_else(|| Path::new("."));
    path_to_slashes(relative_path(html_dir, Path::new(&options.dest)))
}

#[cfg(feature = "fs")]
fn make_ctx(
    options: &ResolvedFileGenerationOptions,
    shared: &SharedTemplateData,
    source_files: &[LoadedSvgSource],
    styles: String,
) -> Map<String, Value> {
    let css_ctx = options.variants.as_ref().map(|_| {
        build_css_context_with_fonts_url(options, shared, Some(&html_css_fonts_url(options)))
    });
    make_content_html_context(options, shared, source_files, styles, css_ctx.as_ref())
}

pub(crate) fn make_content_html_context(
    options: &ResolvedGenerationOptions,
    shared: &SharedTemplateData,
    source_files: &[LoadedSvgSource],
    styles: String,
    variant_css_context: Option<&Map<String, Value>>,
) -> Map<String, Value> {
    let mut ctx = shared.template_options.clone();
    ctx.extend(Map::from_iter([
        (
            "codepoints".to_owned(),
            Value::Object(shared.codepoints_num.clone()),
        ),
        (
            "fontName".to_owned(),
            Value::String(options.font_name.clone()),
        ),
        (
            "names".to_owned(),
            Value::Array(
                source_files
                    .iter()
                    .map(|source_file| Value::String(source_file.glyph_name.clone()))
                    .collect(),
            ),
        ),
        ("styles".to_owned(), Value::String(styles)),
    ]));

    if let Some(css_ctx) = variant_css_context {
        for key in [
            "variants",
            "variantClassPrefix",
            "defaultWeight",
            "fontStyle",
        ] {
            ctx.insert(key.to_owned(), css_ctx[key].clone());
        }
        let mut seen = std::collections::HashSet::new();
        ctx.insert(
            "names".to_owned(),
            Value::Array(
                source_files
                    .iter()
                    .filter(|source| seen.insert(source.glyph_name.as_str()))
                    .map(|source| Value::String(source.glyph_name.clone()))
                    .collect(),
            ),
        );
    }
    ctx
}

struct RemovePeriodsHelper;

impl HelperDef for RemovePeriodsHelper {
    fn call<'reg: 'rc, 'rc>(
        &self,
        helper: &Helper<'rc>,
        _: &'reg Handlebars<'reg>,
        _: &'rc Context,
        _: &mut RenderContext<'reg, 'rc>,
        out: &mut dyn Output,
    ) -> HelperResult {
        let selector = helper
            .param(0)
            .and_then(|value| value.value().as_str())
            .ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex("RemovePeriodsHelper", 0))?;

        out.write(&selector.replace('.', ""))?;

        Ok(())
    }
}

#[cfg(all(test, feature = "fs"))]
mod tests;