From ff31e0488b04d18da8314a5f11ccc72594cdfd4b Mon Sep 17 00:00:00 2001 From: okaneco <47607823+okaneco@users.noreply.github.com> Date: Wed, 12 May 2021 19:54:34 -0400 Subject: [PATCH] Initial commit --- .github/workflows/rust-cd.yml | 105 +++++++++++++++++++++++++++++ .github/workflows/rust-ci.yml | 56 ++++++++++++++++ .gitignore | 2 + Cargo.toml | 5 ++ LICENSE-MIT | 7 ++ README.md | 14 ++++ src/lib.rs | 64 ++++++++++++++++++ src/main.rs | 120 ++++++++++++++++++++++++++++++++++ 8 files changed, 373 insertions(+) create mode 100644 .github/workflows/rust-cd.yml create mode 100644 .github/workflows/rust-ci.yml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE-MIT create mode 100644 README.md create mode 100644 src/lib.rs create mode 100644 src/main.rs diff --git a/.github/workflows/rust-cd.yml b/.github/workflows/rust-cd.yml new file mode 100644 index 0000000..e5cbcf0 --- /dev/null +++ b/.github/workflows/rust-cd.yml @@ -0,0 +1,105 @@ +name: Rust CD + +on: + push: + tags: + - "*.*.*" + +jobs: + publish-binary: + runs-on: ${{ matrix.platform.os }} + strategy: + matrix: + rust: [ stable ] + platform: + - os: macos-latest + os-name: macos + target: x86_64-apple-darwin + architecture: x86_64 + binary-postfix: "" + use-cross: false + - os: ubuntu-latest + os-name: linux + target: x86_64-unknown-linux-gnu + architecture: x86_64 + binary-postfix: "" + use-cross: false + - os: windows-latest + os-name: windows + target: x86_64-pc-windows-msvc + architecture: x86_64 + binary-postfix: ".exe" + use-cross: false + - os: ubuntu-latest + os-name: linux + target: aarch64-unknown-linux-gnu + architecture: arm64 + binary-postfix: "" + use-cross: true + - os: ubuntu-latest + os-name: linux + target: i686-unknown-linux-gnu + architecture: i686 + binary-postfix: "" + use-cross: true + + steps: + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ matrix.rust }} + profile: minimal + override: true + + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Cargo build + uses: actions-rs/cargo@v1 + with: + command: build + use-cross: ${{ matrix.platform.use-cross }} + toolchain: ${{ matrix.rust }} + args: --release --target ${{ matrix.platform.target }} + + - name: Install strip command + if: ${{ matrix.platform.target == 'aarch64-unknown-linux-gnu' }} + shell: bash + run: | + sudo apt update + sudo apt-get install -y binutils-aarch64-linux-gnu + + - name: Package final binary + shell: bash + run: | + cd target/${{ matrix.platform.target }}/release + ####### reduce binary size by removing debug symbols ####### + BINARY_NAME=jpegscans${{ matrix.platform.binary-postfix }} + if [[ ${{ matrix.platform.target }} == aarch64-unknown-linux-gnu ]]; then + GCC_PREFIX="aarch64-linux-gnu-" + else + GCC_PREFIX="" + fi + + if [[ ${{ matrix.platform.target }} != x86_64-pc-windows-msvc ]]; then + "$GCC_PREFIX"strip $BINARY_NAME + fi + + ########## create tar.gz ########## + RELEASE_NAME=jpegscans-${GITHUB_REF/refs\/tags\//}-${{ matrix.platform.os-name }}-${{ matrix.platform.architecture }} + tar czvf $RELEASE_NAME.tar.gz $BINARY_NAME + ########## create sha256 ########## + if [[ ${{ runner.os }} == 'Windows' ]]; then + certutil -hashfile $RELEASE_NAME.tar.gz sha256 | grep -E [A-Fa-f0-9]{64} > $RELEASE_NAME.sha256 + else + shasum -a 256 $RELEASE_NAME.tar.gz > $RELEASE_NAME.sha256 + fi + + - name: Releasing assets + uses: softprops/action-gh-release@v1 + with: + files: | + target/${{ matrix.platform.target }}/release/jpegscans-*.tar.gz + target/${{ matrix.platform.target }}/release/jpegscans-*.sha256 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml new file mode 100644 index 0000000..93be906 --- /dev/null +++ b/.github/workflows/rust-ci.yml @@ -0,0 +1,56 @@ +name: Rust CI + +on: + push: + branches: master + pull_request: + branches: master + schedule: + - cron: "0 0 1 * *" # monthly + workflow_dispatch: # allow manual triggering of the action + +env: + RUSTFLAGS: "-Dwarnings" + +jobs: + build-crate: + name: Build and test crate/docs + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + toolchain: [nightly, beta, stable] + include: + - os: macos-latest + toolchain: stable + steps: + - uses: actions/checkout@v2 + - uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: ${{ matrix.toolchain }} + components: rust-docs + override: true + - name: Build library + run: cargo build -v --lib --no-default-features + - name: Build binary + run: cargo build -v --bins + + clippy-rustfmt: + name: Clippy and rustfmt + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v2 + - uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + components: clippy, rustfmt + override: true + - name: clippy + run: cargo clippy + continue-on-error: true + - name: rustfmt + run: cargo fmt -- --check + continue-on-error: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e264cac --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/*.jpg diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f454f53 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "jpegscans" +version = "0.1.0" +authors = ["okaneco <47607823+okaneco@users.noreply.github.com>"] +edition = "2018" diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..259f501 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,7 @@ +Copyright 2021 Collyn O'Kane + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9811059 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# jpegscans + +Reads a progressive JPEG image file and produces the successive scan images from +that file. + +## Usage + +```bash +jpegscans [input] [output filename prefix] +``` + +The first argument is the `input` filename. The second argument is an optional +prefix for the scan files produced from the `input`. The default output prefix +is `scan`. diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9e8e667 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,64 @@ +//! Crate for producing images of the progressive scan passes found in JPEG +//! image files. +use std::io::{Read, Seek}; + +/// Jpeg header magic bytes. +pub const JPEG_MAGIC_BYTES: [u8; 3] = [0xFF, 0xD8, 0xFF]; +/// Byte padding. +pub const PADDING: u8 = 0x00; +/// Temporary marker. +pub const TEM: u8 = 0x01; +/// End of image marker. +pub const EOI: u8 = 0xD9; +/// Start of scan marker. +pub const SOS: u8 = 0xDA; +/// Fill bytes (markers may be preceded by any number of these). +pub const FILL: u8 = 0xFF; + +/// Consume all bytes in the current marker section. +pub fn consume_marker_section(r: &mut R) -> Result<(), std::io::Error> { + let mut buf = [0, 0]; + r.read_exact(&mut buf)?; + + // Length of marker section includes length bytes, so we subtract 2 bytes + let marker_length = u16::from_be_bytes(buf).saturating_sub(2); + + // Consume the section's bytes + for _ in 0..marker_length { + r.read_exact(&mut buf[..1])?; + } + + Ok(()) +} + +/// Consume the bytes in an [SOS] marker section. +pub fn consume_sos_section(r: &mut R) -> Result<(), std::io::Error> { + loop { + match find_next_marker(r)? { + // Ignore Restart markers, temporary markers, and padding bytes + PADDING | TEM | 0xD0..=0xD7 => {} + // Rewind the cursor to process the next marker + _ => { + r.seek(std::io::SeekFrom::Current(-2))?; + return Ok(()); + } + } + } +} + +/// Consume bytes until the next marker is found. +pub fn find_next_marker(r: &mut R) -> Result { + let mut buf = [0]; + + // Find next fill byte + while buf != [FILL] { + r.read_exact(&mut buf)?; + } + + // Consume all of the fill bytes that precede the marker byte + while buf == [FILL] { + r.read_exact(&mut buf)?; + } + + Ok(buf[0]) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ddc4edd --- /dev/null +++ b/src/main.rs @@ -0,0 +1,120 @@ +use jpegscans::{ + consume_marker_section, consume_sos_section, find_next_marker, EOI, FILL, JPEG_MAGIC_BYTES, SOS, +}; + +const HELP_MESSAGE: &str = "jpegscans +Produce scan images from a progressive JPEG file. + +USAGE: + jpegscans [input] [output filename prefix]"; + +fn main() { + if let Err(e) = try_main() { + eprintln!("{}", e); + std::process::exit(1); + } +} + +fn try_main() -> Result<(), ScanError> { + let mut args = std::env::args().skip(1); + + let input = match args.next() { + Some(s) => { + if matches!(s.as_str(), "-h" | "--help") { + println!("{}", HELP_MESSAGE); + return Ok(()); + } + s + } + None => { + println!("{}", HELP_MESSAGE); + return Ok(()); + } + }; + let output = args.next().map_or_else(|| "scan".into(), |s| s); + + let file = std::fs::read(input)?; + let mut cursor = std::io::Cursor::new(&file); + + let mut buf = [0; 3]; + + // Return an error if the file header doesn't match JPEG bytes + std::io::Read::read_exact(&mut cursor, &mut buf)?; + if buf != JPEG_MAGIC_BYTES { + return Err(ScanError::InvalidFile); + } + + let mut scan_count = 0u16; + for _ in 0..u32::MAX { + match find_next_marker(&mut cursor)? { + SOS => { + consume_sos_section(&mut cursor)?; + + let scan = std::fs::File::create(format!("{}_{:05}.jpg", output, scan_count))?; + let mut w = std::io::BufWriter::new(scan); + + let stream_position = + std::convert::TryFrom::try_from(std::io::Seek::stream_position(&mut cursor)?)?; + + // Write all data up to the current stream position, followed by an EOI marker + std::io::Write::write_all(&mut w, &file[..stream_position])?; + std::io::Write::write_all(&mut w, &[FILL, EOI])?; + + scan_count = scan_count + .checked_add(1) + .ok_or(ScanError::ScanCounterOverflow)?; + } + EOI => break, + _ => consume_marker_section(&mut cursor)?, + } + } + + Ok(()) +} + +/// Error for processing progressive scan images. +#[derive(Debug)] +pub enum ScanError { + /// An error occurred during conversion of the stream position to a `usize`. + Convert(core::num::TryFromIntError), + /// The supplied file isn't a JPEG. + InvalidFile, + /// An error occurred while opening a file, writing to a file, or reading + /// from a cursor/file. + Io(std::io::Error), + /// The scan number counter overflowed. + ScanCounterOverflow, +} + +impl std::fmt::Display for ScanError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Convert(err) => write!(f, "{}", err), + Self::InvalidFile => write!(f, "Input file must be a JPEG"), + Self::Io(err) => write!(f, "{}", err), + Self::ScanCounterOverflow => write!(f, "Scan counter overflow"), + } + } +} + +impl std::error::Error for ScanError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Convert(e) => Some(e), + Self::Io(e) => Some(e), + Self::InvalidFile | Self::ScanCounterOverflow => None, + } + } +} + +impl std::convert::From for ScanError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl std::convert::From for ScanError { + fn from(error: std::num::TryFromIntError) -> Self { + Self::Convert(error) + } +}