~fgaz/blobfox

1cd8665df7b56c7b79fa8657002f2dff9c63916d — Adrien Burgun 1 year, 6 months ago 235722f
:fire: Snuggle generator [WIP]
M .gitignore => .gitignore +1 -0
@@ 2,3 2,4 @@ original/
output/
Cargo.lock
target/
blobfox-*.zip

M Cargo.toml => Cargo.toml +1 -0
@@ 20,3 20,4 @@ resvg = "0.23"
usvg = "0.23"
tiny-skia = "0.6"
png = "0.17"
css-color-parser = "0.1.2"

M README.md => README.md +12 -0
@@ 41,9 41,21 @@ If you'd like to help, there are a few things that need attention outside of imp
    - german shepherd
    - collie
    - sheep
    - bird
    - etc.
- clean up the SVG for the existing emotes (the `clean` binary in `feat/template` is meant to do the heavy-lifting)

### TODO

- set_stroke!
- blobfox_ohmy
- blobfox_trumpet
- blobfox_highfive
- googly eyes?
- tea/coffee
- sad
- uwu

## License

All the code, images and assets of this repository are made available under the Apache 2.0 license.

A snuggle.toml => snuggle.toml +4 -0
@@ 0,0 1,4 @@
name = "snuggle"
dx = -60
dy = -40
transform = "scale(1.02 1.02) translate(-1.5 -1)"

A species/blobfox/assets/snuggle_right.svg => species/blobfox/assets/snuggle_right.svg +49 -0
@@ 0,0 1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" height="128" width="128" sodipodi:docname="blobfox_snuggle_right.svg" id="svg5" inkscape:version="1.1" viewBox="0 0 128 128">
  <title id="title30762">blobfox</title>
  <sodipodi:namedview id="namedview7" borderopacity="1" bordercolor="#ffffff" inkscape:window-height="779" inkscape:zoom="9.7841572" inkscape:pageopacity="0" inkscape:showpageshadow="2" inkscape:current-layer="g9491" units="px" inkscape:document-units="px" inkscape:window-maximized="1" inkscape:window-x="0" pagecolor="#505050" inkscape:pagecheckerboard="1" showgrid="false" inkscape:window-width="1536" inkscape:window-y="0" inkscape:cy="46.401544" inkscape:pageshadow="0" inkscape:deskcolor="#505050" inkscape:cx="59.68833">
    <inkscape:grid type="xygrid" id="grid10"/>
  </sodipodi:namedview>
  <defs id="defs2"/>
  <g style="display:inline" inkscape:label="ref" inkscape:groupmode="layer" id="ref">
    <image id="blobfox" inkscape:label="blobfox" x="0" preserveAspectRatio="none" y="0" width="127.99999" xlink:href="../original/blobfox.png" style="display:none;fill:#313131;fill-opacity:1;stroke:none;image-rendering:optimizeQuality" height="127.99999"/>
    <image id="blobfoxsnuggle" xlink:href="../original/blobfoxsnuggle.png" y="-106.29223" style="image-rendering:optimizeSpeed" preserveAspectRatio="none" inkscape:label="blobfoxsnuggle" height="221.0103" width="221.0103" x="-93.369904"/>
  </g>
  <g id="Base" inkscape:label="Base" inkscape:groupmode="layer" style="display:inline">
    <path d="M 12.87231,40.818966 C -2.8719971,22.749014 -4.3550533,3.1367661 -2.9535978,0.24374984 -1.8493312,-2.0357805 -0.73468479,-3.6569086 1.9073133,-2.7558898 5.0260913,-1.6922712 17.794722,2.5508374 34.540893,8.7120265 c 7.146252,2.6292225 3.161601,9.4167855 1.87439,12.1261315 -2.856028,5.37387 -17.877727,10.71439 -23.542973,19.980808 z" id="left-ear" sodipodi:nodetypes="cssscc" style="display:inline;fill:#313131;fill-opacity:1;stroke:none;stroke-width:0.999999px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" inkscape:label="left-ear"/>
    <path d="m 23.769283,6.3584073 c 2.991294,4.2455057 4.569848,7.4475027 8.895201,10.9888377 -12.070861,-0.541455 -15.260708,1.69375 -19.417541,5.559159 7.973832,0.09289 11.531043,0.522786 15.204226,1.155863 12.215874,2.105419 38.518977,5.035068 47.371493,-2.486915 4.32997,-3.679173 3.410201,-7.265503 -2.316952,-10.378575 C 67.610927,7.9925869 55.645095,4.8584742 51.222049,3.8438904 52.004054,6.0173862 53.35612,8.7380188 54.213344,11.268754 45.9815,7.6883395 37.893549,5.4027147 23.769283,6.3584073 Z" style="display:inline;fill:#ff8702;fill-opacity:1;stroke:none;stroke-width:0.999999px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:0.4501" sodipodi:nodetypes="cccsssccc" id="hair" inkscape:label="hair"/>
    <path style="display:inline;fill:#ff8702;fill-opacity:1;stroke:none;stroke-width:2.64567;stroke-linecap:square;stroke-miterlimit:3" inkscape:label="body" d="m 6.0599734,91.649344 c 0,-8.73002 2.525321,-5.814368 2.6883167,-12.346173 C 8.9112858,72.771365 3.4640017,66.503444 3.0623888,53.796109 2.608823,39.444946 14.618107,15.642023 46.64457,15.642023 c 33.541601,0 57.93714,1.121349 70.19416,23.200939 12.45071,22.428499 12.91096,52.067554 6.38429,59.723645 -6.52667,7.656093 -22.73142,15.897513 -56.529275,15.897513 -33.797859,0 -60.6337716,-12.74755 -60.6337716,-22.814776 z" id="body" sodipodi:nodetypes="cssssssc"/>
    <path style="display:inline;fill:#313131;fill-opacity:1;stroke:none;stroke-width:0.999999px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:0.4501" sodipodi:nodetypes="scscccss" d="m 109.65875,-11.393576 c -9.0534,1.6065407 -29.38453,9.7496393 -44.524796,30.678621 0.210609,4.044387 7.117027,6.692013 15.231023,5.036186 8.412314,-1.716705 21.594053,-22.0009765 26.970613,-27.4823623 1.00114,9.5497778 -12.65173,31.3913833 -4.57278,40.2752173 3.27823,3.604628 6.48404,-4.729898 7.41016,-7.69531 3.7465,-12.049663 6.22482,-21.0511645 6.3764,-27.8592275 0.0598,-2.686778 1.92218,-14.5169695 -6.89062,-12.9531245 z" inkscape:label="right-ear" id="right-ear"/>
    <path id="right-ear-fluff" style="display:inline;fill:#ebdccc;fill-opacity:1;stroke:none;stroke-width:0.999999px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" inkscape:label="right-ear-fluff" d="M 109.7442,-7.9162135 C 101.79383,-4.5766893 95.432201,2.858004 93.256387,11.957451 92.338149,15.7976 86.384544,21.344383 83.242552,23.116446 c 3.859188,-0.486555 8.080586,-0.662358 9.623136,-0.582576 -0.593528,2.540618 -1.150986,5.084216 -1.270641,7.589149 2.095641,-2.400784 6.129327,-4.663902 8.596323,-5.42458 -0.870477,4.814098 -0.218062,7.843034 1.18668,10.437489 0.3855,-11.747093 7.77479,-14.345491 10.05823,-21.014332 1.71162,-4.9987961 4.75466,-15.8385462 1.25678,-21.4073931 -0.64101,-1.0205338 -2.00455,-1.0270711 -2.94886,-0.6304164 z" sodipodi:nodetypes="sscccccsss"/>
  </g>
  <g id="Features" inkscape:label="Features" transform="rotate(-10 45 75)" style="display:inline" inkscape:groupmode="layer">
    <path id="nose-outline" d="m 33.65228,80.15316 c -1.84571,-3.806604 -1.872556,-7.54518 0.527665,-10.056125 2.400221,-2.510944 6.697437,-10.228997 6.681819,-14.101173" inkscape:label="nose-outline" sodipodi:nodetypes="csc" style="fill:none;fill-opacity:1;stroke:#313131;stroke-width:4.40315;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"/>
    <path inkscape:label="nose" d="m 40.349664,72.473149 c 3.611758,-1.970317 14.548618,-2.669295 17.071165,2.5758 0.40334,0.838655 -6.115736,7.143991 -10.106864,7.135136 -3.859049,-0.0087 -7.870186,-9.216748 -6.964301,-9.710936 z" style="fill:#313131;fill-opacity:1;stroke:none;stroke-width:0.999999px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" id="nose" sodipodi:nodetypes="ssss"/>
    <path id="mouth" d="m 30.329986,85.466434 c 2.073506,6.374978 4.020579,10.401679 5.959243,12.940501 3.750744,-3.131043 8.538975,-7.475595 10.436736,-9.373813 2.962201,4.818455 7.017346,10.375135 8.671615,12.645698 4.859078,-3.873207 9.518861,-7.995561 11.43575,-10.162866" style="fill:none;stroke:#313131;stroke-width:4.40315;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" inkscape:label="mouth" sodipodi:nodetypes="ccccc"/>
    <path d="m 24.641601,45.918665 c -4.075919,0.730189 -6.501377,3.925039 -6.273966,5.603347 0.227411,1.678308 1.347515,2.224702 3.8872,1.666965 2.397981,-0.52641 9.098771,-2.404689 17.303469,3.65651 2.148157,1.586937 4.650972,-0.810343 2.741314,-2.795355 -7.65445,-7.956509 -12.747952,-9.01107 -17.658017,-8.131467 z" sodipodi:nodetypes="ssssss" inkscape:label="left-eye" style="display:inline;fill:#313131;fill-opacity:1;stroke-width:4.45223;stroke-linecap:round" id="left-eye"/>
    <path id="right-eye" inkscape:label="right-eye" sodipodi:nodetypes="ssssss" style="display:inline;fill:#313131;fill-opacity:1;stroke-width:4.41528;stroke-linecap:round" d="m 83.448042,49.242516 c 3.928913,1.194349 5.945041,4.623512 5.525393,6.249833 -0.419635,1.626313 -1.586453,2.033818 -4.022559,1.188423 -2.300205,-0.798011 -8.680288,-3.429088 -17.467185,1.58346 -2.300578,1.312383 -4.485891,-1.340335 -2.373777,-3.072561 8.46599,-6.943249 13.605145,-7.38791 18.338128,-5.949155 z"/>
  </g>
  <metadata id="metadata30760">
    <rdf:RDF>
      <cc:Work rdf:about="">
        <dc:rights>
          <cc:Agent>
            <dc:title>Blobfox team (https://git.shadamethyst.xyz/adri326/blobfox), licensed under the Apache 2.0 License</dc:title>
          </cc:Agent>
        </dc:rights>
        <dc:title>blobfox</dc:title>
        <dc:creator>
          <cc:Agent>
            <dc:title>Feuerfuchs</dc:title>
          </cc:Agent>
        </dc:creator>
        <dc:source>https://git.shadamethyst.xyz/adri326/blobfox</dc:source>
        <dc:contributor>
          <cc:Agent>
            <dc:title>Shad Amethyst</dc:title>
          </cc:Agent>
        </dc:contributor>
      </cc:Work>
    </rdf:RDF>
  </metadata>
</svg>

M species/blobfox/species.toml => species/blobfox/species.toml +5 -0
@@ 6,6 6,7 @@ body_color = "#ff8702"
ear_color = "#313131"
ear_fluff_color = "#ebdccc"
hand_color = "#ff8702"
hand_stroke_color = "#313131"
tail_color = "#ff8702"

[variants]


@@ 51,3 52,7 @@ heart_enby = ["body-basic", "eyes-basic", "left-hand", "right-hand", "holding", 
heart_ace = ["body-basic", "eyes-basic", "left-hand", "right-hand", "holding", "big-object"]
heart_demisexual = ["body-basic", "eyes-basic", "left-hand", "right-hand", "holding", "big-object"]
heart_pan = ["body-basic", "eyes-basic", "left-hand", "right-hand", "holding", "big-object"]

# Snuggle
snuggle_right = ["body-snuggle", "eyes-snuggle", "mouth-w"]
snuggle_right_shadow = ["body-snuggle", "eyes-snuggle", "mouth-w"]

A species/blobfox/templates/body-snuggle.mustache => species/blobfox/templates/body-snuggle.mustache +15 -0
@@ 0,0 1,15 @@
{{! Left ear }}
{{#set-fill}} {{vars.ear_color}} | {{#snuggle_right}}#left-ear{{/snuggle_right}} {{/set-fill}}

{{! Body }}
<defs>
    <clipPath id="body-clip">
        {{#snuggle_right}}#body{{/snuggle_right}}
    </clipPath>
</defs>
{{#set-fill}} {{vars.body_color}} | {{#snuggle_right}}#body{{/snuggle_right}} {{/set-fill}}
{{#set-fill}} {{vars.body_color}} | {{#snuggle_right}}#hair{{/snuggle_right}} {{/set-fill}}

{{! Right ear }}
{{#set-fill}} {{vars.ear_color}} | {{#snuggle_right}}#right-ear{{/snuggle_right}} {{/set-fill}}
{{#set-fill}} {{vars.ear_fluff_color}} | {{#snuggle_right}}#right-ear-fluff{{/snuggle_right}} {{/set-fill}}

M species/blobfox/templates/body.mustache => species/blobfox/templates/body.mustache +3 -0
@@ 8,4 8,7 @@
    {{#tags.body-comfy}}
        {{>body-comfy}}
    {{/tags.body-comfy}}
    {{#tags.body-snuggle}}
        {{>body-snuggle}}
    {{/tags.body-snuggle}}
</g>

M species/blobfox/templates/eyes.mustache => species/blobfox/templates/eyes.mustache +4 -0
@@ 23,4 23,8 @@
        {{#blush}}#left-eye{{/blush}}
        {{#blush}}#right-eye{{/blush}}
    {{/tags.eyes-closed}}
    {{#tags.eyes-snuggle}}
        {{#snuggle_right}}#left-eye{{/snuggle_right}}
        {{#snuggle_right}}#right-eye{{/snuggle_right}}
    {{/tags.eyes-snuggle}}
</g>

M species/blobfox/templates/hands.mustache => species/blobfox/templates/hands.mustache +14 -2
@@ 1,7 1,19 @@
<g id="hands">
    {{#tags.hands-reach}}
        {{#set-fill}} {{vars.hand_color}} | {{#reach_aww}}#left-hand{{/reach_aww}} {{/set-fill}}
        {{#set-fill}} {{vars.hand_color}} | {{#reach_aww}}#right-hand{{/reach_aww}} {{/set-fill}}
        {{#set-stroke}}
            {{vars.hand_stroke_color}} |
            {{#set-fill}}
                {{vars.hand_color}}
                | {{#reach_aww}}#left-hand{{/reach_aww}}
            {{/set-fill}}
        {{/set-stroke}}
        {{#set-stroke}}
            {{vars.hand_stroke_color}} |
            {{#set-fill}}
                {{vars.hand_color}}
                | {{#reach_aww}}#right-hand{{/reach_aww}}
            {{/set-fill}}
        {{/set-stroke}}
    {{/tags.hands-reach}}
    {{#tags.hand-3c}}
        {{#tags.holding}}

M species/blobfox/templates/nose.mustache => species/blobfox/templates/nose.mustache +3 -0
@@ 14,6 14,9 @@
    {{#tags.eyes-closed}}
        {{#blush}}#nose-outline{{/blush}}
    {{/tags.eyes-closed}}
    {{#tags.eyes-snuggle}}
        {{#snuggle_right}}#nose-outline{{/snuggle_right}}
    {{/tags.eyes-snuggle}}

    {{#base}}#nose{{/base}}
</g>

M species/blobfox/variants/heart_ace.mustache => species/blobfox/variants/heart_ace.mustache +5 -1
@@ 9,7 9,11 @@
        </clipPath>
    </defs>

    <g clip-path="url(#clip-heart)">
    <g blobfox-only-size="true">
        {{#heart}}#heart{{/heart}}
    </g>

    <g clip-path="url(#clip-heart)" blobfox-ignore-size="true">
        <g transform="rotate(9) scale(1.05 1.05) translate(10 45)">
            {{#flag_ace}}{{/flag_ace}}
        </g>

M species/blobfox/variants/heart_demisexual.mustache => species/blobfox/variants/heart_demisexual.mustache +5 -1
@@ 9,7 9,11 @@
        </clipPath>
    </defs>

    <g clip-path="url(#clip-heart)">
    <g blobfox-only-size="true">
        {{#heart}}#heart{{/heart}}
    </g>

    <g clip-path="url(#clip-heart)" blobfox-ignore-size="true">
        <g transform="rotate(9) scale(1.05 1.05) translate(10 45)">
            {{#flag_demisexual}}{{/flag_demisexual}}
        </g>

M species/blobfox/variants/heart_enby.mustache => species/blobfox/variants/heart_enby.mustache +5 -1
@@ 9,7 9,11 @@
        </clipPath>
    </defs>

    <g clip-path="url(#clip-heart)">
    <g blobfox-only-size="true">
        {{#heart}}#heart{{/heart}}
    </g>

    <g clip-path="url(#clip-heart)" blobfox-ignore-size="true">
        <g transform="rotate(9) scale(1.05 1.05) translate(10 45)">
            {{#flag_enby}}{{/flag_enby}}
        </g>

M species/blobfox/variants/heart_pan.mustache => species/blobfox/variants/heart_pan.mustache +5 -1
@@ 9,7 9,11 @@
        </clipPath>
    </defs>

    <g clip-path="url(#clip-heart)">
    <g blobfox-only-size="true">
        {{#heart}}#heart{{/heart}}
    </g>

    <g clip-path="url(#clip-heart)" blobfox-ignore-size="true">
        <g transform="rotate(9) scale(1.05 1.05) translate(10 45)">
            {{#flag_pan}}{{/flag_pan}}
        </g>

M species/blobfox/variants/heart_progress.mustache => species/blobfox/variants/heart_progress.mustache +5 -1
@@ 9,7 9,11 @@
        </clipPath>
    </defs>

    <g clip-path="url(#clip-heart)">
    <g blobfox-only-size="true">
        {{#heart}}#heart{{/heart}}
    </g>

    <g clip-path="url(#clip-heart)" blobfox-ignore-size="true">
        <g transform="rotate(9) scale(0.95 0.95) translate(20 50)">
            {{#flag_progress}}{{/flag_progress}}
        </g>

A species/blobfox/variants/snuggle_right.mustache => species/blobfox/variants/snuggle_right.mustache +9 -0
@@ 0,0 1,9 @@
{{>header}}
    {{>body}}

    <g transform="rotate(-10 45 75)">
        {{>eyes}}
        {{>nose}}
        {{>mouth}}
    </g>
{{>footer}}

A src/bin/snuggle.rs => src/bin/snuggle.rs +148 -0
@@ 0,0 1,148 @@
//! Very crude tool for generating snuggle emotes
use clap::Parser;
use std::fmt::Write;
use std::path::PathBuf;
use serde::{Serialize, Deserialize};
use xmltree::{Element, XMLNode};

use blobfox_template::{
    parse,
    template,
    export,
};

#[derive(Serialize, Deserialize, Debug)]
struct Desc {
    dx: f64,
    dy: f64,

    scale: Option<f64>,

    #[serde(default)]
    transform: String,
}

fn main() {
    let args = Args::parse();

    let left = std::fs::read_to_string(&args.input_left).unwrap_or_else(|err| {
        panic!("Couldn't open {}: {}", args.input_right.display(), err);
    });
    let right = std::fs::read_to_string(&args.input_right).unwrap_or_else(|err| {
        panic!("Couldn't open {}: {}", args.input_right.display(), err);
    });

    let desc = std::fs::read_to_string(&args.desc).unwrap_or_else(|err| {
        panic!("Couldn't open {}: {}", args.desc.display(), err);
    });
    let desc: Desc = toml::from_str(&desc).unwrap();

    let snuggle = generate_snuggle(left, right, desc);
    let snuggle = export::xml_to_str(&snuggle).unwrap();

    let output_dir = args.output_dir.clone().unwrap_or(PathBuf::from("output/"));

    export::export(
        snuggle,
        &output_dir,
        args.name.clone(),
        &args.clone().into()
    ).unwrap();
}

fn generate_snuggle(left: String, right: String, desc: Desc) -> Element {
    let left_usvg = export::get_usvg(&left).unwrap();
    let left_bbox = left_usvg.svg_node().view_box.rect;

    //
    let mut mask = Element::new("mask");
    mask.attributes.insert("id".to_string(), "snuggle-mask".to_string());

    let mut rect = Element::new("rect");
    rect.attributes.insert("fill".to_string(), "white".to_string());
    // TODO: use scale?
    rect.attributes.insert("x".to_string(), (desc.dx + left_bbox.x()).to_string());
    rect.attributes.insert("y".to_string(), (desc.dy + left_bbox.y()).to_string());
    rect.attributes.insert("width".to_string(), left_bbox.width().to_string());
    rect.attributes.insert("height".to_string(), left_bbox.height().to_string());

    mask.children.push(XMLNode::Element(rect));

    let mut right_mask = Element::new("g");
    right_mask.attributes.insert("transform".to_string(), desc.transform);

    let mut right_xml = Element::parse(right.as_bytes()).unwrap();
    template::set_fill("#000000", &mut right_xml);
    template::set_stroke("#000000", &mut right_xml);

    for child in right_xml.children {
        if let XMLNode::Element(child) = child {
            right_mask.children.push(XMLNode::Element(child));
        }
    }

    mask.children.push(XMLNode::Element(right_mask));

    let mut right_xml = Element::parse(right.as_bytes()).unwrap();
    let left_xml = Element::parse(left.as_bytes()).unwrap();

    let mut left_group = Element::new("g");
    left_group.attributes.insert("transform".to_string(), format!(
        "translate({} {})",
        desc.dx,
        desc.dy
    ));
    left_group.children = left_xml.children;

    let mut left_group2 = Element::new("g");
    left_group2.attributes.insert("mask".to_string(), "url(#snuggle-mask)".to_string());
    left_group2.children.push(XMLNode::Element(left_group));

    let mut res = Element::new("svg");
    res.attributes.insert("xmlns".to_string(), "http://www.w3.org/2000/svg".to_string());
    res.attributes.insert("version".to_string(), "1.1".to_string());
    res.attributes.insert("width".to_string(), "128".to_string());
    res.attributes.insert("height".to_string(), "128".to_string());
    res.children.push(XMLNode::Element(mask));
    res.children.append(&mut right_xml.children);
    res.children.push(XMLNode::Element(left_group2));

    res
}

#[derive(Parser, Clone)]
#[clap(author, version, about, long_about = None)]
struct Args {
    #[clap(value_parser)]
    input_left: PathBuf,

    #[clap(value_parser)]
    input_right: PathBuf,

    #[clap(short, long, value_parser)]
    desc: PathBuf,

    /// Disable automatically resizing the SVG's viewBox, defaults to false
    #[clap(short, long, value_parser, default_value = "false")]
    no_resize: bool,

    #[clap(long, value_parser)]
    name: String,

    /// Dimension to export the images as; can be specified multiple times
    #[clap(long, value_parser)]
    dim: Vec<u32>,

    /// Output directory
    #[clap(short, long, value_parser)]
    output_dir: Option<PathBuf>,
}

impl From<Args> for export::ExportArgs {
    fn from(args: Args) -> export::ExportArgs {
        export::ExportArgs {
            no_resize: args.no_resize,
            dim: args.dim,
        }
    }
}

M src/export.rs => src/export.rs +57 -13
@@ 48,6 48,28 @@ impl From<png::EncodingError> for ExportError {
    }
}

pub struct ExportArgs {
    pub no_resize: bool,
    pub dim: Vec<u32>,
}

pub fn get_usvg(svg_str: &str) -> Result<usvg::Tree, usvg::Error> {
    let usvg_options = Options::default();
    Tree::from_str(svg_str, &usvg_options.to_ref())
}

pub fn get_xml(svg_str: &str) -> Result<Element, xmltree::ParseError> {
    Element::parse(svg_str.as_bytes())
}

pub fn xml_to_str(svg_xml: &Element) -> Result<String, ExportError> {
    let mut s: Vec<u8> = Vec::new();

    svg_xml.write(&mut s)?;

    Ok(String::from_utf8(s)?)
}

fn get_new_bbox(svg: &Tree) -> Option<(f64, f64, f64, f64)> {
    let bbox = svg.root().calculate_bbox()?;



@@ 70,25 92,45 @@ fn get_new_bbox(svg: &Tree) -> Option<(f64, f64, f64, f64)> {
    }
}

fn get_usvg(svg_str: &str) -> Result<usvg::Tree, usvg::Error> {
    let usvg_options = Options::default();
    Tree::from_str(svg_str, &usvg_options.to_ref())
}
/// Removes all elements marked with `blobfox-ignore-size="true"`

fn get_xml(svg_str: &str) -> Result<Element, xmltree::ParseError> {
    Element::parse(svg_str.as_bytes())
}
macro_rules! strip {
    ( $name:tt, $attribute:expr ) => {
        fn $name(svg_str: &str) -> Result<String, ExportError> {
            let mut xml = get_xml(svg_str)?;

fn xml_to_str(svg_xml: &Element) -> Result<String, ExportError> {
    let mut s: Vec<u8> = Vec::new();
            fn rec(element: &mut Element) {
                // TODO: replace with Vec::drain_filter once https://github.com/rust-lang/rust/issues/43244 gets merged
                for child in std::mem::take(&mut element.children) {
                    match child {
                        XMLNode::Element(mut child) => {
                            if let Some("true") = child.attributes.get($attribute).map(|s| s.as_str()) {
                                continue
                            }

    svg_xml.write(&mut s)?;
                            rec(&mut child);

    Ok(String::from_utf8(s)?)
                            element.children.push(XMLNode::Element(child));
                        }
                        child => element.children.push(child),
                    }
                }
            }

            rec(&mut xml);

            xml_to_str(&xml)
        }
    }
}

strip!(strip_ignore_size, "blobfox-ignore-size");
strip!(strip_only_size, "blobfox-only-size");

pub fn resize(svg_str: String) -> Result<String, ExportError> {
    if let Some(new_bbox) = get_new_bbox(&get_usvg(&svg_str)?) {
    let stripped = strip_ignore_size(&svg_str)?;

    if let Some(new_bbox) = get_new_bbox(&get_usvg(&stripped)?) {
        let mut svg_xml = get_xml(&svg_str)?;
        svg_xml.attributes.insert(
            "viewBox".to_string(),


@@ 144,12 186,14 @@ pub fn export(
    mut svg_str: String,
    output_dir: &PathBuf,
    output_name: String,
    args: &super::Args,
    args: &ExportArgs,
) -> Result<(), ExportError> {
    if !args.no_resize {
        svg_str = resize(svg_str)?;
    }

    svg_str = strip_only_size(&svg_str)?;

    svg_str = combine_defs(svg_str)?;

    mkdirp::mkdirp(output_dir.join("vector")).unwrap();

A src/lib.rs => src/lib.rs +3 -0
@@ 0,0 1,3 @@
pub mod parse;
pub mod template;
pub mod export;

M src/main.rs => src/main.rs +18 -10
@@ 1,14 1,11 @@
use clap::Parser;
use std::path::PathBuf;

pub mod parse;
use parse::*;

pub mod template;
use template::*;

pub mod export;
use export::*;
use blobfox_template::{
    parse::*,
    template::*,
    export::*,
};

fn main() {
    let args = Args::parse();


@@ 30,6 27,8 @@ fn main() {
}

fn generate_variant(context: &RenderingContext, name: &str, output_dir: &PathBuf, args: &Args) {
    let args: ExportArgs = args.clone().into();

    if let Some(path) = context.species().variant_paths.get(name) {
        match context.compile(path).and_then(|template| {
            template.render_data_to_string(&context.get_data(name))


@@ 39,7 38,7 @@ fn generate_variant(context: &RenderingContext, name: &str, output_dir: &PathBuf
                    svg,
                    output_dir,
                    format!("{}_{}", context.species().name, name),
                    args
                    &args
                ) {
                    Ok(_) => {}
                    Err(err) => {


@@ 56,7 55,7 @@ fn generate_variant(context: &RenderingContext, name: &str, output_dir: &PathBuf
    }
}

#[derive(Parser, Debug)]
#[derive(Parser, Debug, Clone)]
#[clap(author, version, about, long_about = None)]
pub struct Args {
    /// A folder containing the declaration from which the emotes should be generated


@@ 79,3 78,12 @@ pub struct Args {
    #[clap(short, long, value_parser)]
    output_dir: Option<PathBuf>,
}

impl From<Args> for ExportArgs {
    fn from(args: Args) -> ExportArgs {
        ExportArgs {
            no_resize: args.no_resize,
            dim: args.dim,
        }
    }
}

M src/template.rs => src/template.rs +76 -49
@@ 1,9 1,10 @@
use super::*;
use crate::parse::SpeciesDecl;
use mustache::{Context, Data, MapBuilder, PartialLoader, Template};
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
use xmltree::{Element, XMLNode};
use css_color_parser::Color as CssColor;

#[derive(Debug, Clone)]
pub struct RenderingContext {


@@ 90,39 91,45 @@ impl RenderingContext {
            });
        }

        let this = self.clone();
        let variant_name_owned = variant_name.to_string();
        builder = builder.insert_fn("set-fill", move |input| {
            // Parse `color|xml`
            if let [color, xml] = input.splitn(2, '|').collect::<Vec<_>>()[..] {
                // Render `color` and `xml`
                if let (Ok(color), Ok(xml)) = (
                    this.render_to_string(&color, &variant_name_owned),
                    this.render_to_string(&xml, &variant_name_owned),
                ) {
                    // Convert `xml` to XML
                    match Element::parse(xml.as_bytes()) {
                        Ok(mut xml) => {
                            set_fill(&color.trim(), &mut xml);

                            // Render XML to string
                            if let Some(res) = xml_to_string(xml) {
                                res
                            } else {
                                String::from("<!-- Error in stringifying xml -->")
        for (cb, name) in [
            (set_fill as fn(&str, &mut Element), "set-fill"),
            (set_stroke, "set-stroke")
        ] {
            let this = self.clone();
            let variant_name_owned = variant_name.to_string();

            builder = builder.insert_fn(name, move |input| {
                // Parse `color|xml`
                if let [color, xml] = input.splitn(2, '|').collect::<Vec<_>>()[..] {
                    // Render `color` and `xml`
                    if let (Ok(color), Ok(xml)) = (
                        this.render_to_string(&color, &variant_name_owned),
                        this.render_to_string(&xml, &variant_name_owned),
                    ) {
                        // Convert `xml` to XML
                        match Element::parse(xml.as_bytes()) {
                            Ok(mut xml) => {
                                cb(&color.trim(), &mut xml);

                                // Render XML to string
                                if let Some(res) = xml_to_string(xml) {
                                    res
                                } else {
                                    String::from("<!-- Error in stringifying xml -->")
                                }
                            }
                            Err(err) => {
                                format!("<!-- Error in parsing xml: {} -->", err)
                            }
                        }
                        Err(err) => {
                            format!("<!-- Error in parsing xml: {} -->", err)
                        }
                    } else {
                        String::from("<!-- Error in parsing color or element -->")
                    }
                } else {
                    String::from("<!-- Error in parsing color or element -->")
                    String::from("<!-- Invalid syntax: expected `color|xml` -->")
                }
            } else {
                String::from("<!-- Invalid syntax: expected `color|xml` -->")
            }
        });
            });
        }

        builder = builder.insert("vars", &self.species.vars).unwrap();



@@ 236,34 243,54 @@ impl PartialLoader for RenderingContext {
    }
}

fn set_fill(color: &str, xml: &mut Element) {
    // Substitute the fill color; TODO: handle transparency for SVG 1.1
    if let Some(style) = xml.attributes.get_mut("style") {
        let mut new_style = Vec::new();
macro_rules! set_color {
    ( $fn_name:tt, $color_name:expr, $opacity_name:expr ) => {
        pub fn $fn_name(color: &str, xml: &mut Element) {
            let (color, opacity) = if let Ok(parsed) = color.parse::<CssColor>() {
                (format!("#{:02x}{:02x}{:02x}", parsed.r, parsed.g, parsed.b), parsed.a)
            } else {
                (color.to_string(), 1.0)
            };

        for rule in style.split(';') {
            if let [name, value] = rule.splitn(2, ':').collect::<Vec<_>>()[..] {
                if name.trim() != "fill" && name.trim() != "fill-opacity" {
                    new_style.push(format!("{}:{}", name, value));
                }
            }
        }
            fn rec(color: &str, opacity: f32, xml: &mut Element) {
                if let Some(style) = xml.attributes.get_mut("style") {
                    let mut new_style = Vec::new();

                    for rule in style.split(';') {
                        if let [name, value] = rule.splitn(2, ':').collect::<Vec<_>>()[..] {
                            if name.trim() != $color_name && name.trim() != $opacity_name {
                                new_style.push(format!("{}:{}", name, value));
                            }
                        }
                    }

        new_style.push(format!("fill: {};", color));
                    new_style.push(format!(concat!($color_name, ": {};"), color));
                    new_style.push(format!(concat!($opacity_name, ": {};"), opacity));

        *style = new_style.join(";");
    }
    if let Some(_fill) = xml.attributes.get("fill") {
        xml.attributes.insert("fill".to_string(), color.to_string());
    }
                    *style = new_style.join(";");
                }
                if let Some(_fill) = xml.attributes.get($color_name) {
                    xml.attributes.insert($color_name.to_string(), color.to_string());
                }
                if let Some(_fill) = xml.attributes.get($opacity_name) {
                    xml.attributes.insert($opacity_name.to_string(), opacity.to_string());
                }

                for child in xml.children.iter_mut() {
                    if let XMLNode::Element(ref mut child) = child {
                        rec(color, opacity, child);
                    }
                }
            }

    for child in xml.children.iter_mut() {
        if let XMLNode::Element(ref mut child) = child {
            set_fill(color, child);
            rec(&color, opacity, xml)
        }
    }
}

set_color!(set_fill, "fill", "fill-opacity");
set_color!(set_stroke, "stroke", "stroke-opacity");

pub fn query_selector(svg: Element, pattern: &str) -> Option<Element> {
    if pattern == "" {
        // NOTE: it looks like having a nested svg makes resvg unhappy

A vector/blobfox_snuggle_right.svg => vector/blobfox_snuggle_right.svg +125 -0
@@ 0,0 1,125 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
   inkscape:version="1.2.1 (9c6d41e410, 2022-07-14)"
   viewBox="0 0 128 128"
   width="128"
   sodipodi:docname="blobfox_snuggle_right.svg"
   id="svg5"
   height="128"
   version="1.1"
   xml:space="preserve"
   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
   xmlns:xlink="http://www.w3.org/1999/xlink"
   xmlns="http://www.w3.org/2000/svg"
   xmlns:svg="http://www.w3.org/2000/svg"
   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
   xmlns:cc="http://creativecommons.org/ns#"
   xmlns:dc="http://purl.org/dc/elements/1.1/"><title
     id="title30762">blobfox</title><sodipodi:namedview
     inkscape:window-maximized="1"
     bordercolor="#ffffff"
     inkscape:showpageshadow="2"
     inkscape:window-height="779"
     inkscape:pageopacity="0"
     borderopacity="1"
     inkscape:cy="46.401544"
     inkscape:window-x="0"
     id="namedview7"
     pagecolor="#505050"
     inkscape:pageshadow="0"
     showgrid="false"
     inkscape:zoom="9.7841572"
     inkscape:window-width="1536"
     inkscape:window-y="0"
     inkscape:current-layer="g9491"
     inkscape:cx="59.68833"
     units="px"
     inkscape:pagecheckerboard="1"
     inkscape:document-units="px"
     inkscape:deskcolor="#505050"><inkscape:grid
       id="grid10"
       type="xygrid" /></sodipodi:namedview><defs
     id="defs2" /><g
     style="display:inline"
     id="layer2"
     inkscape:groupmode="layer"
     inkscape:label="ref"><image
       inkscape:label="blobfox"
       y="0"
       height="127.99999"
       width="127.99999"
       style="display:none;fill:#313131;fill-opacity:1;stroke:none;image-rendering:optimizeQuality"
       x="0"
       preserveAspectRatio="none"
       xlink:href="../original/blobfox.png"
       id="image80" /><image
       width="221.0103"
       height="221.0103"
       preserveAspectRatio="none"
       style="image-rendering:optimizeSpeed"
       xlink:href="../original/blobfoxsnuggle.png"
       id="image545"
       x="-93.369904"
       y="-106.29223"
       inkscape:label="blobfoxsnuggle" /></g><g
     inkscape:label="Base"
     id="layer1"
     inkscape:groupmode="layer"
     style="display:inline"><path
       sodipodi:nodetypes="cssscc"
       style="display:inline;fill:#313131;fill-opacity:1;stroke:none;stroke-width:0.999999px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
       d="M 12.87231,40.818966 C -2.8719971,22.749014 -4.3550533,3.1367661 -2.9535978,0.24374984 -1.8493312,-2.0357805 -0.73468479,-3.6569086 1.9073133,-2.7558898 5.0260913,-1.6922712 17.794722,2.5508374 34.540893,8.7120265 c 7.146252,2.6292225 3.161601,9.4167855 1.87439,12.1261315 -2.856028,5.37387 -17.877727,10.71439 -23.542973,19.980808 z"
       id="path20678"
       inkscape:label="left-ear" /><path
       id="path117-7"
       inkscape:label="hair"
       sodipodi:nodetypes="cccsssccc"
       style="display:inline;fill:#ff8702;fill-opacity:1;stroke:none;stroke-width:0.999999px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:0.4501"
       d="m 23.769283,6.3584073 c 2.991294,4.2455057 4.569848,7.4475027 8.895201,10.9888377 -12.070861,-0.541455 -15.260708,1.69375 -19.417541,5.559159 7.973832,0.09289 11.531043,0.522786 15.204226,1.155863 12.215874,2.105419 38.518977,5.035068 47.371493,-2.486915 4.32997,-3.679173 3.410201,-7.265503 -2.316952,-10.378575 C 67.610927,7.9925869 55.645095,4.8584742 51.222049,3.8438904 52.004054,6.0173862 53.35612,8.7380188 54.213344,11.268754 45.9815,7.6883395 37.893549,5.4027147 23.769283,6.3584073 Z" /><path
       style="display:inline;fill:#ff8702;fill-opacity:1;stroke:none;stroke-width:2.64567;stroke-linecap:square;stroke-miterlimit:3"
       d="m 6.0599734,91.649344 c 0,-8.73002 2.525321,-5.814368 2.6883167,-12.346173 C 8.9112858,72.771365 3.4640017,66.503444 3.0623888,53.796109 2.608823,39.444946 14.618107,15.642023 46.64457,15.642023 c 33.541601,0 57.93714,1.121349 70.19416,23.200939 12.45071,22.428499 12.91096,52.067554 6.38429,59.723645 -6.52667,7.656093 -22.73142,15.897513 -56.529275,15.897513 -33.797859,0 -60.6337716,-12.74755 -60.6337716,-22.814776 z"
       id="path8285"
       sodipodi:nodetypes="cssssssc"
       inkscape:label="body" /><path
       d="m 109.65875,-11.393576 c -9.0534,1.6065407 -29.38453,9.7496393 -44.524796,30.678621 0.210609,4.044387 7.117027,6.692013 15.231023,5.036186 8.412314,-1.716705 21.594053,-22.0009765 26.970613,-27.4823623 1.00114,9.5497778 -12.65173,31.3913833 -4.57278,40.2752173 3.27823,3.604628 6.48404,-4.729898 7.41016,-7.69531 3.7465,-12.049663 6.22482,-21.0511645 6.3764,-27.8592275 0.0598,-2.686778 1.92218,-14.5169695 -6.89062,-12.9531245 z"
       id="path14428"
       style="display:inline;fill:#313131;fill-opacity:1;stroke:none;stroke-width:0.999999px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:0.4501"
       inkscape:label="right-ear"
       sodipodi:nodetypes="scscccss" /><path
       id="path18942"
       style="display:inline;fill:#ebdccc;fill-opacity:1;stroke:none;stroke-width:0.999999px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
       inkscape:label="right-ear-fluff"
       d="M 109.7442,-7.9162135 C 101.79383,-4.5766893 95.432201,2.858004 93.256387,11.957451 92.338149,15.7976 86.384544,21.344383 83.242552,23.116446 c 3.859188,-0.486555 8.080586,-0.662358 9.623136,-0.582576 -0.593528,2.540618 -1.150986,5.084216 -1.270641,7.589149 2.095641,-2.400784 6.129327,-4.663902 8.596323,-5.42458 -0.870477,4.814098 -0.218062,7.843034 1.18668,10.437489 0.3855,-11.747093 7.77479,-14.345491 10.05823,-21.014332 1.71162,-4.9987961 4.75466,-15.8385462 1.25678,-21.4073931 -0.64101,-1.0205338 -2.00455,-1.0270711 -2.94886,-0.6304164 z"
       sodipodi:nodetypes="sscccccsss" /></g><g
     id="layer3"
     inkscape:groupmode="layer"
     inkscape:label="Features"
     style="display:inline" transform="rotate(-10 45 75)"><path
         d="m 33.65228,80.15316 c -1.84571,-3.806604 -1.872556,-7.54518 0.527665,-10.056125 2.400221,-2.510944 6.697437,-10.228997 6.681819,-14.101173"
         sodipodi:nodetypes="csc"
         inkscape:label="nose-outline"
         id="path27175"
         style="fill:none;fill-opacity:1;stroke:#313131;stroke-width:4.40315;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /><path
         id="path28517"
         style="fill:#313131;fill-opacity:1;stroke:none;stroke-width:0.999999px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
         sodipodi:nodetypes="ssss"
         d="m 40.349664,72.473149 c 3.611758,-1.970317 14.548618,-2.669295 17.071165,2.5758 0.40334,0.838655 -6.115736,7.143991 -10.106864,7.135136 -3.859049,-0.0087 -7.870186,-9.216748 -6.964301,-9.710936 z"
         inkscape:label="nose" /><path
         inkscape:label="mouth"
         id="path29369"
         style="fill:none;stroke:#313131;stroke-width:4.40315;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
         d="m 30.329986,85.466434 c 2.073506,6.374978 4.020579,10.401679 5.959243,12.940501 3.750744,-3.131043 8.538975,-7.475595 10.436736,-9.373813 2.962201,4.818455 7.017346,10.375135 8.671615,12.645698 4.859078,-3.873207 9.518861,-7.995561 11.43575,-10.162866"
         sodipodi:nodetypes="ccccc" /><path
         style="display:inline;fill:#313131;fill-opacity:1;stroke-width:4.45223;stroke-linecap:round"
         sodipodi:nodetypes="ssssss"
         d="m 24.641601,45.918665 c -4.075919,0.730189 -6.501377,3.925039 -6.273966,5.603347 0.227411,1.678308 1.347515,2.224702 3.8872,1.666965 2.397981,-0.52641 9.098771,-2.404689 17.303469,3.65651 2.148157,1.586937 4.650972,-0.810343 2.741314,-2.795355 -7.65445,-7.956509 -12.747952,-9.01107 -17.658017,-8.131467 z"
         inkscape:label="left-eye"
         id="left-eye" /><path
         sodipodi:nodetypes="ssssss"
         style="display:inline;fill:#313131;fill-opacity:1;stroke-width:4.41528;stroke-linecap:round"
         d="m 83.448042,49.242516 c 3.928913,1.194349 5.945041,4.623512 5.525393,6.249833 -0.419635,1.626313 -1.586453,2.033818 -4.022559,1.188423 -2.300205,-0.798011 -8.680288,-3.429088 -17.467185,1.58346 -2.300578,1.312383 -4.485891,-1.340335 -2.373777,-3.072561 8.46599,-6.943249 13.605145,-7.38791 18.338128,-5.949155 z"
         id="path456-3"
         inkscape:label="right-eye" /></g><metadata
     id="metadata30760"><rdf:RDF><cc:Work
         rdf:about=""><dc:rights><cc:Agent><dc:title>Blobfox team (https://git.shadamethyst.xyz/adri326/blobfox), licensed under the Apache 2.0 License</dc:title></cc:Agent></dc:rights><dc:title>blobfox</dc:title><dc:creator><cc:Agent><dc:title>Feuerfuchs</dc:title></cc:Agent></dc:creator><dc:source>https://git.shadamethyst.xyz/adri326/blobfox</dc:source><dc:contributor><cc:Agent><dc:title>Shad Amethyst</dc:title></cc:Agent></dc:contributor></cc:Work></rdf:RDF></metadata></svg>