├── .github ├── dependabot.yml └── workflows │ ├── ci-version.yml │ └── ci.yml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── rustfmt.toml └── src ├── cli.rs ├── lib.rs └── main.rs /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" -------------------------------------------------------------------------------- /.github/workflows/ci-version.yml: -------------------------------------------------------------------------------- 1 | name: CI-version 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | tests: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: 17 | - ubuntu-latest 18 | # - macos-latest # jpeg will break things 19 | toolchain: 20 | - stable 21 | - nightly 22 | features: 23 | - 24 | name: Test ${{ matrix.toolchain }} on ${{ matrix.os }} (${{ matrix.features }}) 25 | runs-on: ${{ matrix.os }} 26 | steps: 27 | - name: Install libwebp (Linux) 28 | run: | 29 | sudo apt update 30 | sudo apt install libwebp-dev 31 | if: runner.os == 'Linux' 32 | - name: Install libwebp (macOS) 33 | run: | 34 | brew update 35 | brew list webp || brew install webp 36 | if: runner.os == 'macOS' 37 | - name: Install ImageMagick 38 | run: | 39 | wget https://imagemagick.org/archive/ImageMagick.tar.gz 40 | tar xf ImageMagick.tar.gz 41 | cd ImageMagick-* 42 | ./configure --enable-hdri --with-webp 43 | make -j$(nproc) 44 | sudo make install 45 | - run: sudo ldconfig 46 | if: runner.os == 'Linux' 47 | - uses: actions/checkout@v4 48 | - uses: actions-rust-lang/setup-rust-toolchain@v1 49 | with: 50 | toolchain: ${{ matrix.toolchain }} 51 | - run: cargo test --release ${{ matrix.features }} 52 | - run: cargo doc --release ${{ matrix.features }} 53 | 54 | MSRV: 55 | strategy: 56 | fail-fast: false 57 | matrix: 58 | os: 59 | - ubuntu-latest 60 | # - macos-latest # jpeg will break things 61 | toolchain: 62 | - "1.74" 63 | features: 64 | - 65 | name: Test ${{ matrix.toolchain }} on ${{ matrix.os }} (${{ matrix.features }}) 66 | runs-on: ${{ matrix.os }} 67 | steps: 68 | - name: Install libwebp (Linux) 69 | run: | 70 | sudo apt update 71 | sudo apt install libwebp-dev 72 | if: runner.os == 'Linux' 73 | - name: Install libwebp (macOS) 74 | run: | 75 | brew update 76 | brew list webp || brew install webp 77 | if: runner.os == 'macOS' 78 | - name: Install ImageMagick 79 | run: | 80 | wget https://imagemagick.org/archive/ImageMagick.tar.gz 81 | tar xf ImageMagick.tar.gz 82 | cd ImageMagick-* 83 | ./configure --enable-hdri --with-webp 84 | make -j$(nproc) 85 | sudo make install 86 | - run: sudo ldconfig 87 | if: runner.os == 'Linux' 88 | - uses: actions/checkout@v4 89 | - uses: actions-rust-lang/setup-rust-toolchain@v1 90 | with: 91 | toolchain: ${{ matrix.toolchain }} 92 | - run: cargo test --release --lib --bins ${{ matrix.features }} -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [ push, pull_request ] 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | 8 | jobs: 9 | rustfmt: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions-rust-lang/setup-rust-toolchain@v1 14 | with: 15 | toolchain: nightly 16 | components: rustfmt 17 | - uses: actions-rust-lang/rustfmt@v1 18 | 19 | clippy: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Install ImageMagick 23 | run: | 24 | sudo apt update 25 | sudo apt install libwebp-dev 26 | wget https://imagemagick.org/archive/ImageMagick.tar.gz 27 | tar xf ImageMagick.tar.gz 28 | cd ImageMagick-* 29 | ./configure --enable-hdri --with-webp 30 | make -j$(nproc) 31 | sudo make install 32 | - uses: actions/checkout@v4 33 | - uses: actions-rust-lang/setup-rust-toolchain@v1 34 | with: 35 | components: clippy 36 | - run: cargo clippy --all-targets --all-features -- -D warnings 37 | 38 | tests: 39 | strategy: 40 | fail-fast: false 41 | matrix: 42 | os: 43 | - ubuntu-latest 44 | # - macos-latest # jpeg will break things 45 | toolchain: 46 | - stable 47 | - nightly 48 | features: 49 | - 50 | name: Test ${{ matrix.toolchain }} on ${{ matrix.os }} (${{ matrix.features }}) 51 | runs-on: ${{ matrix.os }} 52 | steps: 53 | - name: Install libwebp (Linux) 54 | run: | 55 | sudo apt update 56 | sudo apt install libwebp-dev 57 | if: runner.os == 'Linux' 58 | - name: Install libwebp (macOS) 59 | run: | 60 | brew update 61 | brew list webp || brew install webp 62 | if: runner.os == 'macOS' 63 | - name: Install ImageMagick 64 | run: | 65 | wget https://imagemagick.org/archive/ImageMagick.tar.gz 66 | tar xf ImageMagick.tar.gz 67 | cd ImageMagick-* 68 | ./configure --enable-hdri --with-webp 69 | make -j$(nproc) 70 | sudo make install 71 | - run: sudo ldconfig 72 | if: runner.os == 'Linux' 73 | - uses: actions/checkout@v4 74 | - uses: actions-rust-lang/setup-rust-toolchain@v1 75 | with: 76 | toolchain: ${{ matrix.toolchain }} 77 | - run: cargo test ${{ matrix.features }} 78 | - run: cargo doc ${{ matrix.features }} 79 | 80 | MSRV: 81 | strategy: 82 | fail-fast: false 83 | matrix: 84 | os: 85 | - ubuntu-latest 86 | # - macos-latest # jpeg will break things 87 | toolchain: 88 | - "1.74" 89 | features: 90 | - 91 | name: Test ${{ matrix.toolchain }} on ${{ matrix.os }} (${{ matrix.features }}) 92 | runs-on: ${{ matrix.os }} 93 | steps: 94 | - name: Install libwebp (Linux) 95 | run: | 96 | sudo apt update 97 | sudo apt install libwebp-dev 98 | if: runner.os == 'Linux' 99 | - name: Install libwebp (macOS) 100 | run: | 101 | brew update 102 | brew list webp || brew install webp 103 | if: runner.os == 'macOS' 104 | - name: Install ImageMagick 105 | run: | 106 | wget https://imagemagick.org/archive/ImageMagick.tar.gz 107 | tar xf ImageMagick.tar.gz 108 | cd ImageMagick-* 109 | ./configure --enable-hdri --with-webp 110 | make -j$(nproc) 111 | sudo make install 112 | - run: sudo ldconfig 113 | if: runner.os == 'Linux' 114 | - uses: actions/checkout@v4 115 | - uses: actions-rust-lang/setup-rust-toolchain@v1 116 | with: 117 | toolchain: ${{ matrix.toolchain }} 118 | - run: cargo test --lib --bins ${{ matrix.features }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/intellij+all 2 | 3 | ### Intellij+all ### 4 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 5 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 6 | 7 | # User-specific stuff 8 | .idea/**/workspace.xml 9 | .idea/**/tasks.xml 10 | .idea/**/usage.statistics.xml 11 | .idea/**/dictionaries 12 | .idea/**/shelf 13 | 14 | # Sensitive or high-churn files 15 | .idea/**/dataSources/ 16 | .idea/**/dataSources.ids 17 | .idea/**/dataSources.local.xml 18 | .idea/**/sqlDataSources.xml 19 | .idea/**/dynamic.xml 20 | .idea/**/uiDesigner.xml 21 | .idea/**/dbnavigator.xml 22 | 23 | # Gradle 24 | .idea/**/gradle.xml 25 | .idea/**/libraries 26 | 27 | # Gradle and Maven with auto-import 28 | # When using Gradle or Maven with auto-import, you should exclude module files, 29 | # since they will be recreated, and may cause churn. Uncomment if using 30 | # auto-import. 31 | # .idea/modules.xml 32 | # .idea/*.iml 33 | # .idea/modules 34 | 35 | # CMake 36 | cmake-build-*/ 37 | 38 | # Mongo Explorer plugin 39 | .idea/**/mongoSettings.xml 40 | 41 | # File-based project format 42 | *.iws 43 | 44 | # IntelliJ 45 | out/ 46 | 47 | # mpeltonen/sbt-idea plugin 48 | .idea_modules/ 49 | 50 | # JIRA plugin 51 | atlassian-ide-plugin.xml 52 | 53 | # Cursive Clojure plugin 54 | .idea/replstate.xml 55 | 56 | # Crashlytics plugin (for Android Studio and IntelliJ) 57 | com_crashlytics_export_strings.xml 58 | crashlytics.properties 59 | crashlytics-build.properties 60 | fabric.properties 61 | 62 | # Editor-based Rest Client 63 | .idea/httpRequests 64 | 65 | ### Intellij+all Patch ### 66 | # Ignores the whole .idea folder and all .iml files 67 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 68 | 69 | .idea/ 70 | 71 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 72 | 73 | *.iml 74 | modules.xml 75 | .idea/misc.xml 76 | *.ipr 77 | 78 | 79 | # End of https://www.gitignore.io/api/intellij+all 80 | 81 | 82 | ### Rust ### 83 | # Generated by Cargo 84 | # will have compiled files and executables 85 | /target/ 86 | 87 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 88 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 89 | Cargo.lock 90 | 91 | # These are backup files generated by rustfmt 92 | **/*.rs.bk 93 | 94 | 95 | # End of https://www.gitignore.io/api/rust 96 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "image-resizer" 3 | version = "0.2.18" 4 | authors = ["Magic Len "] 5 | edition = "2021" 6 | rust-version = "1.74" 7 | repository = "https://github.com/magiclen/image-resizer" 8 | homepage = "https://magiclen.org/image-resizer" 9 | keywords = ["image", "resize", "shrink", "sharpen"] 10 | categories = ["command-line-utilities"] 11 | description = "Resize or just shrink images and sharpen them appropriately." 12 | license = "MIT" 13 | include = ["src/**/*", "Cargo.toml", "README.md", "LICENSE"] 14 | 15 | [profile.release] 16 | lto = true 17 | codegen-units = 1 18 | panic = "abort" 19 | strip = true 20 | 21 | [dependencies] 22 | clap = { version = "4", features = ["derive"] } 23 | concat-with = "0.2" 24 | terminal_size = "0.3" 25 | 26 | anyhow = "1" 27 | 28 | image-convert = "0.17" 29 | num_cpus = "1" 30 | scanner-rust = "2" 31 | str-utils = "0.1" 32 | pathdiff = "0.2" 33 | threadpool = "1" 34 | 35 | walkdir = "2" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 magiclen.org (Ron Li) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | EXECUTABLE_NAME := image-resizer 2 | 3 | all: ./target/x86_64-unknown-linux-musl/release/$(EXECUTABLE_NAME) 4 | 5 | ./target/x86_64-unknown-linux-musl/release/$(EXECUTABLE_NAME): $(shell find . -type f -iname '*.rs' -o -name 'Cargo.toml' | grep -v ./target | sed 's/ /\\ /g') 6 | PWD=$$(pwd) 7 | cd $$MAGICK_PATH && bash build.sh 8 | cd $$PWD 9 | IMAGE_MAGICK_INCLUDE_DIRS="$$MAGICK_PATH/linux/include/ImageMagick-7" IMAGE_MAGICK_LIB_DIRS="$$MUSL_PATH/x86_64-linux-musl/lib:$$MUSL_PATH/lib/gcc/x86_64-linux-musl/11.4.0:$$MAGICK_PATH/linux/lib" IMAGE_MAGICK_LIBS=z:bz2:lzma:zstd:jpeg:png:tiff:openjp2:jbig:sharpyuv:webpmux:webpdemux:webp:de265:x265:aom:stdc++:heif:iconv:gcc:xml2:freetype:fontconfig:gomp:MagickWand-7.Q16HDRI:MagickCore-7.Q16HDRI IMAGE_MAGICK_STATIC=1 cargo build --release --target x86_64-unknown-linux-musl 10 | 11 | install: 12 | $(MAKE) 13 | sudo cp ./target/x86_64-unknown-linux-musl/release/$(EXECUTABLE_NAME) /usr/local/bin/$(EXECUTABLE_NAME) 14 | sudo chown root: /usr/local/bin/$(EXECUTABLE_NAME) 15 | sudo chmod 0755 /usr/local/bin/$(EXECUTABLE_NAME) 16 | 17 | test: 18 | cargo test --verbose 19 | 20 | clean: 21 | cargo clean 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Image Resizer 2 | ==================== 3 | 4 | [![CI](https://github.com/magiclen/image-resizer/actions/workflows/ci.yml/badge.svg)](https://github.com/magiclen/image-resizer/actions/workflows/ci.yml) 5 | 6 | Resize or just shrink images and sharpen them appropriately. 7 | 8 | ## Help 9 | 10 | ``` 11 | EXAMPLES: 12 | image-resizer /path/to/image -m 1920 # Make /path/to/image resized 13 | image-resizer /path/to/folder -m 1920 # Make images inside /path/to/folder and make resized 14 | image-resizer /path/to/image -o /path/to/image2 -m 1920 # Make /path/to/image resized, and save it to /path/to/image2 15 | image-resizer /path/to/folder -o /path/to/folder2 -m 1920 # Make images inside /path/to/folder resized, and save them to /path/to/folder2 16 | image-resizer /path/to/folder -o /path/to/folder2 -f -m 1920 # Make images inside /path/to/folder resized, and save them to /path/to/folder2 without overwriting checks 17 | image-resizer /path/to/folder --allow-gif -r -m 1920 # Make images inside /path/to/folder including GIF resized and also remain their profiles 18 | image-resizer /path/to/image -m 1920 --shrink # Make /path/to/image shrunk if it needs to be 19 | image-resizer /path/to/image -m 1920 -q 75 # Make /path/to/image resized with a quality of 75 if it uses lossy compression 20 | image-resizer /path/to/image -m 1920 --4:2:0 # Make /path/to/image resized and output using 4:2:0 (chroma quartered) subsampling to reduce the file size 21 | image-resizer /path/to/image -m 1920 --no-sharpen # Make /path/to/image resized without auto sharpening 22 | image-resizer /path/to/image -m 1920 --ppi 150 # Make /path/to/image resized, and set their PPI to 150 23 | 24 | Usage: image-resizer [OPTIONS] --side-maximum 25 | 26 | Arguments: 27 | Assign an image or a directory for image resizing. It should be a path of a file or a directory 28 | 29 | Options: 30 | -o, --output-path Assign a destination of your generated files. It should be a path of a directory or a file depending on your input path [aliases: output] 31 | -s, --single-thread Use only one thread 32 | -f, --force Force to overwrite files 33 | --allow-gif Allow to do GIF interlacing 34 | -r, --remain-profile Remain the profiles of all images 35 | -m, --side-maximum Set the maximum pixels of each side of an image (Aspect ratio will be preserved) [aliases: max] 36 | --only-shrink Only shrink images, not enlarge them [aliases: shrink] 37 | --no-sharpen Disable automatically sharpening 38 | -q, --quality Set the quality for lossy compression [default: 92] 39 | --ppi Set pixels per inch (ppi) 40 | --chroma-quartered Use 4:2:0 (chroma quartered) subsampling to reduce the file size if it is supported [aliases: 4:2:0] 41 | -h, --help Print help 42 | -V, --version Print version 43 | ``` 44 | 45 | ## License 46 | 47 | [MIT](LICENSE) -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # array_width = 60 2 | # attr_fn_like_width = 70 3 | binop_separator = "Front" 4 | blank_lines_lower_bound = 0 5 | blank_lines_upper_bound = 1 6 | brace_style = "PreferSameLine" 7 | # chain_width = 60 8 | color = "Auto" 9 | # comment_width = 100 10 | condense_wildcard_suffixes = true 11 | control_brace_style = "AlwaysSameLine" 12 | empty_item_single_line = true 13 | enum_discrim_align_threshold = 80 14 | error_on_line_overflow = false 15 | error_on_unformatted = false 16 | # fn_call_width = 60 17 | fn_params_layout = "Tall" 18 | fn_single_line = false 19 | force_explicit_abi = true 20 | force_multiline_blocks = false 21 | format_code_in_doc_comments = true 22 | doc_comment_code_block_width = 80 23 | format_generated_files = true 24 | format_macro_matchers = true 25 | format_macro_bodies = true 26 | skip_macro_invocations = [] 27 | format_strings = true 28 | hard_tabs = false 29 | hex_literal_case = "Upper" 30 | imports_indent = "Block" 31 | imports_layout = "Mixed" 32 | indent_style = "Block" 33 | inline_attribute_width = 0 34 | match_arm_blocks = true 35 | match_arm_leading_pipes = "Never" 36 | match_block_trailing_comma = true 37 | max_width = 100 38 | merge_derives = true 39 | imports_granularity = "Crate" 40 | newline_style = "Unix" 41 | normalize_comments = false 42 | normalize_doc_attributes = true 43 | overflow_delimited_expr = true 44 | remove_nested_parens = true 45 | reorder_impl_items = true 46 | reorder_imports = true 47 | group_imports = "StdExternalCrate" 48 | reorder_modules = true 49 | short_array_element_width_threshold = 10 50 | # single_line_if_else_max_width = 50 51 | space_after_colon = true 52 | space_before_colon = false 53 | spaces_around_ranges = false 54 | struct_field_align_threshold = 80 55 | struct_lit_single_line = false 56 | # struct_lit_width = 18 57 | # struct_variant_width = 35 58 | tab_spaces = 4 59 | trailing_comma = "Vertical" 60 | trailing_semicolon = true 61 | type_punctuation_density = "Wide" 62 | use_field_init_shorthand = true 63 | use_small_heuristics = "Max" 64 | use_try_shorthand = true 65 | where_single_line = false 66 | wrap_comments = false -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::{CommandFactory, FromArgMatches, Parser}; 4 | use concat_with::concat_line; 5 | use terminal_size::terminal_size; 6 | 7 | const APP_NAME: &str = "Image Resizer"; 8 | const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); 9 | const CARGO_PKG_AUTHORS: &str = env!("CARGO_PKG_AUTHORS"); 10 | 11 | const AFTER_HELP: &str = "Enjoy it! https://magiclen.org"; 12 | 13 | const APP_ABOUT: &str = concat!( 14 | "It helps you interlace an image or multiple images for web-page usage.\n\nEXAMPLES:\n", 15 | concat_line!(prefix "image-resizer ", 16 | "/path/to/image -m 1920 # Make /path/to/image resized", 17 | "/path/to/folder -m 1920 # Make images inside /path/to/folder and make resized", 18 | "/path/to/image -o /path/to/image2 -m 1920 # Make /path/to/image resized, and save it to /path/to/image2", 19 | "/path/to/folder -o /path/to/folder2 -m 1920 # Make images inside /path/to/folder resized, and save them to /path/to/folder2", 20 | "/path/to/folder -o /path/to/folder2 -f -m 1920 # Make images inside /path/to/folder resized, and save them to /path/to/folder2 without overwriting checks", 21 | "/path/to/folder --allow-gif -r -m 1920 # Make images inside /path/to/folder including GIF resized and also remain their profiles", 22 | "/path/to/image -m 1920 --shrink # Make /path/to/image shrunk if it needs to be", 23 | "/path/to/image -m 1920 -q 75 # Make /path/to/image resized with a quality of 75 if it uses lossy compression", 24 | "/path/to/image -m 1920 --4:2:0 # Make /path/to/image resized and output using 4:2:0 (chroma quartered) subsampling to reduce the file size", 25 | "/path/to/image -m 1920 --no-sharpen # Make /path/to/image resized without auto sharpening", 26 | "/path/to/image -m 1920 --ppi 150 # Make /path/to/image resized, and set their PPI to 150", 27 | ) 28 | ); 29 | 30 | #[derive(Debug, Parser)] 31 | #[command(name = APP_NAME)] 32 | #[command(term_width = terminal_size().map(|(width, _)| width.0 as usize).unwrap_or(0))] 33 | #[command(version = CARGO_PKG_VERSION)] 34 | #[command(author = CARGO_PKG_AUTHORS)] 35 | #[command(after_help = AFTER_HELP)] 36 | pub struct CLIArgs { 37 | #[arg(value_hint = clap::ValueHint::AnyPath)] 38 | #[arg(help = "Assign an image or a directory for image resizing. It should be a path of a \ 39 | file or a directory")] 40 | pub input_path: PathBuf, 41 | #[arg(short, long, visible_alias = "output")] 42 | #[arg(value_hint = clap::ValueHint::AnyPath)] 43 | #[arg(help = "Assign a destination of your generated files. It should be a path of a \ 44 | directory or a file depending on your input path")] 45 | pub output_path: Option, 46 | #[arg(short, long)] 47 | #[arg(help = "Use only one thread")] 48 | pub single_thread: bool, 49 | #[arg(short, long)] 50 | #[arg(help = "Force to overwrite files")] 51 | pub force: bool, 52 | #[arg(long)] 53 | #[arg(help = "Allow to do GIF interlacing")] 54 | pub allow_gif: bool, 55 | #[arg(short, long)] 56 | #[arg(help = "Remain the profiles of all images")] 57 | pub remain_profile: bool, 58 | #[arg(short = 'm', long, visible_alias = "max")] 59 | #[arg( 60 | help = "Set the maximum pixels of each side of an image (Aspect ratio will be preserved)" 61 | )] 62 | pub side_maximum: u16, 63 | #[arg(long, visible_alias = "shrink")] 64 | #[arg(help = "Only shrink images, not enlarge them")] 65 | pub only_shrink: bool, 66 | #[arg(long)] 67 | #[arg(help = "Disable automatically sharpening")] 68 | pub no_sharpen: bool, 69 | #[arg(short, long)] 70 | #[arg(default_value = "92")] 71 | #[arg(value_parser = clap::value_parser!(u8).range(0..=100))] 72 | #[arg(help = "Set the quality for lossy compression")] 73 | pub quality: u8, 74 | #[arg(long)] 75 | #[arg(value_parser = parse_ppi)] 76 | #[arg(help = "Set pixels per inch (ppi)")] 77 | pub ppi: Option, 78 | #[arg(long, visible_alias = "4:2:0")] 79 | #[arg(help = "Use 4:2:0 (chroma quartered) subsampling to reduce the file size if it is \ 80 | supported")] 81 | pub chroma_quartered: bool, 82 | } 83 | 84 | fn parse_ppi(arg: &str) -> Result { 85 | let ppi = arg.parse::().map_err(|err| err.to_string())?; 86 | 87 | if ppi <= 0f64 { 88 | return Err("PPI must be bigger than 0".into()); 89 | } 90 | 91 | Ok(ppi) 92 | } 93 | 94 | pub fn get_args() -> CLIArgs { 95 | let args = CLIArgs::command(); 96 | 97 | let about = format!("{APP_NAME} {CARGO_PKG_VERSION}\n{CARGO_PKG_AUTHORS}\n{APP_ABOUT}"); 98 | 99 | let args = args.about(about); 100 | 101 | let matches = args.get_matches(); 102 | 103 | match CLIArgs::from_arg_matches(&matches) { 104 | Ok(args) => args, 105 | Err(err) => { 106 | err.exit(); 107 | }, 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | # Image Resizer 3 | 4 | Resize or just shrink images and sharpen them appropriately. 5 | */ 6 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | extern crate core; 2 | 3 | mod cli; 4 | 5 | use std::{ 6 | fs, io, 7 | io::Write, 8 | path::Path, 9 | sync::{Arc, Mutex}, 10 | }; 11 | 12 | use anyhow::{anyhow, Context}; 13 | use cli::*; 14 | use scanner_rust::{generic_array::typenum::U8, Scanner}; 15 | use str_utils::EqIgnoreAsciiCaseMultiple; 16 | use threadpool::ThreadPool; 17 | use walkdir::WalkDir; 18 | 19 | fn main() -> anyhow::Result<()> { 20 | let args = get_args(); 21 | 22 | let is_dir = 23 | args.input_path.metadata().with_context(|| anyhow!("{:?}", args.input_path))?.is_dir(); 24 | 25 | if let Some(output_path) = args.output_path.as_deref() { 26 | if is_dir { 27 | match output_path.metadata() { 28 | Ok(metadata) => { 29 | if !metadata.is_dir() { 30 | return Err(anyhow!("{output_path:?} is not a directory.",)); 31 | } 32 | }, 33 | Err(error) if error.kind() == io::ErrorKind::NotFound => { 34 | fs::create_dir_all(output_path) 35 | .with_context(|| anyhow!("{:?}", output_path))?; 36 | }, 37 | Err(error) => { 38 | return Err(error).with_context(|| anyhow!("{:?}", output_path)); 39 | }, 40 | } 41 | } else if output_path.is_dir() { 42 | return Err(anyhow!("{output_path:?} is a directory.")); 43 | } 44 | } 45 | 46 | let sc: Arc>> = Arc::new(Mutex::new(Scanner::new2(io::stdin()))); 47 | let overwriting: Arc> = Arc::new(Mutex::new(0)); 48 | 49 | if is_dir { 50 | let mut image_paths = Vec::new(); 51 | 52 | for dir_entry in WalkDir::new(args.input_path.as_path()).into_iter().filter_map(|e| e.ok()) 53 | { 54 | if !dir_entry.metadata()?.is_file() { 55 | continue; 56 | } 57 | 58 | let p = dir_entry.into_path(); 59 | 60 | if let Some(extension) = p.extension() { 61 | if let Some(extension) = extension.to_str() { 62 | let mut allow_extensions = vec!["jpg", "jpeg", "png"]; 63 | 64 | if args.allow_gif { 65 | allow_extensions.push("gif"); 66 | } 67 | 68 | if extension 69 | .eq_ignore_ascii_case_with_lowercase_multiple(&allow_extensions) 70 | .is_some() 71 | { 72 | image_paths.push(p); 73 | } 74 | } 75 | } 76 | } 77 | 78 | if args.single_thread { 79 | for image_path in image_paths { 80 | let output_path = match args.output_path.as_ref() { 81 | Some(output_path) => { 82 | let p = 83 | pathdiff::diff_paths(&image_path, args.input_path.as_path()).unwrap(); 84 | 85 | let output_path = output_path.join(p); 86 | 87 | Some(output_path) 88 | }, 89 | None => None, 90 | }; 91 | 92 | resizing( 93 | args.allow_gif, 94 | args.remain_profile, 95 | args.force, 96 | args.side_maximum, 97 | args.only_shrink, 98 | !args.no_sharpen, 99 | args.quality, 100 | args.ppi, 101 | args.chroma_quartered, 102 | &sc, 103 | &overwriting, 104 | image_path.as_path(), 105 | output_path.as_deref(), 106 | )?; 107 | } 108 | } else { 109 | let cpus = num_cpus::get(); 110 | 111 | let pool = ThreadPool::new(cpus * 2); 112 | 113 | for image_path in image_paths { 114 | let sc = sc.clone(); 115 | let overwriting = overwriting.clone(); 116 | let output_path = match args.output_path.as_ref() { 117 | Some(output_path) => { 118 | let p = 119 | pathdiff::diff_paths(&image_path, args.input_path.as_path()).unwrap(); 120 | 121 | let output_path = output_path.join(p); 122 | 123 | Some(output_path) 124 | }, 125 | None => None, 126 | }; 127 | 128 | pool.execute(move || { 129 | if let Err(error) = resizing( 130 | args.allow_gif, 131 | args.remain_profile, 132 | args.force, 133 | args.side_maximum, 134 | args.only_shrink, 135 | !args.no_sharpen, 136 | args.quality, 137 | args.ppi, 138 | args.chroma_quartered, 139 | &sc, 140 | &overwriting, 141 | image_path.as_path(), 142 | output_path.as_deref(), 143 | ) { 144 | eprintln!("{error:?}"); 145 | io::stderr().flush().unwrap(); 146 | } 147 | }); 148 | } 149 | 150 | pool.join(); 151 | } 152 | } else { 153 | resizing( 154 | args.allow_gif, 155 | args.remain_profile, 156 | args.force, 157 | args.side_maximum, 158 | args.only_shrink, 159 | !args.no_sharpen, 160 | args.quality, 161 | args.ppi, 162 | args.chroma_quartered, 163 | &sc, 164 | &overwriting, 165 | args.input_path, 166 | args.output_path, 167 | )?; 168 | } 169 | 170 | Ok(()) 171 | } 172 | 173 | #[allow(clippy::too_many_arguments)] 174 | fn resizing, OP: AsRef>( 175 | allow_gif: bool, 176 | remain_profile: bool, 177 | force: bool, 178 | side_maximum: u16, 179 | only_shrink: bool, 180 | sharpen: bool, 181 | quality: u8, 182 | ppi: Option, 183 | force_to_chroma_quartered: bool, 184 | sc: &Arc>>, 185 | overwriting: &Arc>, 186 | input_path: IP, 187 | output_path: Option, 188 | ) -> anyhow::Result<()> { 189 | let input_path = input_path.as_ref(); 190 | let output_path = output_path.as_ref().map(|p| p.as_ref()); 191 | 192 | let input_image_resource = image_convert::ImageResource::from_path(input_path); 193 | 194 | let input_identify = image_convert::identify_ping(&input_image_resource) 195 | .with_context(|| anyhow!("{input_path:?}"))?; 196 | 197 | match input_identify.format.as_str() { 198 | "JPEG" => { 199 | if let Some(output_path) = 200 | get_output_path(force, sc, overwriting, input_path, output_path)? 201 | { 202 | let mut config = image_convert::JPGConfig::new(); 203 | 204 | config.remain_profile = remain_profile; 205 | config.width = side_maximum; 206 | config.height = side_maximum; 207 | config.shrink_only = only_shrink; 208 | 209 | if !sharpen { 210 | config.sharpen = 0f64; 211 | } 212 | 213 | config.quality = quality; 214 | 215 | if let Some(ppi) = ppi { 216 | config.ppi = Some((ppi, ppi)); 217 | } 218 | 219 | config.force_to_chroma_quartered = force_to_chroma_quartered; 220 | 221 | let mut output = image_convert::ImageResource::from_path(output_path); 222 | 223 | image_convert::to_jpg(&mut output, &input_image_resource, &config) 224 | .with_context(|| anyhow!("to_jpg {output_path:?}"))?; 225 | 226 | print_resized_message(output_path)?; 227 | } 228 | }, 229 | "PNG" => { 230 | if let Some(output_path) = 231 | get_output_path(force, sc, overwriting, input_path, output_path)? 232 | { 233 | let mut config = image_convert::PNGConfig::new(); 234 | 235 | config.remain_profile = remain_profile; 236 | config.width = side_maximum; 237 | config.height = side_maximum; 238 | config.shrink_only = only_shrink; 239 | 240 | if !sharpen { 241 | config.sharpen = 0f64; 242 | } 243 | 244 | if let Some(ppi) = ppi { 245 | config.ppi = Some((ppi, ppi)); 246 | } 247 | 248 | let mut output = image_convert::ImageResource::from_path(output_path); 249 | 250 | image_convert::to_png(&mut output, &input_image_resource, &config) 251 | .with_context(|| anyhow!("to_png {output_path:?}"))?; 252 | 253 | print_resized_message(output_path)?; 254 | } 255 | }, 256 | "TIFF" => { 257 | if let Some(output_path) = 258 | get_output_path(force, sc, overwriting, input_path, output_path)? 259 | { 260 | let mut config = image_convert::TIFFConfig::new(); 261 | 262 | config.remain_profile = remain_profile; 263 | config.width = side_maximum; 264 | config.height = side_maximum; 265 | config.shrink_only = only_shrink; 266 | 267 | if !sharpen { 268 | config.sharpen = 0f64; 269 | } 270 | 271 | if let Some(ppi) = ppi { 272 | config.ppi = Some((ppi, ppi)); 273 | } 274 | 275 | let mut output = image_convert::ImageResource::from_path(output_path); 276 | 277 | image_convert::to_tiff(&mut output, &input_image_resource, &config) 278 | .with_context(|| anyhow!("to_tiff {output_path:?}"))?; 279 | 280 | print_resized_message(output_path)?; 281 | } 282 | }, 283 | "WEBP" => { 284 | if let Some(output_path) = 285 | get_output_path(force, sc, overwriting, input_path, output_path)? 286 | { 287 | let mut config = image_convert::WEBPConfig::new(); 288 | 289 | config.remain_profile = remain_profile; 290 | config.width = side_maximum; 291 | config.height = side_maximum; 292 | config.shrink_only = only_shrink; 293 | 294 | if !sharpen { 295 | config.sharpen = 0f64; 296 | } 297 | 298 | config.quality = quality; 299 | 300 | let mut output = image_convert::ImageResource::from_path(output_path); 301 | 302 | image_convert::to_webp(&mut output, &input_image_resource, &config) 303 | .with_context(|| anyhow!("to_webp {output_path:?}"))?; 304 | 305 | print_resized_message(output_path)?; 306 | } 307 | }, 308 | "PGM" => { 309 | if let Some(output_path) = 310 | get_output_path(force, sc, overwriting, input_path, output_path)? 311 | { 312 | let mut config = image_convert::PGMConfig::new(); 313 | 314 | config.remain_profile = remain_profile; 315 | config.width = side_maximum; 316 | config.height = side_maximum; 317 | config.shrink_only = only_shrink; 318 | 319 | if !sharpen { 320 | config.sharpen = 0f64; 321 | } 322 | 323 | let mut output = image_convert::ImageResource::from_path(output_path); 324 | 325 | image_convert::to_pgm(&mut output, &input_image_resource, &config) 326 | .with_context(|| anyhow!("to_pgm {output_path:?}"))?; 327 | 328 | print_resized_message(output_path)?; 329 | } 330 | }, 331 | "GIF" => { 332 | if allow_gif { 333 | if let Some(output_path) = 334 | get_output_path(force, sc, overwriting, input_path, output_path)? 335 | { 336 | let mut config = image_convert::GIFConfig::new(); 337 | 338 | config.remain_profile = remain_profile; 339 | config.width = side_maximum; 340 | config.height = side_maximum; 341 | config.shrink_only = only_shrink; 342 | 343 | if !sharpen { 344 | config.sharpen = 0f64; 345 | } 346 | 347 | let mut output = image_convert::ImageResource::from_path(output_path); 348 | 349 | image_convert::to_gif(&mut output, &input_image_resource, &config) 350 | .with_context(|| anyhow!("to_gif {output_path:?}"))?; 351 | 352 | print_resized_message(output_path)?; 353 | } 354 | } 355 | }, 356 | _ => (), 357 | } 358 | 359 | Ok(()) 360 | } 361 | 362 | fn get_output_path<'a>( 363 | force: bool, 364 | sc: &Arc>>, 365 | overwriting: &Arc>, 366 | input_path: &'a Path, 367 | output_path: Option<&'a Path>, 368 | ) -> anyhow::Result> { 369 | match output_path { 370 | Some(output_path) => { 371 | if output_path.exists() { 372 | if !force { 373 | let mutex_guard = overwriting.lock().unwrap(); 374 | 375 | loop { 376 | print!("{output_path:?} exists, do you want to overwrite it? [Y/N] ",); 377 | io::stdout().flush().with_context(|| anyhow!("stdout"))?; 378 | 379 | match sc.lock().unwrap().next_line().with_context(|| anyhow!("stdout"))? { 380 | Some(token) => match token.to_ascii_uppercase().as_str() { 381 | "Y" => { 382 | break; 383 | }, 384 | "N" => { 385 | return Ok(None); 386 | }, 387 | _ => { 388 | continue; 389 | }, 390 | }, 391 | None => { 392 | return Ok(None); 393 | }, 394 | } 395 | } 396 | 397 | drop(mutex_guard); 398 | } 399 | } else { 400 | let dir_path = output_path.parent().unwrap(); 401 | 402 | fs::create_dir_all(dir_path).with_context(|| anyhow!("{dir_path:?}"))?; 403 | } 404 | 405 | Ok(Some(output_path)) 406 | }, 407 | None => Ok(Some(input_path)), 408 | } 409 | } 410 | 411 | #[inline] 412 | fn print_resized_message>(path: P) -> anyhow::Result<()> { 413 | println!("{:?} has been resized.", path.as_ref().canonicalize().unwrap()); 414 | io::stdout().flush()?; 415 | 416 | Ok(()) 417 | } 418 | --------------------------------------------------------------------------------