1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
use crate::cli::config::{
    validate_jpeg_quality, Config, ConfigBuilder, InputOutputModeType, SelectedLicenses,
};
use anyhow::{anyhow, bail};
use arg_names::*;
use clap::{App, AppSettings, Arg, ArgGroup, ArgMatches};
use naut_cli_ops::create_image_ops;
use naut_cli_ops::operations::OperationId;
use naut_io::load::FrameIndex;
use std::path::Path;
use std::str::FromStr;
use strum::VariantNames;

macro_rules! define_arg_consts {
    ($mod:ident, { $($argdef:ident),+ $(,)? } ) => {
            pub mod $mod {
                $(
                    pub const $argdef: &str = stringify!($argdef);
                )+
            }
    };
}

define_arg_consts!(arg_names, {
    // organisational:
    ARG_LICENSE,
    ARG_DEP_LICENSES,

    // input and output images
    ARG_INPUT,
    ARG_INPUT_GLOB,
    ARG_OUTPUT,
    ARG_OUTPUT_GLOB,

    // config for glob/batch mode
    ARG_GLOB_NO_SKIP_UNSUPPORTED_EXTENSIONS,

    // set specific configurations for decoding
    ARG_SELECT_FRAME,

    // set specific configurations for encoding
    ARG_DISABLE_AUTOMATIC_COLOR_TYPE_ADJUSTMENT,
    ARG_FORCED_OUTPUT_FORMAT,
    ARG_JPEG_ENCODING_QUALITY,
    ARG_PNM_ENCODING_ASCII,
    ARG_IMAGE_CRATE_FALLBACK,

    // provide image operations using image script
    ARG_APPLY_OPERATIONS,
    ARG_OPERATIONS_SCRIPT,

    // group: image operations
    GROUP_IMAGE_OPERATIONS,
});

#[cfg(not(feature = "imageproc-ops"))]
fn wrap_with(app: App<'static, 'static>) -> App<'static, 'static> {
    app
}

#[cfg(feature = "imageproc-ops")]
fn wrap_with(app: App<'static, 'static>) -> App<'static, 'static> {
    app.arg(
        Arg::with_name(OperationId::DrawText.as_str())
            .help("Operation: draw-text.")
            .long(OperationId::DrawText.as_str())
            .takes_value(true)
            .value_name(
                "<text> <coord(x, y)> <rgba(r,g,b,a)> <size(s)> <font(\"path/to/font.ttf\">)",
            )
            .number_of_values(5)
            .multiple(true),
    )
}

pub fn create_app(
    version: &'static str,
    about: &'static str,
    help_ops: &'static str,
) -> App<'static, 'static> {
    wrap_with(App::new("naut")
        .version(version)
        .about(about)
        .after_help("For more information, visit: https://github.com/foresterre/naut")
        .author("Martijn Gribnau <garm@ilumeo.com>")

        // settings
        .global_setting(AppSettings::NextLineHelp)
        .global_setting(AppSettings::ColoredHelp)
        .global_setting(AppSettings::ColorAuto)
        .global_setting(AppSettings::DontCollapseArgsInUsage)
        .global_setting(AppSettings::UnifiedHelpMessage)
        .max_term_width(120)

        // cli arguments

        // organisational:
        .arg(Arg::with_name(ARG_LICENSE)
            .long("license")
            .help("Displays the license of this piece of software (`naut`).")
            .takes_value(false)
            .conflicts_with_all(&[ARG_DEP_LICENSES, ARG_INPUT, ARG_OUTPUT, ARG_INPUT_GLOB, ARG_OUTPUT_GLOB]))
        .arg(Arg::with_name(ARG_DEP_LICENSES)
            .long("dep-licenses")
            .help("Displays the licenses of the dependencies on which this software relies.")
            .takes_value(false)
            .conflicts_with_all(&[ARG_LICENSE, ARG_INPUT, ARG_OUTPUT, ARG_INPUT_GLOB, ARG_OUTPUT_GLOB]))

        // io(input):
        .arg(Arg::with_name(ARG_INPUT)
            .long("input")
            .short("i")
            .value_name("INPUT_PATH")
            .takes_value(true)
            .help("Input image path. When using this option, input piped from stdin will be ignored. \
                      If using unexpanded globs as argument, use --glob-input instead.")
            .conflicts_with_all(&[ARG_LICENSE, ARG_DEP_LICENSES, ARG_INPUT_GLOB, ARG_OUTPUT_GLOB]))

        .arg(Arg::with_name(ARG_INPUT_GLOB)
            .long("glob-input")
            .takes_value(true)
            .value_name("GLOB_INPUT_PATTERN")
            .help("Input glob path which attempts to match all files matching the given glob pattern. Use with --glob-output. \
                Depending on your shell you may need to add explicit quotation marks around the argument.")
            .conflicts_with_all(&[ARG_LICENSE, ARG_DEP_LICENSES, ARG_INPUT, ARG_OUTPUT])
        )

        // io(output):
        .arg(Arg::with_name(ARG_OUTPUT)
            .long("output")
            .short("o")
            .value_name("OUTPUT_PATH")
            .takes_value(true)
            .help("Output image path. When using this option, output won't be piped to stdout.")
            .conflicts_with_all(&[ARG_LICENSE, ARG_DEP_LICENSES, ARG_INPUT_GLOB, ARG_OUTPUT_GLOB])
        )

        .arg(Arg::with_name(ARG_OUTPUT_GLOB)
            .long("glob-output")
            .value_name("GLOB_OUTPUT_ROOT_FOLDER")
            .takes_value(true)
            .help("This output should point to a folder in which the greatest root common directory of the glob input will be mirrored")
            .conflicts_with_all(&[ARG_LICENSE, ARG_DEP_LICENSES, ARG_INPUT, ARG_OUTPUT])
        )

        // config for glob/batch mode
        .arg(Arg::with_name(ARG_GLOB_NO_SKIP_UNSUPPORTED_EXTENSIONS)
            .long("no-skip-unsupported-extensions")
            .help("Files which don't have a known extension will not be skipped in glob mode")
            .long_help("Only has an effect when combined with --glob-input")
            .takes_value(false)
        )

        // config(in):
        .arg(Arg::with_name(ARG_SELECT_FRAME)
            .long("select-frame")
            .value_name("#FRAME")
            .help("Frame to be loaded as still image if the input image is an animated image.\
                      To pick the first and last frame respectively, you can provide 'first' and 'last' as arguments. \
                      Otherwise provide a single one-indexed positive number which corresponds with the frame index. \
                      For example, to select the first frame, the argument would be '1', for the second '2', etc.")
            .takes_value(true))

        // config(out):
        .arg(Arg::with_name(ARG_DISABLE_AUTOMATIC_COLOR_TYPE_ADJUSTMENT)
            .long("disable-automatic-color-type-adjustment")
            .help("Some image output formats do not support the color type of the image buffer prior to encoding. \
                      By default the program tries to adjust the color type. If this flag is provided, \
                      the program will not try to adjust the color type."))

        .arg(Arg::with_name(ARG_FORCED_OUTPUT_FORMAT)
            .short("f")
            .long("output-format")
            .value_name("FORMAT")
            .help("Force the output image format to use FORMAT, regardless of the (if any) extension of the given output file path. \
                      Output formats (FORMAT values) supported: AVIF, BMP, Farbfeld, GIF, ICO, JPEG, PNG, PAM, PBM, PGM, PPM and TGA.")
            .takes_value(true))

        .arg(Arg::with_name(ARG_JPEG_ENCODING_QUALITY)
            .long("jpeg-encoding-quality")
            .help("Set the jpeg quality to QUALITY. Valid values are positive numbers from 1 up to and including 100. Will only be used when the output format is determined to be jpeg.")
            .value_name("QUALITY")
            .takes_value(true))

        .arg(Arg::with_name(ARG_PNM_ENCODING_ASCII)
            .long("pnm-encoding-ascii")
            .help("Use ascii based encoding when using a PNM image output format (pbm, pgm or ppm). Doesn't apply to 'pam' (PNM Arbitrary Map)."))

        .arg(Arg::with_name(ARG_IMAGE_CRATE_FALLBACK)
            .long("enable-output-format-decider-fallback")
            .help("[experimental] When this flag is set, naut will attempt to fallback to an alternative output format decider (image crate version), \
            *if* naut's own decider can't find a suitable format. Setting this flag may introduce unwanted behaviour; use with caution."))

        // image-operations(script):
        .arg(Arg::with_name(ARG_APPLY_OPERATIONS)
            .long("apply-operations")
            .short("x")
            .alias("A")
            .help(help_ops)
            .value_name("OPERATIONS")
            .takes_value(true)
            .conflicts_with(ARG_OPERATIONS_SCRIPT))

        .arg(Arg::with_name(ARG_OPERATIONS_SCRIPT)
            .long("operations-script")
            .help("Like '--apply-operations' but takes a file path where the file contains the script instead of taking it as value directly")
            .value_name("SCRIPT_FILE")
            .takes_value(true)
            .conflicts_with(ARG_APPLY_OPERATIONS))

        // image-operations(cli-arguments):
        .group(ArgGroup::with_name(GROUP_IMAGE_OPERATIONS)
            .args(&OperationId::VARIANTS)
            .conflicts_with(ARG_APPLY_OPERATIONS)
            .multiple(true))
        .arg(Arg::with_name(OperationId::Blur.as_str())
            .help("Operation: perform a gaussian blur on the input image")
            .long(OperationId::Blur.as_str())
            .takes_value(true)
            .value_name("fp")
            .number_of_values(1)
            .multiple(true)
            .allow_hyphen_values(true))
        .arg(Arg::with_name(OperationId::Brighten.as_str())
            .help("Operation: increase or decrease the brightness of the input image")
            .long(OperationId::Brighten.as_str())
            .takes_value(true)
            .value_name("int")
            .number_of_values(1)
            .multiple(true)
            .allow_hyphen_values(true))
        .arg(Arg::with_name(OperationId::Contrast.as_str())
            .help("Operation: increase or decrease the contrast of the input image")
            .long(OperationId::Contrast.as_str())
            .takes_value(true)
            .value_name("fp")
            .number_of_values(1)
            .multiple(true)
            .allow_hyphen_values(true))
        .arg(Arg::with_name(OperationId::Crop.as_str())
            .help("Operation: crop the input image to a bounding rectangle ranging from top-left (lx, ly) to bottom-right (rx, ry) coordinates")
            .long(OperationId::Crop.as_str())
            .takes_value(true)
            .value_names(&["lx", "ly", "rx", "ry"])
            .number_of_values(4)
            .multiple(true))
        .arg(Arg::with_name(OperationId::Diff.as_str())
            .help("Operation: show ")
            .long(OperationId::Diff.as_str())
            .takes_value(true)
            .value_name("path to image")
            .number_of_values(1)
            .multiple(true))

        .arg(Arg::with_name(OperationId::Filter3x3.as_str())
            .help("Operation: apply a 3x3 convolution filter to the input image (matrix arguments should be given left-to-right, top-to-bottom)")
            .long(OperationId::Filter3x3.as_str())
            .takes_value(true)
            .value_names(&["fp", "fp", "fp", "fp", "fp", "fp", "fp", "fp", "fp"])
            .number_of_values(9)
            .multiple(true)
            .allow_hyphen_values(true))
        .arg(Arg::with_name(OperationId::FlipHorizontal.as_str())
            .help("Operation: flip the input image horizontally")
            .long(OperationId::FlipHorizontal.as_str())
            .multiple(true))
        .arg(Arg::with_name(OperationId::FlipVertical.as_str())
            .help("Operation: flip the input image vertically")
            .long(OperationId::FlipVertical.as_str())
            .multiple(true))
        .arg(Arg::with_name(OperationId::Grayscale.as_str())
            .help("Operation: discard the chrominance signal from the input image, so it becomes achromatic")
            .long_help("Note that (depending on the provided settings flags), the processed image may still be stored in a format which encodes its chrominance")
            .long(OperationId::Grayscale.as_str())
            .multiple(true))
        .arg(Arg::with_name(OperationId::HueRotate.as_str())
            .help("Operation: rotate the hue for each pixel of the input image by a provided degree")
            .long_help("Range is 0-360 degrees, any other value will be mapped to that range by rotation")
            .long(OperationId::HueRotate.as_str())
            .takes_value(true)
            .value_name("int")
            .number_of_values(1)
            .multiple(true)
            .allow_hyphen_values(true))
        .arg(Arg::with_name(OperationId::Invert.as_str())
            .help("Operation: invert the each pixel of the input image ")
            .long(OperationId::Invert.as_str())
            .multiple(true))
        .arg(Arg::with_name(OperationId::Overlay.as_str())
            .help("Operation: overlay an image loaded from the provided path argument, over the input image (at a certain position)")
            .long(OperationId::Overlay.as_str())
            .value_names(&["overlay image path", "x", "y"])
            .takes_value(true)
            .number_of_values(3)
            .multiple(true))
        .arg(Arg::with_name(OperationId::Resize.as_str())
            .help("Operation: resize the input image to x by y pixels")
            .long(OperationId::Resize.as_str())
            .takes_value(true)
            .value_names(&["x", "y"])
            .number_of_values(2)
            .multiple(true))
        .arg(Arg::with_name(OperationId::Rotate90.as_str())
            .help("Operation: rotate the input image by 90 degrees")
            .long(OperationId::Rotate90.as_str())
            .multiple(true))
        .arg(Arg::with_name(OperationId::Rotate180.as_str())
            .help("Operation: rotate the input image by 180 degrees")
            .long(OperationId::Rotate180.as_str())
            .multiple(true))
        .arg(Arg::with_name(OperationId::Rotate270.as_str())
            .help("Operation: rotate the input image by 270 degrees")
            .long(OperationId::Rotate270.as_str())
            .multiple(true))
        .arg(Arg::with_name(OperationId::Unsharpen.as_str())
            .help("Operation: sharpen an image by combining an unsharp (blurred) mask of the input image with the (original) input image, sharpening for pixels where the difference is bigger than the provided threshold")
            .long(OperationId::Unsharpen.as_str())
            .takes_value(true)
            .value_names(&["blur amount","threshold"])
            .number_of_values(2)
            .multiple(true)
            .allow_hyphen_values(true))

        // image-operations(cli-arguments/modifiers):
        .arg(Arg::with_name(OperationId::PreserveAspectRatio.as_str())
            .help("Operation modifier for 'resize': preserve the aspect ratio of the original input image")
            .long(OperationId::PreserveAspectRatio.as_str())
            .takes_value(true)
            .value_name("bool")
            .number_of_values(1)
            .multiple(true)
            .possible_values(&["true", "false"])
        )
        .arg(Arg::with_name(OperationId::SamplingFilter.as_str())
            .help("Operation modifier for 'resize': resize the image using a specific sampling-filter")
            .long(OperationId::SamplingFilter.as_str())
            .takes_value(true)
            .value_name("sampling filter")
            .number_of_values(1)
            .multiple(true)
            .possible_values(&["catmullrom", "gaussian", "lanczos3", "nearest", "triangle"])
        ))
}

// Here any argument should not panic when invalid.
// Previously, it was allowed to panic within Config, but this is no longer the case.
pub fn build_app_config<'a>(matches: &'a ArgMatches) -> anyhow::Result<Config<'a>> {
    let mut builder = ConfigBuilder::new();

    // organisational/licenses:

    let show_license = if matches.is_present(ARG_LICENSE) {
        builder = builder.show_license_text_of(SelectedLicenses::ThisSoftware);

        Some(())
    } else if matches.is_present(ARG_DEP_LICENSES) {
        builder = builder.show_license_text_of(SelectedLicenses::Dependencies);

        Some(())
    } else {
        None
    };

    if show_license.is_some() {
        return Ok(builder.build());
    }

    builder = builder.mode(InputOutputModeType::from_arg_matches(matches)?);

    // config(in)/gif-select-frame:
    if let Some(frame_in) = matches.value_of(ARG_SELECT_FRAME) {
        let frame_out = match frame_in {
            "first" => FrameIndex::First,
            "last" => FrameIndex::Last,
            n => {
                let pick = n.parse::<usize>().map_err(|_| {
                    anyhow!(
                        "Provided argument for --select-frame is not a valid option. \
                         Valid options are 'first', 'last' or a (one-indexed) positive number."
                    )
                })?;

                if pick == 0 {
                    bail!(
                        "Provided argument for --select-frame is not a valid option. \
                         If a number is provided, the number should be positive and larger than 0. \
                         To select the first frame, provide the argument '1'."
                    );
                }

                FrameIndex::Nth(pick - 1)
            }
        };

        builder = builder.select_frame(frame_out);
    }

    // config(out)/disable-automatic-color-type-adjustment:
    if matches.is_present(ARG_DISABLE_AUTOMATIC_COLOR_TYPE_ADJUSTMENT) {
        builder = builder.disable_automatic_color_type_adjustment(true);
    }

    // config(out)/output-format:
    if let Some(format) = matches.value_of(ARG_FORCED_OUTPUT_FORMAT) {
        builder = builder.forced_output_format(format);
    }

    // config(out)/jpeg-encoding-quality:
    if let Some(value) = matches.value_of(ARG_JPEG_ENCODING_QUALITY) {
        let requested_jpeg_quality = u8::from_str(value)
            .map_err(|_| {
                anyhow!("JPEG Encoding quality should be a value between 1 and 100 (inclusive).")
            })
            .and_then(validate_jpeg_quality)?;
        builder = builder.jpeg_quality(requested_jpeg_quality);
    }

    // config(out)/pnm-encoding-type:
    if matches.is_present(ARG_PNM_ENCODING_ASCII) {
        builder = builder.pnm_format_type(true);
    }

    // config(out)/ARG_IMAGE_CRATE_FALLBACK:
    builder =
        builder.image_output_format_decider_fallback(matches.is_present(ARG_IMAGE_CRATE_FALLBACK));

    // image-operations:
    //
    // Image operations are a bit more involved.
    // Thanks to clap, we know either ARG_APPLY_OPERATIONS xor GROUP_IMAGE_OPERATIONS
    // will be the method of providing an image operations program.
    //
    // However with Arg::multiple(true) and Arg::number_of_values(n) we can set to allow multiple
    // operations, like: --blur --blur (multiple is ok), and --crop 0 0 1 1 (number of values = 4),
    // but Clap also allows: --crop 0 0 --crop 1 1 (multiple is ok and number of values = 4).
    //
    // We want to set multiple to true, because image operations can be repeated and can have different
    // effects than the first time.
    // But since the effects can be different, we need to know the order in which arguments are
    // provided. Luckily Clap does tell us the indices of values if we ask for them.
    // If we use --crop 0 0 --blur 1 --crop 1 1, the order of the operations would be undefined, not to
    // say perhaps feel not logical for a user. Therefor, we enforce left to right ordering of
    // operations and require all values to be provided at once, after the operation argument.
    //
    // There is an edge case which we can't (as far as I am aware) handle without looking within the
    // argv ourselves: --crop 0 0 1 1 --crop, is valid according to Clap. However, since we do not
    // receive the amount of times --crop was defined, but rather all the separate provided values for
    // the name of the argument, we just know that for `crop` we have values 0,0,1,1.
    let program = if let Some(script) = matches.value_of(ARG_APPLY_OPERATIONS) {
        naut_parser::parse_script(script)?
    } else if let Some(path) = matches.value_of(ARG_OPERATIONS_SCRIPT) {
        let contents = std::fs::read_to_string(Path::new(path))
            .map_err(|err| anyhow::anyhow!("unable to read script file: {}", err))?;
        naut_parser::parse_script(&contents)?
    } else {
        create_image_ops(std::env::args())?
    };

    builder = builder.image_operations_program(program);

    Ok(builder.build())
}