feat: support settings import/export, closes #22

master
Sameer Puri 4 years ago
parent acdc33ab71
commit 7019264735

15
Cargo.lock generated

@ -228,15 +228,16 @@ checksum = "af51b1b4a7fdff033703db39de8802c673eb91855f2e0d47dcf3bf2c0ef01f99"
[[package]]
name = "g-code"
version = "0.3.1"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2403e3a8d80208fa702f2bc4588d8ea28ed456c7e552891eea6e7e9c68582167"
checksum = "8220033ab3971720c5ec54c4b7d36b35b87e3e58d53785152ffbdfd72d8e4c80"
dependencies = [
"codespan",
"codespan-reporting",
"paste",
"peg",
"rust_decimal",
"serde",
]
[[package]]
@ -632,7 +633,7 @@ dependencies = [
[[package]]
name = "svg2gcode"
version = "0.0.5"
version = "0.0.6"
dependencies = [
"cairo-rs",
"euclid",
@ -641,19 +642,22 @@ dependencies = [
"lyon_geom",
"paste",
"roxmltree",
"serde",
"serde_json",
"svgtypes",
"uom",
]
[[package]]
name = "svg2gcode-cli"
version = "0.0.1"
version = "0.0.2"
dependencies = [
"codespan-reporting",
"env_logger",
"g-code",
"log",
"roxmltree",
"serde_json",
"structopt",
"svg2gcode",
"svgtypes",
@ -661,7 +665,7 @@ dependencies = [
[[package]]
name = "svg2gcode-web"
version = "0.0.1"
version = "0.0.2"
dependencies = [
"base64",
"codespan",
@ -674,6 +678,7 @@ dependencies = [
"paste",
"roxmltree",
"serde",
"serde_json",
"svg2gcode",
"svgtypes",
"wasm-bindgen",

@ -1,6 +1,6 @@
[package]
name = "svg2gcode-cli"
version = "0.0.1"
version = "0.0.2"
authors = ["Sameer Puri <crates@purisa.me>"]
edition = "2018"
description = "Command line interface for svg2gcode"
@ -8,7 +8,7 @@ repository = "https://github.com/sameer/svg2gcode"
license = "MIT"
[dependencies]
svg2gcode = { path = "../lib" }
svg2gcode = { path = "../lib", features = ["serde"]}
env_logger = { version = "0", default-features = false, features = ["atty", "termcolor", "humantime"] }
log = "0"
g-code = "0"
@ -16,6 +16,7 @@ codespan-reporting = "0.11"
structopt = "0.3"
roxmltree = "0"
svgtypes = "0"
serde_json = "1"
[[bin]]
name = "svg2gcode"

@ -6,29 +6,29 @@ use log::info;
use std::{
env,
fs::File,
io::{self, Read},
io::{self, Read, Write},
path::PathBuf,
};
use structopt::StructOpt;
use svgtypes::LengthListParser;
use svg2gcode::{
set_origin, svg2program, ConversionOptions, Machine, SupportedFunctionality, Turtle,
set_origin, svg2program, ConversionOptions, Machine, Settings, SupportedFunctionality, Turtle,
};
#[derive(Debug, StructOpt)]
#[structopt(name = "svg2gcode", author, about)]
struct Opt {
/// Curve interpolation tolerance (mm)
#[structopt(long, default_value = "0.002")]
tolerance: f64,
#[structopt(long)]
tolerance: Option<f64>,
/// Machine feed rate (mm/min)
#[structopt(long, default_value = "300")]
feedrate: f64,
#[structopt(long)]
feedrate: Option<f64>,
/// Dots per Inch (DPI)
/// Used for scaling visual units (pixels, points, picas, etc.)
#[structopt(long, default_value = "96")]
dpi: f64,
#[structopt(long)]
dpi: Option<f64>,
#[structopt(alias = "tool_on_sequence", long = "on")]
/// G-Code for turning on the tool
tool_on_sequence: Option<String>,
@ -46,9 +46,17 @@ struct Opt {
/// Output file path (overwrites old files), else writes to stdout
#[structopt(short, long)]
out: Option<PathBuf>,
/// Provide settings from a JSON file. Overrides command-line arguments.
#[structopt(long)]
settings: Option<PathBuf>,
/// Export current settings to a JSON file instead of converting.
///
/// Use `-` to export to standard out.
#[structopt(long)]
export: Option<PathBuf>,
/// Coordinates for the bottom left corner of the machine
#[structopt(long, default_value = "0,0")]
origin: String,
#[structopt(long)]
origin: Option<String>,
/// Override the width and height of the SVG (i.e. 210mm,297mm)
///
/// Useful when the SVG does not specify these (see https://github.com/sameer/svg2gcode/pull/16)
@ -60,7 +68,7 @@ struct Opt {
///
/// Please check if your machine supports G2/G3 commands before enabling this.
#[structopt(long)]
circular_interpolation: bool,
circular_interpolation: Option<bool>,
}
fn main() -> io::Result<()> {
@ -71,6 +79,95 @@ fn main() -> io::Result<()> {
let opt = Opt::from_args();
let settings = {
let mut settings = if let Some(path) = opt.settings {
serde_json::from_reader(File::open(path)?)?
} else {
Settings::default()
};
{
let conversion = &mut settings.conversion;
conversion.dpi = opt.dpi.unwrap_or(conversion.dpi);
conversion.feedrate = opt.feedrate.unwrap_or(conversion.feedrate);
conversion.tolerance = opt.dpi.unwrap_or(conversion.tolerance);
}
{
let machine = &mut settings.machine;
machine.supported_functionality = SupportedFunctionality {
circular_interpolation: opt
.circular_interpolation
.unwrap_or(machine.supported_functionality.circular_interpolation),
};
if let Some(sequence) = opt.tool_on_sequence {
machine.tool_on_sequence.insert(sequence);
}
if let Some(sequence) = opt.tool_off_sequence {
machine.tool_off_sequence.insert(sequence);
}
if let Some(sequence) = opt.begin_sequence {
machine.begin_sequence.insert(sequence);
}
if let Some(sequence) = opt.end_sequence {
machine.end_sequence.insert(sequence);
}
}
{
let postprocess = &mut settings.postprocess;
if let Some(origin) = opt.origin {
for (i, dimension_origin) in origin
.split(',')
.map(|point| {
if point.is_empty() {
Default::default()
} else {
point.parse().expect("could not parse coordinate")
}
})
.take(2)
.enumerate()
{
postprocess.origin[i] = dimension_origin;
}
}
}
settings
};
if let Some(export_path) = opt.export {
let mut config_json_bytes = serde_json::to_vec_pretty(&settings)?;
if export_path.to_string_lossy() == "-" {
return io::stdout().write_all(&mut config_json_bytes);
} else {
return File::create(export_path)?.write_all(&mut config_json_bytes);
}
}
let options = {
let mut dimensions = [None, None];
if let Some(dimensions_str) = opt.dimensions {
dimensions_str
.split(',')
.map(|dimension_str| {
if dimension_str.is_empty() {
None
} else {
LengthListParser::from(dimension_str)
.next()
.transpose()
.expect("could not parse dimension")
}
})
.take(2)
.enumerate()
.for_each(|(i, dimension_origin)| {
dimensions[i] = dimension_origin;
});
}
ConversionOptions { dimensions }
};
let input = match opt.file {
Some(filename) => {
let mut f = File::open(filename)?;
@ -87,58 +184,38 @@ fn main() -> io::Result<()> {
}
};
let mut dimensions = [None, None];
if let Some(dimensions_str) = opt.dimensions {
dimensions_str
.split(',')
.map(|dimension_str| {
if dimension_str.is_empty() {
None
} else {
LengthListParser::from(dimension_str)
.next()
.transpose()
.expect("could not parse dimension")
}
})
.take(2)
.enumerate()
.for_each(|(i, dimension_origin)| {
dimensions[i] = dimension_origin;
});
}
let options = ConversionOptions {
tolerance: opt.tolerance,
feedrate: opt.feedrate,
dpi: opt.dpi,
dimensions,
};
let snippets = [
opt.tool_on_sequence
settings
.machine
.tool_on_sequence
.as_deref()
.map(snippet_parser)
.transpose(),
settings
.machine
.tool_off_sequence
.as_deref()
.map(snippet_parser)
.transpose(),
opt.tool_off_sequence
settings
.machine
.begin_sequence
.as_deref()
.map(snippet_parser)
.transpose(),
opt.begin_sequence
settings
.machine
.end_sequence
.as_deref()
.map(snippet_parser)
.transpose(),
opt.end_sequence.as_deref().map(snippet_parser).transpose(),
];
let machine = if let [Ok(tool_on_action), Ok(tool_off_action), Ok(program_begin_sequence), Ok(program_end_sequence)] =
snippets
{
Machine::new(
SupportedFunctionality {
circular_interpolation: opt.circular_interpolation,
},
settings.machine.supported_functionality,
tool_on_action,
tool_off_action,
program_begin_sequence,
@ -153,10 +230,10 @@ fn main() -> io::Result<()> {
let config = codespan_reporting::term::Config::default();
for (i, (filename, gcode)) in [
("tool_on_sequence", &opt.tool_on_sequence),
("tool_off_sequence", &opt.tool_off_sequence),
("begin_sequence", &opt.begin_sequence),
("end_sequence", &opt.end_sequence),
("tool_on_sequence", &settings.machine.tool_on_sequence),
("tool_off_sequence", &settings.machine.tool_off_sequence),
("begin_sequence", &settings.machine.begin_sequence),
("end_sequence", &settings.machine.end_sequence),
]
.iter()
.enumerate()
@ -177,26 +254,9 @@ fn main() -> io::Result<()> {
let document = roxmltree::Document::parse(&input).unwrap();
let mut turtle = Turtle::new(machine);
let mut program = svg2program(&document, options, &mut turtle);
let mut origin = [0., 0.];
for (i, dimension_origin) in opt
.origin
.split(',')
.map(|point| {
if point.is_empty() {
Default::default()
} else {
point.parse().expect("could not parse coordinate")
}
})
.take(2)
.enumerate()
{
origin[i] = dimension_origin;
}
set_origin(&mut program, origin);
let mut program = svg2program(&document, &settings.conversion, options, &mut turtle);
set_origin(&mut program, settings.postprocess.origin);
if let Some(out_path) = opt.out {
format_gcode_io(&program, FormatOptions::default(), File::create(out_path)?)

@ -1,6 +1,6 @@
[package]
name = "svg2gcode"
version = "0.0.5"
version = "0.0.6"
authors = ["Sameer Puri <crates@purisa.me>"]
edition = "2018"
description = "Convert paths in SVG files to GCode for a pen plotter, laser engraver, or other machine."
@ -8,7 +8,7 @@ repository = "https://github.com/sameer/svg2gcode"
license = "MIT"
[dependencies]
g-code = "0.3.1"
g-code = { version = "0.3.3", features = ["serde"] }
lyon_geom = ">= 0.17.5"
euclid = "0.22"
log = "0.4"
@ -17,5 +17,12 @@ roxmltree = "0.14"
svgtypes = "0.6"
paste = "1.0"
[dependencies.serde]
default-features = false
optional = true
version = "1"
features = ["derive"]
[dev-dependencies]
cairo-rs = { version = "0.14", default-features = false, features = ["svg", "v1_16"] }
serde_json = "1"

@ -8,43 +8,136 @@ use lyon_geom::{
point, vector, ArcFlags,
};
use roxmltree::{Document, Node};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use svgtypes::{
Length, LengthListParser, LengthUnit, PathParser, PathSegment, TransformListParser,
TransformListToken, ViewBox,
Length, LengthListParser, PathParser, PathSegment, TransformListParser, TransformListToken,
ViewBox,
};
use crate::turtle::*;
const SVG_TAG_NAME: &str = "svg";
/// High-level output options
#[derive(Debug)]
pub struct ConversionOptions {
/// High-level output configuration
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ConversionConfig {
/// Curve interpolation tolerance in millimeters
pub tolerance: f64,
/// Feedrate in millimeters / minute
pub feedrate: f64,
/// Dots per inch for pixels, picas, points, etc.
pub dpi: f64,
/// Width and height override
///
/// Useful when an SVG does not have a set width and height or you want to override it.
pub dimensions: [Option<Length>; 2],
}
impl Default for ConversionOptions {
impl Default for ConversionConfig {
fn default() -> Self {
Self {
tolerance: 0.002,
feedrate: 300.0,
dpi: 96.0,
dimensions: [None; 2],
}
}
}
/// Options are specific to this conversion.
///
/// This is separate from [ConversionConfig] to support bulk processing in the web interface.
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ConversionOptions {
/// Width and height override
///
/// Useful when an SVG does not have a set width and height or you want to override it.
#[cfg_attr(feature = "serde", serde(with = "length_serde"))]
pub dimensions: [Option<Length>; 2],
}
#[cfg(feature = "serde")]
mod length_serde {
use serde::{
de::{SeqAccess, Visitor},
ser::SerializeSeq,
Deserialize, Deserializer, Serialize, Serializer,
};
use svgtypes::{Length, LengthUnit};
pub fn serialize<S>(length: &[Option<Length>; 2], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut seq = serializer.serialize_seq(Some(2))?;
for i in 0..2 {
let length_def = length[i].clone().map(|length| LengthDef {
number: length.number,
unit: length.unit,
});
seq.serialize_element(&length_def)?;
}
seq.end()
}
struct OptionalLengthArrayVisitor;
impl<'de> Visitor<'de> for OptionalLengthArrayVisitor {
type Value = [Option<Length>; 2];
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "SVG dimension array")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let x = seq.next_element::<Option<LengthDef>>()?.flatten();
let y = seq.next_element::<Option<LengthDef>>()?.flatten();
Ok([
x.map(|length_def| Length {
number: length_def.number,
unit: length_def.unit,
}),
y.map(|length_def| Length {
number: length_def.number,
unit: length_def.unit,
}),
])
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<[Option<Length>; 2], D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_seq(OptionalLengthArrayVisitor)
}
#[derive(Serialize, Deserialize)]
struct LengthDef {
number: f64,
#[serde(with = "LengthUnitDef")]
unit: LengthUnit,
}
#[derive(Serialize, Deserialize)]
#[serde(remote = "LengthUnit")]
enum LengthUnitDef {
None,
Em,
Ex,
Px,
In,
Cm,
Mm,
Pt,
Pc,
Percent,
}
}
pub fn svg2program<'input>(
doc: &Document,
config: &ConversionConfig,
options: ConversionOptions,
turtle: &'input mut Turtle<'input>,
) -> Vec<Token<'input>> {
@ -110,13 +203,13 @@ pub fn svg2program<'input>(
.and_then(|mut parser| parser.next())
.transpose()
.expect("could not parse width")
.map(|width| length_to_mm(width, options.dpi, scale_w)),
.map(|width| length_to_mm(width, config.dpi, scale_w)),
node.attribute("height")
.map(LengthListParser::from)
.and_then(|mut parser| parser.next())
.transpose()
.expect("could not parse height")
.map(|height| length_to_mm(height, options.dpi, scale_h)),
.map(|height| length_to_mm(height, config.dpi, scale_h)),
);
let aspect_ratio = match (view_box, dimensions) {
(_, (Some(ref width), Some(ref height))) => *width / *height,
@ -136,8 +229,8 @@ pub fn svg2program<'input>(
}
let dimensions_override = [
options.dimensions[0].map(|dim_x| length_to_mm(dim_x, options.dpi, scale_w)),
options.dimensions[1].map(|dim_y| length_to_mm(dim_y, options.dpi, scale_h)),
options.dimensions[0].map(|dim_x| length_to_mm(dim_x, config.dpi, scale_w)),
options.dimensions[1].map(|dim_y| length_to_mm(dim_y, config.dpi, scale_h)),
];
match (dimensions_override, dimensions) {
@ -203,7 +296,7 @@ pub fn svg2program<'input>(
is_inline: false,
inner: Cow::Owned(comment),
});
program.extend(apply_path(turtle, &options, d));
program.extend(apply_path(turtle, config, d));
} else {
warn!("There is a path node containing no actual path: {:?}", node);
}
@ -238,7 +331,7 @@ fn node_name(node: &Node) -> String {
fn apply_path<'input>(
turtle: &'_ mut Turtle<'input>,
options: &ConversionOptions,
config: &ConversionConfig,
path: &str,
) -> Vec<Token<'input>> {
use PathSegment::*;
@ -250,11 +343,11 @@ fn apply_path<'input>(
MoveTo { abs, x, y } => turtle.move_to(abs, x, y),
ClosePath { abs: _ } => {
// Ignore abs, should have identical effect: [9.3.4. The "closepath" command]("https://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand)
turtle.close(options.feedrate)
turtle.close(config.feedrate)
}
LineTo { abs, x, y } => turtle.line(abs, x, y, options.feedrate),
HorizontalLineTo { abs, x } => turtle.line(abs, x, None, options.feedrate),
VerticalLineTo { abs, y } => turtle.line(abs, None, y, options.feedrate),
LineTo { abs, x, y } => turtle.line(abs, x, y, config.feedrate),
HorizontalLineTo { abs, x } => turtle.line(abs, x, None, config.feedrate),
VerticalLineTo { abs, y } => turtle.line(abs, None, y, config.feedrate),
CurveTo {
abs,
x1,
@ -268,28 +361,28 @@ fn apply_path<'input>(
point(x1, y1),
point(x2, y2),
point(x, y),
options.tolerance,
options.feedrate,
config.tolerance,
config.feedrate,
),
SmoothCurveTo { abs, x2, y2, x, y } => turtle.smooth_cubic_bezier(
abs,
point(x2, y2),
point(x, y),
options.tolerance,
options.feedrate,
config.tolerance,
config.feedrate,
),
Quadratic { abs, x1, y1, x, y } => turtle.quadratic_bezier(
abs,
point(x1, y1),
point(x, y),
options.tolerance,
options.feedrate,
config.tolerance,
config.feedrate,
),
SmoothQuadratic { abs, x, y } => turtle.smooth_quadratic_bezier(
abs,
point(x, y),
options.tolerance,
options.feedrate,
config.tolerance,
config.feedrate,
),
EllipticalArc {
abs,
@ -306,8 +399,8 @@ fn apply_path<'input>(
Angle::degrees(x_axis_rotation),
ArcFlags { large_arc, sweep },
point(x, y),
options.feedrate,
options.tolerance,
config.feedrate,
config.tolerance,
),
}
})
@ -373,3 +466,67 @@ fn length_to_mm(l: svgtypes::Length, dpi: f64, scale: Option<f64>) -> f64 {
length.get::<millimeter>()
}
#[cfg(test)]
mod test {
use super::*;
#[cfg(feature = "serde")]
use svgtypes::LengthUnit;
#[test]
#[cfg(feature = "serde")]
fn serde_conversion_options_is_correct() {
let default_struct = ConversionOptions::default();
let default_json = "{\"dimensions\":[null,null]}";
assert_eq!(
serde_json::to_string(&default_struct).unwrap(),
default_json
);
assert_eq!(
serde_json::from_str::<ConversionOptions>(default_json).unwrap(),
default_struct
);
}
#[test]
#[cfg(feature = "serde")]
fn serde_conversion_options_with_single_dimension_is_correct() {
let mut r#struct = ConversionOptions::default();
r#struct.dimensions[0] = Some(Length {
number: 4.,
unit: LengthUnit::Mm,
});
let json = "{\"dimensions\":[{\"number\":4.0,\"unit\":\"Mm\"},null]}";
assert_eq!(serde_json::to_string(&r#struct).unwrap(), json);
assert_eq!(
serde_json::from_str::<ConversionOptions>(json).unwrap(),
r#struct
);
}
#[test]
#[cfg(feature = "serde")]
fn serde_conversion_options_with_both_dimensions_is_correct() {
let mut r#struct = ConversionOptions::default();
r#struct.dimensions = [
Some(Length {
number: 4.,
unit: LengthUnit::Mm,
}),
Some(Length {
number: 10.5,
unit: LengthUnit::In,
}),
];
let json =
"{\"dimensions\":[{\"number\":4.0,\"unit\":\"Mm\"},{\"number\":10.5,\"unit\":\"In\"}]}";
assert_eq!(serde_json::to_string(&r#struct).unwrap(), json);
assert_eq!(
serde_json::from_str::<ConversionOptions>(json).unwrap(),
r#struct
);
}
}

@ -1,3 +1,6 @@
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
/// Approximate [Bézier curves](https://en.wikipedia.org/wiki/B%C3%A9zier_curve) with [Circular arcs](https://en.wikipedia.org/wiki/Circular_arc)
mod arc;
/// Converts an SVG to G-Code in an internal representation
@ -11,12 +14,19 @@ mod postprocess;
/// This concept is referred to as [Turtle graphics](https://en.wikipedia.org/wiki/Turtle_graphics).
mod turtle;
pub use converter::{svg2program, ConversionOptions};
pub use machine::Machine;
pub use machine::SupportedFunctionality;
pub use postprocess::set_origin;
pub use converter::{svg2program, ConversionConfig, ConversionOptions};
pub use machine::{Machine, MachineConfig, SupportedFunctionality};
pub use postprocess::{set_origin, PostprocessConfig};
pub use turtle::Turtle;
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Settings {
pub conversion: ConversionConfig,
pub machine: MachineConfig,
pub postprocess: PostprocessConfig,
}
#[cfg(test)]
mod test {
use super::*;
@ -28,10 +38,8 @@ mod test {
circular_interpolation: bool,
dimensions: [Option<Length>; 2],
) -> String {
let options = ConversionOptions {
dimensions,
..Default::default()
};
let config = ConversionConfig::default();
let options = ConversionOptions { dimensions };
let document = roxmltree::Document::parse(input).unwrap();
let mut turtle = Turtle::new(Machine::new(
@ -43,7 +51,7 @@ mod test {
None,
None,
));
let mut program = converter::svg2program(&document, options, &mut turtle);
let mut program = converter::svg2program(&document, &config, options, &mut turtle);
postprocess::set_origin(&mut program, [0., 0.]);
let mut acc = String::new();

@ -1,4 +1,6 @@
use g_code::{command, emit::Token, parse::ast::Snippet};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
/// Whether the tool is active (i.e. cutting)
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
@ -41,13 +43,24 @@ pub struct Machine<'input> {
supported_functionality: SupportedFunctionality,
tool_state: Option<Tool>,
distance_mode: Option<Distance>,
tool_on_action: Vec<Token<'input>>,
tool_off_action: Vec<Token<'input>>,
tool_on_sequence: Vec<Token<'input>>,
tool_off_sequence: Vec<Token<'input>>,
program_begin_sequence: Vec<Token<'input>>,
program_end_sequence: Vec<Token<'input>>,
}
#[derive(Debug, Default, Clone)]
#[derive(Debug, Default, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MachineConfig {
pub supported_functionality: SupportedFunctionality,
pub tool_on_sequence: Option<String>,
pub tool_off_sequence: Option<String>,
pub begin_sequence: Option<String>,
pub end_sequence: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SupportedFunctionality {
/// Indicates support for G2/G3 circular interpolation.
///
@ -58,17 +71,17 @@ pub struct SupportedFunctionality {
impl<'input> Machine<'input> {
pub fn new(
supported_functionality: SupportedFunctionality,
tool_on_action: Option<Snippet<'input>>,
tool_off_action: Option<Snippet<'input>>,
tool_on_sequence: Option<Snippet<'input>>,
tool_off_sequence: Option<Snippet<'input>>,
program_begin_sequence: Option<Snippet<'input>>,
program_end_sequence: Option<Snippet<'input>>,
) -> Self {
Self {
supported_functionality,
tool_on_action: tool_on_action
tool_on_sequence: tool_on_sequence
.map(|s| s.iter_emit_tokens().collect())
.unwrap_or_default(),
tool_off_action: tool_off_action
tool_off_sequence: tool_off_sequence
.map(|s| s.iter_emit_tokens().collect())
.unwrap_or_default(),
program_begin_sequence: program_begin_sequence
@ -89,7 +102,7 @@ impl<'input> Machine<'input> {
pub fn tool_on(&mut self) -> Vec<Token<'input>> {
if self.tool_state == Some(Tool::Off) || self.tool_state == None {
self.tool_state = Some(Tool::On);
self.tool_on_action.clone()
self.tool_on_sequence.clone()
} else {
vec![]
}
@ -99,7 +112,7 @@ impl<'input> Machine<'input> {
pub fn tool_off(&mut self) -> Vec<Token<'input>> {
if self.tool_state == Some(Tool::On) || self.tool_state == None {
self.tool_state = Some(Tool::Off);
self.tool_off_action.clone()
self.tool_off_sequence.clone()
} else {
vec![]
}

@ -1,3 +1,6 @@
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use euclid::default::Box2D;
use g_code::emit::{
command::{ABSOLUTE_DISTANCE_MODE_FIELD, RELATIVE_DISTANCE_MODE_FIELD},
@ -7,6 +10,12 @@ use lyon_geom::{point, vector, Point};
type F64Point = Point<f64>;
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Default, Clone, PartialEq)]
pub struct PostprocessConfig {
pub origin: [f64; 2],
}
/// Moves all the commands so that they are beyond a specified position
pub fn set_origin(tokens: &mut [Token<'_>], origin: [f64; 2]) {
let offset =

@ -1,6 +1,6 @@
[package]
name = "svg2gcode-web"
version = "0.0.1"
version = "0.0.2"
authors = ["Sameer Puri <crates@purisa.me>"]
edition = "2018"
description = "Convert vector graphics to g-code for pen plotters, laser engravers, and other CNC machines"
@ -10,7 +10,7 @@ license = "MIT"
[dependencies]
wasm-bindgen = "0.2"
svg2gcode = { path = "../lib" }
svg2gcode = { path = "../lib", features = ["serde"] }
roxmltree = "0"
g-code = "0"
codespan-reporting = "0.11"
@ -19,6 +19,7 @@ serde = "1"
paste = "1"
log = "0"
svgtypes = "0"
serde_json = "1"
yew = { git = "https://github.com/yewstack/yew.git" }
yewdux-functional = { git = "https://github.com/intendednull/yewdux.git" }

@ -1,11 +1,12 @@
use codespan_reporting::term::{emit, termcolor::NoColor, Config};
use g_code::parse::{into_diagnostic, snippet_parser};
use gloo_file::futures::read_as_text;
use gloo_file::futures::{read_as_bytes, read_as_text};
use gloo_timers::callback::Timeout;
use js_sys::TypeError;
use paste::paste;
use roxmltree::Document;
use std::num::ParseFloatError;
use std::{convert::TryInto, num::ParseFloatError, path::Path};
use svg2gcode::Settings;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
use web_sys::{window, FileList, HtmlElement, Response};
@ -171,7 +172,8 @@ macro_rules! gcode_input {
($($name: ident {
$label: literal,
$desc: literal,
$accessor: expr $(=> $idx: literal)?,
$form_accessor: expr $(=> $form_idx: literal)?,
$app_accessor: expr $(=> $app_idx: literal)?,
})*) => {
$(
paste! {
@ -207,14 +209,14 @@ macro_rules! gcode_input {
timeout.set(Some(Timeout::new(VALIDATION_TIMEOUT, move || {
timeout_inner.set(None);
})));
state.$accessor $([$idx])? = res;
state.$form_accessor $([$form_idx])? = res;
})
};
html! {
<FormGroup success={form.state().map(|state| (state.$accessor $([$idx])?).as_ref().map(Result::is_ok)).flatten()}>
<FormGroup success={form.state().map(|state| (state.$form_accessor $([$form_idx])?).as_ref().map(Result::is_ok)).flatten()}>
<TextArea<String, String> label=$label desc=$desc
default={app.state().map(|state| (state.$accessor $([$idx])?).clone()).unwrap_or_else(|| AppState::default().$accessor $([$idx])?)}
parsed={form.state().and_then(|state| (state.$accessor $([$idx])?).clone()).filter(|_| timeout.is_none())}
default={app.state().map(|state| (state.$app_accessor $([$app_idx])?).clone()).unwrap_or_else(|| AppState::default().$app_accessor $([$app_idx])?)}
parsed={form.state().and_then(|state| (state.$form_accessor $([$form_idx])?).clone()).filter(|_| timeout.is_none())}
oninput={oninput}
/>
</FormGroup>
@ -230,21 +232,25 @@ gcode_input! {
"Tool On Sequence",
"G-Code for turning on the tool",
tool_on_sequence,
settings.machine.tool_on_sequence,
}
ToolOffSequence {
"Tool Off Sequence",
"G-Code for turning off the tool",
tool_off_sequence,
settings.machine.tool_off_sequence,
}
BeginSequence {
"Program Begin Sequence",
"G-Code for initializing the machine at the beginning of the program",
begin_sequence,
settings.machine.begin_sequence,
}
EndSequence {
"Program End Sequence",
"G-Code for stopping/idling the machine at the end of the program",
end_sequence,
settings.machine.end_sequence,
}
}
@ -252,7 +258,8 @@ macro_rules! form_input {
($($name: ident {
$label: literal,
$desc: literal,
$accessor: expr $(=> $idx: literal)?,
$form_accessor: expr $(=> $form_idx: literal)?,
$app_accessor: expr $(=> $app_idx: literal)?,
})*) => {
$(
paste! {
@ -260,12 +267,12 @@ macro_rules! form_input {
fn [<$name:snake:lower _input>]() -> Html {
let app = use_store::<AppStore>();
let form = use_store::<BasicStore<FormState>>();
let oninput = form.dispatch().input(|state, value| state.$accessor $([$idx])? = value.parse::<f64>());
let oninput = form.dispatch().input(|state, value| state.$form_accessor $([$form_idx])? = value.parse::<f64>());
html! {
<FormGroup success={form.state().map(|state| (state.$accessor $([$idx])?).is_ok())}>
<FormGroup success={form.state().map(|state| (state.$form_accessor $([$form_idx])?).is_ok())}>
<Input<f64, ParseFloatError> label=$label desc=$desc
default={app.state().map(|state| state.$accessor $([$idx])?).unwrap_or_else(|| AppState::default().$accessor $([$idx])?)}
parsed={form.state().map(|state| (state.$accessor $([$idx])?).clone())}
default={app.state().map(|state| state.$app_accessor $([$app_idx])?).unwrap_or_else(|| AppState::default().$app_accessor $([$app_idx])?)}
parsed={form.state().map(|state| (state.$form_accessor $([$form_idx])?).clone())}
oninput={oninput}
/>
</FormGroup>
@ -281,26 +288,31 @@ form_input! {
"Tolerance",
"Curve interpolation tolerance (mm)",
tolerance,
settings.conversion.tolerance,
}
Feedrate {
"Feedrate",
"Machine feedrate (mm/min)",
feedrate,
settings.conversion.feedrate,
}
Dpi {
"Dots per Inch",
"Used for scaling visual units (pixels, points, picas, etc.)",
dpi,
settings.conversion.dpi,
}
OriginX {
"Origin X",
"X-axis coordinate for the bottom left corner of the machine",
origin => 0,
settings.postprocess.origin => 0,
}
OriginY {
"Origin Y",
"Y-axis coordinate for the bottom left corner of the machine",
origin => 1,
settings.postprocess.origin => 1,
}
}
@ -357,18 +369,7 @@ pub fn settings_form() -> Html {
let close_ref = close_ref.clone();
app.dispatch().reduce_callback(move |app| {
if let (false, Some(form)) = (disabled, form.state()) {
app.tolerance = *form.tolerance.as_ref().unwrap();
app.feedrate = *form.feedrate.as_ref().unwrap();
app.origin = [
*form.origin[0].as_ref().unwrap(),
*form.origin[1].as_ref().unwrap(),
];
app.circular_interpolation = form.circular_interpolation;
app.dpi = *form.dpi.as_ref().unwrap();
app.tool_on_sequence = form.tool_on_sequence.clone().and_then(Result::ok);
app.tool_off_sequence = form.tool_off_sequence.clone().and_then(Result::ok);
app.begin_sequence = form.begin_sequence.clone().and_then(Result::ok);
app.end_sequence = form.end_sequence.clone().and_then(Result::ok);
app.settings = form.as_ref().try_into().unwrap();
// TODO: this is a poor man's crutch for closing the Modal.
// There is probably a better way.
@ -384,7 +385,17 @@ pub fn settings_form() -> Html {
id="settings"
header={
html!(
<h5>{ "Settings" }</h5>
<>
<h2>{ "Settings" }</h2>
<p>{"Persisted using "}
// Opening new tabs is usually bad.
// But if we don't, the user is at risk of losing the settings they've entered so far.
<a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage" target="_blank">
{"local storage"}
</a>
{"."}
</p>
</>
)
}
body={html!(
@ -411,15 +422,6 @@ pub fn settings_form() -> Html {
footer={
html!(
<>
<p>
{"These settings are persisted using local storage. Learn more "}
// Opening new tabs is usually bad.
// But if we don't, the user is at risk of losing the settings they've entered so far.
<a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage" target="_blank">
{"on MDN"}
</a>
{"."}
</p>
<Button
style={ButtonStyle::Primary}
disabled={disabled}
@ -436,8 +438,120 @@ pub fn settings_form() -> Html {
</>
)
}
>
</Modal>
/>
}
}
#[function_component(ImportExportModal)]
pub fn import_export_modal() -> Html {
let app = use_store::<AppStore>();
let import_state = use_state(|| Option::<Result<Settings, String>>::None);
let export_onclick =
app.dispatch()
.reduce_callback(|app| match serde_json::to_vec_pretty(&app.settings) {
Ok(settings_json_bytes) => {
let filename = format!("svg2gcode_settings");
let filepath = Path::new(&filename).with_extension("json");
crate::util::prompt_download(&filepath, &settings_json_bytes);
}
Err(serde_json_err) => {}
});
let settings_upload_onchange = {
let import_state = import_state.clone();
app.dispatch()
.future_callback_with(move |app, file_list: FileList| {
let import_state = import_state.clone();
async move {
let file = file_list.item(0).unwrap();
let filename = file.name();
let res = read_as_bytes(&gloo_file::File::from(file))
.await
.map_err(|err| format!("Error reading {}: {}", &filename, err))
.and_then(|bytes| {
serde_json::from_slice::<Settings>(&bytes)
.map_err(|err| format!("Error parsing {}: {}", &filename, err))
});
match res {
Ok(settings) => {
import_state.set(Some(Ok(settings)));
}
Err(err) => {
import_state.set(Some(Err(err)));
}
}
}
})
};
let import_save_onclick = {
let import_state = import_state.clone();
app.dispatch().reduce_callback(move |app| {
if let Some(Ok(ref settings)) = *import_state {
app.settings = settings.clone();
import_state.set(None);
}
})
};
let close_ref = NodeRef::default();
html! {
<Modal
id="import_export"
header={html!(
<>
<h2>{"Import/Export Settings"}</h2>
<p>{"Uses JSON, compatible with the "}<a href="https://github.com/sameer/svg2gcode/releases">{"command line interface"}</a>{"."}</p>
</>
)}
body={
html!(
<>
<h3>{"Import"}</h3>
<FormGroup success={import_state.as_ref().map(Result::is_ok)}>
<FileUpload<Settings, String>
label="Select settings JSON file"
accept=".json"
multiple={false}
onchange={settings_upload_onchange}
parsed={(*import_state).clone()}
button={html_nested!(
<Button
style={ButtonStyle::Primary}
disabled={import_state.is_none()}
title="Save"
onclick={import_save_onclick}
/>
)}
/>
</FormGroup>
<h3>{"Export"}</h3>
<Button
style={ButtonStyle::Primary}
disabled={false}
title="Export"
icon={html_nested!(<Icon name={IconName::Download}/>)}
onclick={export_onclick}
/>
</>
)
}
footer={
html!(
<HyperlinkButton
ref={close_ref}
style={ButtonStyle::Default}
title="Close"
href="#close"
/>
)
}
/>
}
}
@ -527,7 +641,7 @@ pub fn svg_input() -> Html {
url_input_parsed.set(None);
let res = JsFuture::from(window().unwrap().fetch_with_str(&request_url))
.await
.map(|res| res.dyn_into::<Response>().unwrap());
.map(JsCast::unchecked_into::<Response>);
url_add_loading.set(false);
match res {
Ok(res) => {

@ -6,21 +6,19 @@ use g_code::{
};
use log::Level;
use roxmltree::Document;
use svg2gcode::{
set_origin, svg2program, ConversionOptions, Machine, SupportedFunctionality, Turtle,
};
use wasm_bindgen::JsCast;
use web_sys::HtmlElement;
use yew::{prelude::*, utils::window};
use svg2gcode::{set_origin, svg2program, ConversionOptions, Machine, Turtle};
use yew::prelude::*;
use yewdux::prelude::{Dispatch, Dispatcher};
mod inputs;
mod spectre;
mod state;
mod util;
use inputs::*;
use spectre::*;
use state::*;
use util::*;
struct App {
app_dispatch: Dispatch<AppStore>,
@ -73,34 +71,38 @@ impl Component for App {
self.link.send_future(async move {
for svg in app_state.svgs.iter() {
let options = ConversionOptions {
tolerance: app_state.tolerance,
feedrate: app_state.feedrate,
dpi: app_state.dpi,
dimensions: [None; 2],
dimensions: svg.dimensions,
};
let machine = Machine::new(
SupportedFunctionality {
circular_interpolation: app_state.circular_interpolation,
},
app_state.settings.machine.supported_functionality.clone(),
app_state
.settings
.machine
.tool_on_sequence
.as_deref()
.map(snippet_parser)
.transpose()
.unwrap(),
app_state
.settings
.machine
.tool_off_sequence
.as_deref()
.map(snippet_parser)
.transpose()
.unwrap(),
app_state
.settings
.machine
.begin_sequence
.as_deref()
.map(snippet_parser)
.transpose()
.unwrap(),
app_state
.settings
.machine
.end_sequence
.as_deref()
.map(snippet_parser)
@ -110,30 +112,23 @@ impl Component for App {
let document = Document::parse(svg.content.as_str()).unwrap();
let mut turtle = Turtle::new(machine);
let mut program = svg2program(&document, options, &mut turtle);
let mut program = svg2program(
&document,
&app_state.settings.conversion,
options,
&mut turtle,
);
set_origin(&mut program, app_state.origin);
set_origin(&mut program, app_state.settings.postprocess.origin);
let gcode_base64 = {
let gcode = {
let mut acc = String::new();
format_gcode_fmt(&program, FormatOptions::default(), &mut acc).unwrap();
base64::encode(acc.as_bytes())
acc
};
let window = window();
let document = window.document().unwrap();
let hyperlink = document.create_element("a").unwrap();
let filepath = Path::new(svg.filename.as_str()).with_extension("gcode");
let filename = filepath.to_str().unwrap();
hyperlink
.set_attribute(
"href",
&format!("data:text/plain;base64,{}", gcode_base64),
)
.unwrap();
hyperlink.set_attribute("download", filename).unwrap();
hyperlink.unchecked_into::<HtmlElement>().click();
prompt_download(filepath, &gcode.as_bytes());
}
AppMsg::Done
@ -159,7 +154,7 @@ impl Component for App {
// Having separate stores is somewhat of an anti-pattern in Redux,
// but there's no easy way to do hydration after the app state is
// restored from local storage.
let hydrated_form_state = FormState::from(self.app_state.as_ref());
let hydrated_form_state = FormState::from(&self.app_state.settings);
let settings_hydrate_onclick = self.form_dispatch.reduce_callback_once(move |form| {
*form = hydrated_form_state;
});
@ -226,8 +221,15 @@ impl Component for App {
onclick={settings_hydrate_onclick}
href="#settings"
/>
<HyperlinkButton
title="Import/Export"
style={ButtonStyle::Default}
icon={IconName::Copy}
href="#import_export"
/>
</ButtonGroup>
<SettingsForm />
<SettingsForm/>
<ImportExportModal/>
</div>
<div class={classes!("text-right", "column")}>
<p>

@ -96,23 +96,23 @@ where
<label class="form-label" for={id.clone()}>
{ props.label }
</label>
<div class={classes!(if success || error { Some("has-icon-right") } else { None })}>
<div class={classes!(if props.button.is_some() { Some("input-group") } else { None })}>
<div class={classes!(if props.button.is_some() { Some("input-group") } else { None })}>
<div class={classes!(if props.button.is_some() { Some("input-group") } else { None }, if success || error { Some("has-icon-right") } else { None })}>
<input id={id} class="form-input" type={props.r#type.to_string()} ref={(*node_ref).clone()}
oninput={props.oninput.clone()} placeholder={ props.placeholder.as_ref().map(ToString::to_string) }
/>
{ props.button.clone().map(Html::from).unwrap_or_default() }
</div>
{
if let Some(parsed) = props.parsed.as_ref() {
match parsed {
Ok(_) => html!(<Icon form=true name={IconName::Check}/>),
Err(_) => html!(<Icon form=true name={IconName::Cross}/>)
{
if let Some(parsed) = props.parsed.as_ref() {
match parsed {
Ok(_) => html!(<Icon form=true name={IconName::Check}/>),
Err(_) => html!(<Icon form=true name={IconName::Cross}/>)
}
} else {
html!()
}
} else {
html!()
}
}
</div>
{ props.button.clone().map(Html::from).unwrap_or_default() }
</div>
{
if let Some(Err(ref err)) = props.parsed.as_ref() {
@ -171,6 +171,8 @@ where
pub parsed: Option<Result<T, E>>,
#[prop_or_default]
pub onchange: Callback<FileList>,
#[prop_or_default]
pub button: Option<VChild<Button>>,
}
#[function_component(FileUpload)]
@ -187,25 +189,28 @@ where
<label class="form-label" for={id.clone()}>
{ props.label }
</label>
<div class={classes!(if success || error { Some("has-icon-right") } else { None })}>
<input id={id} class="form-input" type="file" accept={props.accept} multiple={props.multiple}
onchange={props.onchange.clone().reform(|x: ChangeData| {
match x {
ChangeData::Files(file_list) => file_list,
_ => unreachable!()
}
})}
/>
{
if let Some(parsed) = props.parsed.as_ref() {
match parsed {
Ok(_) => html!(<Icon form=true name={IconName::Check}/>),
Err(_) => html!(<Icon form=true name={IconName::Cross}/>)
<div class={classes!(if props.button.is_some() { Some("input-group") } else { None })}>
<div class={classes!(if props.button.is_some() { Some("input-group") } else { None }, if success || error { Some("has-icon-right") } else { None })}>
<input id={id} class="form-input" type="file" accept={props.accept} multiple={props.multiple}
onchange={props.onchange.clone().reform(|x: ChangeData| {
match x {
ChangeData::Files(file_list) => file_list,
_ => unreachable!()
}
})}
/>
{
if let Some(parsed) = props.parsed.as_ref() {
match parsed {
Ok(_) => html!(<Icon form=true name={IconName::Check}/>),
Err(_) => html!(<Icon form=true name={IconName::Cross}/>)
}
} else {
html!()
}
} else {
html!()
}
}
</div>
{ props.button.clone().map(Html::from).unwrap_or_default() }
</div>
{
if let Some(Err(ref err)) = props.parsed.as_ref() {

@ -1,5 +1,8 @@
use serde::{Deserialize, Serialize};
use std::num::ParseFloatError;
use std::{convert::TryInto, num::ParseFloatError};
use svg2gcode::{
Settings, ConversionConfig, MachineConfig, PostprocessConfig, SupportedFunctionality,
};
use svgtypes::Length;
use yewdux::prelude::{BasicStore, Persistent, PersistentStore};
@ -19,22 +22,75 @@ pub struct FormState {
impl Default for FormState {
fn default() -> Self {
let app_state = AppState::default();
Self::from(&app_state)
Self::from(&app_state.settings)
}
}
impl From<&AppState> for FormState {
fn from(app_state: &AppState) -> Self {
impl<'a> TryInto<Settings> for &'a FormState {
type Error = ParseFloatError;
fn try_into(self) -> Result<Settings, Self::Error> {
Ok(Settings {
conversion: ConversionConfig {
tolerance: self.tolerance.clone()?,
feedrate: self.feedrate.clone()?,
dpi: self.dpi.clone()?,
},
machine: MachineConfig {
supported_functionality: SupportedFunctionality {
circular_interpolation: self.circular_interpolation,
},
tool_on_sequence: self.tool_on_sequence.clone().and_then(Result::ok),
tool_off_sequence: self.tool_off_sequence.clone().and_then(Result::ok),
begin_sequence: self.begin_sequence.clone().and_then(Result::ok),
end_sequence: self.end_sequence.clone().and_then(Result::ok),
},
postprocess: PostprocessConfig {
origin: [self.origin[0].clone()?, self.origin[1].clone()?],
},
})
}
}
impl From<&Settings> for FormState {
fn from(settings: &Settings) -> Self {
Self {
tolerance: Ok(app_state.tolerance),
feedrate: Ok(app_state.feedrate),
circular_interpolation: app_state.circular_interpolation,
origin: [Ok(app_state.origin[0]), Ok(app_state.origin[1])],
dpi: Ok(app_state.dpi),
tool_on_sequence: app_state.tool_on_sequence.clone().map(Result::Ok),
tool_off_sequence: app_state.tool_off_sequence.clone().map(Result::Ok),
begin_sequence: app_state.begin_sequence.clone().map(Result::Ok),
end_sequence: app_state.end_sequence.clone().map(Result::Ok),
tolerance: Ok(settings.conversion.tolerance),
feedrate: Ok(settings.conversion.feedrate),
circular_interpolation:
settings
.machine
.supported_functionality
.circular_interpolation,
origin: [
Ok(settings.postprocess.origin[0]),
Ok(settings.postprocess.origin[1]),
],
dpi: Ok(settings.conversion.dpi),
tool_on_sequence:
settings
.machine
.tool_on_sequence
.clone()
.map(Result::Ok),
tool_off_sequence:
settings
.machine
.tool_off_sequence
.clone()
.map(Result::Ok),
begin_sequence:
settings
.machine
.begin_sequence
.clone()
.map(Result::Ok),
end_sequence:
settings
.machine
.end_sequence
.clone()
.map(Result::Ok),
}
}
}
@ -44,15 +100,7 @@ pub type AppStore = PersistentStore<AppState>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppState {
pub first_visit: bool,
pub tolerance: f64,
pub feedrate: f64,
pub origin: [f64; 2],
pub circular_interpolation: bool,
pub dpi: f64,
pub tool_on_sequence: Option<String>,
pub tool_off_sequence: Option<String>,
pub begin_sequence: Option<String>,
pub end_sequence: Option<String>,
pub settings: Settings,
#[serde(skip)]
pub svgs: Vec<Svg>,
}
@ -68,15 +116,7 @@ impl Default for AppState {
fn default() -> Self {
Self {
first_visit: true,
tolerance: 0.002,
feedrate: 300.,
origin: [0., 0.],
circular_interpolation: false,
dpi: 96.,
tool_on_sequence: None,
tool_off_sequence: None,
begin_sequence: None,
end_sequence: None,
settings: Settings::default(),
svgs: vec![],
}
}

@ -0,0 +1,17 @@
use std::path::Path;
use wasm_bindgen::JsCast;
use web_sys::{HtmlElement, window};
pub fn prompt_download(path: impl AsRef<Path>, content: impl AsRef<[u8]>) {
let window = window().unwrap();
let document = window.document().unwrap();
let hyperlink = document.create_element("a").unwrap();
let content_base64 = base64::encode(content);
hyperlink
.set_attribute("href", &format!("data:text/plain;base64,{}", content_base64))
.unwrap();
hyperlink
.set_attribute("download", &path.as_ref().display().to_string())
.unwrap();
hyperlink.unchecked_into::<HtmlElement>().click();
}

@ -11,3 +11,12 @@ div.card-container {
margin-top: 1em;
margin-bottom: 1em;
}
div.modal-container {
max-height: 90vh !important;
}
div.input-group,
div.has-icon-right {
flex: auto;
}

Loading…
Cancel
Save