diff --git a/.github/workflows/rust-publish.yml b/.github/workflows/rust-publish.yml new file mode 100644 index 0000000..861f9b7 --- /dev/null +++ b/.github/workflows/rust-publish.yml @@ -0,0 +1,23 @@ +name: Cargo Publish +on: + push: + tags: + - "rust-*" + workflow_dispatch: +jobs: + publish: + name: Publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + - name: Change Working Directory + run: cd icns-rs + - name: Publish + run: cargo publish --token ${CRATES_TOKEN} + env: + CRATES_IO_TOKEN: ${{ secrets.CRATES_TOKEN }} diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml new file mode 100644 index 0000000..ea5fc55 --- /dev/null +++ b/.github/workflows/rust-test.yml @@ -0,0 +1,63 @@ +name: Rust CI +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +on: + pull_request: + paths: + - icns-rs/** + push: null + workflow_dispatch: null +jobs: + tests: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + - name: Change Working Directory + run: cd icns-rs + - name: Run tests + uses: actions-rs/cargo@v1] + with: + command: test + rustfmt: + name: Rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + components: rustfmt + - name: Change Working Directory + run: cd icns-rs + - name: Run rustfmt + uses: actions-rs/cargo@v1 + with: + command: fmt + args: "-- --check" + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + components: clippy + - name: Change Working Directory + run: cd icns-rs + - name: Run clippy + uses: actions-rs/cargo@v1 + with: + command: clippy + args: "-- -D warnings" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..144f824 --- /dev/null +++ b/LICENSE @@ -0,0 +1,157 @@ +# GNU LESSER GENERAL PUBLIC LICENSE + +Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +This version of the GNU Lesser General Public License incorporates the +terms and conditions of version 3 of the GNU General Public License, +supplemented by the additional permissions listed below. + +## 0. Additional Definitions. + +As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the +GNU General Public License. + +"The Library" refers to a covered work governed by this License, other +than an Application or a Combined Work as defined below. + +An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + +A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + +The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + +The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + +## 1. Exception to Section 3 of the GNU GPL. + +You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + +## 2. Conveying Modified Versions. + +If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + +- a) under this License, provided that you make a good faith effort + to ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or +- b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + +## 3. Object Code Incorporating Material from Library Header Files. + +The object code form of an Application may incorporate material from a +header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + +- a) Give prominent notice with each copy of the object code that + the Library is used in it and that the Library and its use are + covered by this License. +- b) Accompany the object code with a copy of the GNU GPL and this + license document. + +## 4. Combined Works. + +You may convey a Combined Work under terms of your choice that, taken +together, effectively do not restrict modification of the portions of +the Library contained in the Combined Work and reverse engineering for +debugging such modifications, if you also do each of the following: + +- a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. +- b) Accompany the Combined Work with a copy of the GNU GPL and this + license document. +- c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. +- d) Do one of the following: + - 0) Convey the Minimal Corresponding Source under the terms of + this License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + - 1) Use a suitable shared library mechanism for linking with + the Library. A suitable mechanism is one that (a) uses at run + time a copy of the Library already present on the user's + computer system, and (b) will operate properly with a modified + version of the Library that is interface-compatible with the + Linked Version. +- e) Provide Installation Information, but only if you would + otherwise be required to provide such information under section 6 + of the GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the Application + with a modified version of the Linked Version. (If you use option + 4d0, the Installation Information must accompany the Minimal + Corresponding Source and Corresponding Application Code. If you + use option 4d1, you must provide the Installation Information in + the manner specified by section 6 of the GNU GPL for conveying + Corresponding Source.) + +## 5. Combined Libraries. + +You may place library facilities that are a work based on the Library +side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + +- a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities, conveyed under the terms of this License. +- b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + +## 6. Revised Versions of the GNU Lesser General Public License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +as you received it specifies that a certain numbered version of the +GNU Lesser General Public License "or any later version" applies to +it, you have the option of following the terms and conditions either +of that published version or of any later version published by the +Free Software Foundation. If the Library as you received it does not +specify a version number of the GNU Lesser General Public License, you +may choose any version of the GNU Lesser General Public License ever +published by the Free Software Foundation. + +If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..213606e --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# ICNS + +This repository contains a multiple libraries for reading and writing ICNS files. + - Rust: [icns-rs](icns-rs/README.md) + +## Contributing + +Contributions are welcome! Feel free to open an issue or submit a pull request. + +## License + +This project is licensed under the LGPLv3 license. See the [LICENSE](/LICENSE) file for more details. \ No newline at end of file diff --git a/icns-rs/.gitignore b/icns-rs/.gitignore new file mode 100644 index 0000000..a73dbda --- /dev/null +++ b/icns-rs/.gitignore @@ -0,0 +1,4 @@ +# Cargo +/target/ +Cargo.lock +# ^^^^^^^^ Lock files aren't allowed in libraries \ No newline at end of file diff --git a/icns-rs/Cargo.toml b/icns-rs/Cargo.toml new file mode 100644 index 0000000..073d294 --- /dev/null +++ b/icns-rs/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "icns-rs" +version = "0.1.0" +edition = "2021" +description = "A library for reading and writing Apple Icon Image (.icns) files." +license = "LGPL-3.0-or-later" +homepage = "https://github.com/JoshuaBrest/icns/tree/master/icns-rs" +documentation = "https://docs.rs/icns-rs" +repository = "https://github.com/JoshuaBrest/icns" +catagories = ["image"] +keywords = ["icns", "image", "apple", "icon"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +image = "0.24.6" + +[[example]] +name = "encode" +path = "examples/encode.rs" diff --git a/icns-rs/README.md b/icns-rs/README.md new file mode 100644 index 0000000..99916c5 --- /dev/null +++ b/icns-rs/README.md @@ -0,0 +1,74 @@ +# ICNS-RS + +ICNS is a file format used by Apple to store icons for macOS applications. This crate provides a simple API for reading and (and soon)writing ICNS files. + +## Roadmap + +- [x] Write ICNS files +- [ ] Read ICNS files + +## Usage + +Here's a simple example of how to read an ICNS file: + +> You can find this example in `examples/encode.rs` or run it with: +> ```sh +> cargo run --example encode +> ``` + +```rust +use std::fs::File; +use std::io::prelude::*; +use image::open; +use icns_rs::{IcnsEncoder, IconFormats}; + +fn main() -> std::io::Result<()> { + // Open the image + let image = match open("example.png") { + Ok(image) => image, + Err(e) => { + println!("Error opening file: {}", e); + return Ok(()); + } + }; + + // Create the encoder + let mut encoder = IcnsEncoder::new(); + + encoder.data(image); + encoder.formats(IconFormats::recommended()); + + // Encode the image + let data = match encoder.build() { + Ok(data) => data, + Err(e) => { + println!("Error encoding image: {}", e); + return Ok(()); + } + }; + + // Write data to file + let mut file = File::create("example.icns")?; + file.write_all(&data)?; + + Ok(()) +} +``` + +## License + +This project is licensed under the GPLv3 license. See the [LICENSE](/LICENSE) file for more details. + +## Contributing + +Contributions are welcome! Feel free to open an issue or submit a pull request. + +## Acknowledgements + +This project is heavily inspired by: + - The Python package: [icnsutil](https://github.com/relikd/icnsutil/) + - The JavaScript package: [@fiahfy/icns](https://github.com/fiahfy/icns/) + - The JavaScript PackBits implementation: [@fiahfy/packbits](https://github.com/fiahfy/packbits/) + - The Wikipedia page: [Wikipedia: Apple Icon Image Format](https://en.wikipedia.org/wiki/Apple_Icon_Image_format#Icon_types) + +When I started building this, I didn't know there already was a ICNS lib for rust, but, after looking at it, it was not up to my standards because of the lack of ARGB, RGB, and, Mask support. I wanted to create a modern package that was easy to use and had a simple API. I also wanted to make sure that it was well documented and had a good test suite. I hope you enjoy using this package as much as I enjoyed making it!. \ No newline at end of file diff --git a/icns-rs/example.icns b/icns-rs/example.icns new file mode 100644 index 0000000..232f2c6 Binary files /dev/null and b/icns-rs/example.icns differ diff --git a/icns-rs/example.png b/icns-rs/example.png new file mode 100644 index 0000000..205d67f Binary files /dev/null and b/icns-rs/example.png differ diff --git a/icns-rs/examples/encode.rs b/icns-rs/examples/encode.rs new file mode 100644 index 0000000..c30e61b --- /dev/null +++ b/icns-rs/examples/encode.rs @@ -0,0 +1,36 @@ +use icns_rs::{IcnsEncoder, IconFormats}; +use image::open; +use std::fs::File; +use std::io::prelude::*; + +fn main() -> std::io::Result<()> { + // Open the image + let image = match open("example.png") { + Ok(image) => image, + Err(e) => { + println!("Error opening file: {}", e); + return Ok(()); + } + }; + + // Create the encoder + let mut encoder = IcnsEncoder::new(); + + encoder.data(image); + encoder.formats(IconFormats::recommended()); + + // Encode the image + let data = match encoder.build() { + Ok(data) => data, + Err(e) => { + println!("Error encoding image: {}", e); + return Ok(()); + } + }; + + // Write data to file + let mut file = File::create("example.icns")?; + file.write_all(&data)?; + + Ok(()) +} diff --git a/icns-rs/rustfmt.toml b/icns-rs/rustfmt.toml new file mode 100644 index 0000000..eaac36c --- /dev/null +++ b/icns-rs/rustfmt.toml @@ -0,0 +1,3 @@ +error_on_line_overflow = true +error_on_unformatted = true +format_code_in_doc_comments=true \ No newline at end of file diff --git a/icns-rs/src/icns_format.rs b/icns-rs/src/icns_format.rs new file mode 100644 index 0000000..7cd01ec --- /dev/null +++ b/icns-rs/src/icns_format.rs @@ -0,0 +1,129 @@ +const MAGIC: [u8; 4] = [0x69, 0x63, 0x6e, 0x73]; // "icns" + +/// ## IcnsDataEntry +/// This file contains both the OSType and the data. +/// The OSType is a 4-byte identifier that tells the OS what the data is. +/// The data is the actual image / whatever data is being stored. +/// Data can be images, masks, metadata, etc. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IcnsDataEntry { + pub os_type: [u8; 4], + pub data: Box<[u8]>, +} + +impl IcnsDataEntry { + /// ## New + /// Creates a new IcnsDataEntry. + pub fn new(os_type: [u8; 4], data: Box<[u8]>) -> Self { + Self { os_type, data } + } + + /// ## Length + /// This function gets the length of the data when compiled. + /// The length is 4 bytes (OSType) + 4 bytes (length) + length (data) + pub fn len(&self) -> u32 { + (8 + self.data.len()) as u32 + } + + /// ## Building the data + /// This function compiles the data into a single byte array. + /// This contains the OSType followed by the length of the data + /// followed by the data. + pub fn build(&self) -> Box<[u8]> { + // Total: 4 bytes (OSType) + 4 bytes (length) + length (data) + let mut result = Vec::with_capacity(self.len() as usize); + + result.extend_from_slice(&self.os_type); + result.extend_from_slice(&(8 + self.data.len() as u32).to_be_bytes()); + result.extend_from_slice(&self.data); + + result.into_boxed_slice() + } +} + +/// ## ICNSBuilder +/// This struct holds a list of data that will be compiled into an ICNS file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IconFamily { + pub data: Vec, +} + +impl IconFamily { + /// ## New + /// Creates a new file format + pub fn new() -> Self { + Self { data: vec![] } + } + + /// ## Adding data + /// This adds a new entry to the file. + /// Data can be images, masks, metadata, etc. + pub fn add_data(&mut self, data: IcnsDataEntry) -> &mut Self { + self.data.push(data); + + self + } + + /// ## Creating the table of contents + /// The table of contents is the first entry in the file. + /// It contains the OSType of each entry and the length of each entry. + pub fn create_contents_table(&self) -> IcnsDataEntry { + let mut buffer = Vec::with_capacity(8 * self.data.len()); // Each entry is 8 bytes + + for data in &self.data { + buffer.extend_from_slice(&data.os_type); + buffer.extend_from_slice(&((&data).data.len() as u32).to_be_bytes()); + } + + IcnsDataEntry::new( + [0x54, 0x4F, 0x43, 0x20], // "TOC " + buffer.into_boxed_slice(), + ) + } + + /// ## Building the ICNS file + /// Building the file will create the table of contents + /// and compile all the data into a single file. + pub fn build(&self) -> Box<[u8]> { + // Calculate the total size of the file + let contents_table = self.create_contents_table(); + + // Insert the TOC first + let mut data = Vec::with_capacity(self.data.len() + 1); + data.push(contents_table); + for d in &self.data { + data.push(d.clone()); + } + + let total_size = data.iter().map(|data| data.len()).sum::(); + let mut buffer = Vec::with_capacity(MAGIC.len() + 4 + total_size as usize); + + // Add the magic bytes, the total size and the data + buffer.extend_from_slice(&MAGIC); + buffer.extend_from_slice(&(total_size as u32).to_be_bytes()); + for data in &data { + buffer.extend_from_slice(&data.build()); + } + + buffer.into_boxed_slice() + } +} + +#[cfg(test)] +mod tests { + #[test] + fn encode_icns_data_entry() { + let dummy_data: Vec = vec![0x00, 0x01, 0x02, 0x03]; + let result = vec![ + 0x00, 0x00, 0x00, 0x00, // OSType: NUL NUL NUL NUL + 0x00, 0x00, 0x00, + 0x0C, // Length: 12 (4 bytes OSType + 4 bytes length + 4 bytes data) + 0x00, 0x01, 0x02, 0x03, // Data: 00 01 02 03 + ]; + + let entry = + super::IcnsDataEntry::new([0x00, 0x00, 0x00, 0x00], dummy_data.into_boxed_slice()); + + assert_eq!(entry.build(), result.into_boxed_slice()); + } +} diff --git a/icns-rs/src/image_encoder.rs b/icns-rs/src/image_encoder.rs new file mode 100644 index 0000000..2e78bce --- /dev/null +++ b/icns-rs/src/image_encoder.rs @@ -0,0 +1,215 @@ +use std::io::Write; + +use crate::{icns_format::IcnsDataEntry, image_types::IconFormats, packbits}; + +use image::{codecs::png::PngEncoder, imageops::FilterType, DynamicImage, ImageEncoder}; + +/// The ImageBuilder struct +/// This struct is used to build the image data, specifically, +/// resizing the image and encoding it as a RGB, ARGB, mask, +/// or PNG image +pub struct ImageBuilder { + pub format: IconFormats, + pub data: DynamicImage, + pub filter: FilterType, +} + +impl ImageBuilder { + pub fn new() -> Self { + Self { + format: IconFormats::IS32, + data: DynamicImage::new_rgb8(1, 1), + filter: FilterType::Nearest, + } + } + + /// Sets the image format + /// See the `IconFormats` enum for more information + pub fn format(&mut self, format: IconFormats) -> &mut Self { + self.format = format; + + self + } + + /// Sets the image data. Encode a png and pass it as a DynamicImage. + pub fn data(&mut self, data: DynamicImage) -> &mut Self { + self.data = data; + + self + } + + /// Sets the filter type to be used when resizing the image + /// - `Nearest`: Nearest neighbor interpolation + /// - `Triangle`: Triangle interpolation + /// - `CatmullRom`: Catmull-Rom interpolation + /// - `Gaussian`: Gaussian interpolation + /// + /// The default is `Nearest` because it's the fastest + pub fn filter(&mut self, filter: FilterType) -> &mut Self { + self.filter = filter; + + self + } + + /// Encodes an image as a RGB image + /// You probably want to use `.build()` instead of this method + pub fn rgb_image(&self) -> Result, String> { + let size = self.format.get_size() as u32; + let resized = self.data.resize(size, size, self.filter); + let rgb8 = resized.to_rgb8(); + let data = rgb8.pixels().collect::>(); + + let channels = [ + // Offset if the type is it32 + if self.format == IconFormats::IT32 { + vec![0x00, 0x00, 0x00, 0x00].into_boxed_slice() + } else { + Vec::new().into_boxed_slice() + }, + // Red channel + packbits::compress( + data.iter() + .map(|pixel| pixel[0]) + .collect::>() + .into_boxed_slice(), + ), + // Green channel + packbits::compress( + data.iter() + .map(|pixel| pixel[1]) + .collect::>() + .into_boxed_slice(), + ), + // Blue channel + packbits::compress( + data.iter() + .map(|pixel| pixel[2]) + .collect::>() + .into_boxed_slice(), + ), + ]; + + let mut buffer = Vec::with_capacity(channels.iter().map(|c| c.len()).sum::()); + + for b in channels { + buffer.extend_from_slice(&b); + } + + Ok(buffer.into_boxed_slice()) + } + + /// Encodes an image as a ARGB + /// You probably want to use `.build()` instead of this method + pub fn argb_image(&self) -> Result, String> { + let size = self.format.get_size() as u32; + let resized = self.data.resize(size, size, self.filter); + let rgba8 = resized.to_rgba8(); + let data = rgba8.pixels().collect::>(); + + let channels = [ + // File header + vec![0x41, 0x52, 0x47, 0x42].into_boxed_slice(), // ARGB + // Alpha channel + packbits::compress( + data.iter() + .map(|pixel| pixel[3]) + .collect::>() + .into_boxed_slice(), + ), + // Red channel + packbits::compress( + data.iter() + .map(|pixel| pixel[0]) + .collect::>() + .into_boxed_slice(), + ), + // Green channel + packbits::compress( + data.iter() + .map(|pixel| pixel[1]) + .collect::>() + .into_boxed_slice(), + ), + // Blue channel + packbits::compress( + data.iter() + .map(|pixel| pixel[2]) + .collect::>() + .into_boxed_slice(), + ), + ]; + + let mut buffer = Vec::with_capacity(channels.iter().map(|c| c.len()).sum::()); + + for b in channels { + buffer.extend_from_slice(&b); + } + + Ok(buffer.into_boxed_slice()) + } + + /// Encodes an image as a mask + /// You probably want to use `.build()` instead of this method + pub fn mask_image(&self) -> Result, String> { + let size = self.format.get_size() as u32; + let resized = self.data.resize(size, size, self.filter); + let luma = resized.to_luma_alpha8(); + let data = luma.pixels().collect::>(); + + // No compression + let mask = data + .iter() + .map(|pixel| pixel[1]) + .collect::>() + .into_boxed_slice(); + + Ok(mask) + } + + /// Encodes an image as a PNG + pub fn png_image(&self) -> Result, String> { + let size = self.format.get_size() as u32; + let data = self.data.resize(size, size, self.filter); + + let mut buffer = Vec::new(); + + // Required because the PngEncoder drops the writer + struct WriterProxy<'a> { + buffer: &'a mut Vec, + } + + impl<'a> Write for WriterProxy<'a> { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buffer.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buffer.flush() + } + } + + let encoder = PngEncoder::new(WriterProxy { + buffer: &mut buffer, + }); + + let color = data.color(); + + let result = encoder.write_image(data.into_bytes().as_slice(), size, size, color); + + match result { + Ok(_) => Ok(buffer.into_boxed_slice()), + Err(e) => Err(format!("Failed to encode PNG: {}", e)), + } + } + + pub fn build(&self) -> Result { + let data = match self.format.get_format() { + crate::image_types::FileFormat::RGB => self.rgb_image(), + crate::image_types::FileFormat::ARGB => self.argb_image(), + crate::image_types::FileFormat::MASK => self.mask_image(), + crate::image_types::FileFormat::PNG => self.png_image(), + }?; + + Ok(IcnsDataEntry::new(self.format.get_bytes(), data)) + } +} diff --git a/icns-rs/src/image_types.rs b/icns-rs/src/image_types.rs new file mode 100644 index 0000000..966d1bb --- /dev/null +++ b/icns-rs/src/image_types.rs @@ -0,0 +1,226 @@ +#[doc(hidden)] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)] +pub enum FileFormat { + RGB, + ARGB, + MASK, + PNG, +} + +/// # ICNS Types +/// These are the types of icons that can be stored in an ICNS file. +/// Not all of them are included, but the most common ones are. +/// The full list can be found at Wikipedia +/// https://en.wikipedia.org/wiki/Apple_Icon_Image_format#Icon_types +#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)] +pub enum IconFormats { + /// - OSName: is32 + /// - Size: 16x16 + /// - Format: 24-bit RGB icon + /// - OS: System 8.5+ + IS32, + /// - OSName: il32 + /// - Size: 32x32 + /// - Format: 24-bit RGB icon + /// - OS: System 8.5+ + IL32, + /// - OSName: ih32 + /// - Size: 48x48 + /// - Format: 24-bit RGB icon + /// - OS: System 8.5+ + IH32, + /// - OSName: it32 + /// - Size: 128x128 + /// - Format: 24-bit RGB icon + /// - OS: Mac OS X 10.0+ + IT32, + /// - OSName: s8mk + /// - Size: 16x12 + /// - Format: 8-bit mask + /// - OS: System 8.5+ + S8MK, + /// - OSName: l8mk + /// - Size: 32x32 + /// - Format: 8-bit mask + /// - OS: System 8.5+ + L8MK, + /// - OSName: h8mk + /// - Size: 48x48 + /// - Format: 8-bit mask + /// - OS: System 8.5+ + H8MK, + /// - OSName: t8mk + /// - Size: 128x128 + /// - Format: 8-bit mask + /// - OS: Mac OS X 10.0+ + T8MK, + /// - OSName: ic04 + /// - Size: 16x16 + /// - Format: ARGB + /// - OS: N/A + IC04, + /// - OSName: ic05 + /// - Size: 32x32 + /// - Format: ARGB + /// - OS: N/A + IC05, + /// - OSName: ic07 + /// - Size: 128x128 + /// - Format: PNG + /// - OS: Mac OS X 10.7+ + IC07, + /// - OSName: ic08 + /// - Size: 256x256 + /// - Format: PNG + /// - OS: Mac OS X 10.5+ + IC08, + /// - OSName: ic09 + /// - Size: 512x512 + /// - Format: PNG + /// - OS: Mac OS X 10.5+ + IC09, + /// - OSName: ic10 + /// - Size: 1024x1024 + /// - Format: PNG + /// - OS: Mac OS X 10.7+ + IC10, + /// - OSName: ic11 + /// - Size: 32x32 + /// - Format: PNG + /// - OS: Mac OS X 10.8+ + IC11, + /// - OSName: ic12 + /// - Size: 64x64 + /// - Format: PNG + /// - OS: Mac OS X 10.8+ + IC12, + /// - OSName: ic13 + /// - Size: 256x256 + /// - Format: PNG + /// - OS: Mac OS X 10.8+ + IC13, + /// - OSName: ic14 + /// - Size: 512x512 + /// - Format: PNG + /// - OS: Mac OS X 10.8+ + IC14, + /// - OSName: icp4 + /// - Size: 16x16 + /// - Format: PNG + /// - OS: Mac OS X 10.7+ + ICP4, + /// - OSName: icp5 + /// - Size: 32x32 + /// - Format: PNG + /// - OS: Mac OS X 10.7+ + ICP5, + /// - OSName: icp6 + /// - Size: 64x64 + /// - Format: PNG + /// - OS: Mac OS X 10.7+ + ICP6, +} + +impl IconFormats { + /// Get the default recommended format for the icon type. + pub fn recommended() -> Vec { + vec![ + IconFormats::IS32, + IconFormats::IL32, + IconFormats::IH32, + IconFormats::IT32, + IconFormats::S8MK, + IconFormats::L8MK, + IconFormats::H8MK, + IconFormats::T8MK, + IconFormats::IC04, + IconFormats::IC05, + IconFormats::IC07, + IconFormats::IC08, + IconFormats::IC09, + IconFormats::IC10, + IconFormats::IC11, + IconFormats::IC12, + IconFormats::IC13, + IconFormats::IC14, + ] + } + + pub fn get_format(&self) -> FileFormat { + match self { + IconFormats::IS32 => FileFormat::RGB, + IconFormats::IL32 => FileFormat::RGB, + IconFormats::IH32 => FileFormat::RGB, + IconFormats::IT32 => FileFormat::RGB, + IconFormats::S8MK => FileFormat::MASK, + IconFormats::L8MK => FileFormat::MASK, + IconFormats::H8MK => FileFormat::MASK, + IconFormats::T8MK => FileFormat::MASK, + IconFormats::IC04 => FileFormat::ARGB, + IconFormats::IC05 => FileFormat::ARGB, + IconFormats::IC07 => FileFormat::PNG, + IconFormats::IC08 => FileFormat::PNG, + IconFormats::IC09 => FileFormat::PNG, + IconFormats::IC10 => FileFormat::PNG, + IconFormats::IC11 => FileFormat::PNG, + IconFormats::IC12 => FileFormat::PNG, + IconFormats::IC13 => FileFormat::PNG, + IconFormats::IC14 => FileFormat::PNG, + IconFormats::ICP4 => FileFormat::PNG, + IconFormats::ICP5 => FileFormat::PNG, + IconFormats::ICP6 => FileFormat::PNG, + } + } + + pub fn get_size(&self) -> usize { + match self { + IconFormats::IS32 => 16, + IconFormats::IL32 => 32, + IconFormats::IH32 => 48, + IconFormats::IT32 => 128, + IconFormats::S8MK => 16, + IconFormats::L8MK => 32, + IconFormats::H8MK => 48, + IconFormats::T8MK => 128, + IconFormats::IC04 => 16, + IconFormats::IC05 => 32, + IconFormats::IC07 => 128, + IconFormats::IC08 => 256, + IconFormats::IC09 => 512, + IconFormats::IC10 => 1024, + IconFormats::IC11 => 32, + IconFormats::IC12 => 64, + IconFormats::IC13 => 256, + IconFormats::IC14 => 512, + IconFormats::ICP4 => 16, + IconFormats::ICP5 => 32, + IconFormats::ICP6 => 64, + } + } + + pub fn get_bytes(&self) -> [u8; 4] { + match self { + IconFormats::IS32 => [0x69, 0x73, 0x33, 0x32], //is32 + IconFormats::IL32 => [0x69, 0x6c, 0x33, 0x32], //il32 + IconFormats::IH32 => [0x69, 0x68, 0x33, 0x32], //ih32 + IconFormats::IT32 => [0x69, 0x74, 0x33, 0x32], //it32 + IconFormats::S8MK => [0x73, 0x38, 0x6d, 0x6b], //s8mk + IconFormats::L8MK => [0x6c, 0x38, 0x6d, 0x6b], //l8mk + IconFormats::H8MK => [0x68, 0x38, 0x6d, 0x6b], //h8mk + IconFormats::T8MK => [0x74, 0x38, 0x6d, 0x6b], //t8mk + IconFormats::IC04 => [0x69, 0x63, 0x30, 0x34], //ic04 + IconFormats::IC05 => [0x69, 0x63, 0x30, 0x35], //ic05 + IconFormats::IC07 => [0x69, 0x63, 0x30, 0x37], //ic07 + IconFormats::IC08 => [0x69, 0x63, 0x30, 0x38], //ic08 + IconFormats::IC09 => [0x69, 0x63, 0x30, 0x39], //ic09 + IconFormats::IC10 => [0x69, 0x63, 0x31, 0x30], //ic10 + IconFormats::IC11 => [0x69, 0x63, 0x31, 0x31], //ic11 + IconFormats::IC12 => [0x69, 0x63, 0x31, 0x32], //ic12 + IconFormats::IC13 => [0x69, 0x63, 0x31, 0x33], //ic13 + IconFormats::IC14 => [0x69, 0x63, 0x31, 0x34], //ic14 + IconFormats::ICP4 => [0x69, 0x63, 0x70, 0x34], //icp4 + IconFormats::ICP5 => [0x69, 0x63, 0x70, 0x35], //icp5 + IconFormats::ICP6 => [0x69, 0x63, 0x70, 0x36], //icp6 + } + } +} diff --git a/icns-rs/src/lib.rs b/icns-rs/src/lib.rs new file mode 100644 index 0000000..b3b55a6 --- /dev/null +++ b/icns-rs/src/lib.rs @@ -0,0 +1,105 @@ +pub mod icns_format; +pub mod image_encoder; +pub mod image_types; +pub mod packbits; + +use icns_format::IconFamily; +use image::DynamicImage; +use image_encoder::ImageBuilder; +pub use image_types::IconFormats; + +/// The main encoder struct +/// Create a new encoder with `IcnsEncoder::new()` +pub struct IcnsEncoder { + data: DynamicImage, + formats: Vec, +} + +impl IcnsEncoder { + /// Creates a new IcnsEncoder + /// + /// Usage: + /// ```no_run + /// use icns_rs::{IcnsEncoder, IconFormats}; + /// use image::open; + /// use std::fs::File; + /// use std::io::prelude::*; + /// + /// // Open the image + /// let image = match open("512x512@2.png") { + /// Ok(image) => image, + /// Err(e) => { + /// println!("Error: {}", e); + /// std::process::exit(1); + /// } + /// }; + /// + /// // Create the encoder + /// let mut encoder = IcnsEncoder::new(); + /// + /// encoder.data(image); + /// encoder.formats(IconFormats::recommended()); + /// + /// // Encode the image + /// let data = match encoder.build() { + /// Ok(data) => data, + /// Err(e) => { + /// println!("Error ould not encode image"); + /// std::process::exit(1); + /// } + /// }; + /// + /// // Write data to file + /// let mut file = match File::create("example.icns") { + /// Ok(file) => file, + /// Err(e) => { + /// println!("Error: {}", e); + /// std::process::exit(1); + /// } + /// }; + /// + /// match file.write_all(&data) { + /// Ok(_) => println!("Successfully wrote to file"), + /// Err(e) => { + /// println!("Error: {}", e); + /// std::process::exit(1); + /// } + /// }; + /// ``` + pub fn new() -> Self { + Self { + data: DynamicImage::new_rgb8(1, 1), + formats: Vec::new(), + } + } + + /// Sets the image data. Encode a png and pass it as a DynamicImage. + pub fn data(&mut self, data: DynamicImage) -> &mut Self { + self.data = data; + + self + } + + /// Sets the image formats to be encoded + pub fn formats(&mut self, formats: Vec) -> &mut Self { + self.formats = formats; + + self + } + + /// Encodes the image as an ICNS file + pub fn build(&self) -> Result, String> { + let mut file = IconFamily::new(); + + let mut image_encoder = ImageBuilder::new(); + image_encoder.data(self.data.clone()); + + for format in &self.formats { + let image = image_encoder.format(format.clone()).build()?; + + file.add_data(image); + } + + Ok(file.build()) + } +} diff --git a/icns-rs/src/packbits.rs b/icns-rs/src/packbits.rs new file mode 100644 index 0000000..aec65da --- /dev/null +++ b/icns-rs/src/packbits.rs @@ -0,0 +1,268 @@ +/// To denote that a byte is repeated, the first byte of a sequence +/// must be greater or equal to 128. A byte is 255 so because of this +/// 255 - 128 = 127 is the maximum amount of bytes that can be repeated. +/// When a byte is repeated, it is repeated at least 3 times. +/// So add 3 to the maximum amount of bytes that can be repeated. +const MAX_REPEAT: usize = 130; +const ENCODE_REPEAT: u8 = 128; + +/// # ICNS PackBits(like) compression +/// Apple uses a format simular to PackBits to compress the image data. +/// PackBits is a lossless compression format that is used in TIFF files +/// since system 6.0.5. +/// This implementation is based on the javascript implementation by +/// @fiahfy/packbits https://github.com/fiahfy/packbits +/// +/// ```rust +/// let data = vec![ +/// 0x01, 0x02, 0x02, 0x03, 0x03, 0x03, 0x04, 0x04, 0x04, 0x04, 0x05, 0x05, 0x05, 0x05, 0x05 +/// ]; +/// +/// let compressed = icns_rs::packbits::compress(data.into_boxed_slice()); +/// +/// assert_eq!( +/// compressed, +/// vec![0x02, 0x01, 0x02, 0x02, 0x80, 0x03, 0x81, 0x04, 0x82, 0x05] +/// .into_boxed_slice() +/// ); +pub fn compress(raw: Box<[u8]>) -> Box<[u8]> { + let mut buffers: Vec> = vec![]; + + // I'd be happy to use a iterator here + // FIXME: This is a mess + let mut i = 0; + while i < raw.len() { + let byte = &raw[i]; + // Check if last 1 or 2 bytes + if i + 2 >= raw.len() { + let length = raw.len() - i; + let mut buffer = Vec::with_capacity(1); + buffer.push(length as u8 - 1); + buffers.push(buffer.into_boxed_slice()); + buffers.push(raw[i..].to_vec().into_boxed_slice()); + break; + } + + // Should be repeated if the next 2 bytes are the same + let should_repeat = byte == &raw[i + 1] && byte == &raw[i + 2]; + + if should_repeat { + let mut repeat_to = i + 2; + + while repeat_to + 1 < raw.len() + && byte == &raw[repeat_to + 1] + && repeat_to - i + 1 < MAX_REPEAT + { + repeat_to += 1; + } + + repeat_to += 1; + + let length = repeat_to - i; // + 1 because the first byte is also included + + let mut buffer = Vec::with_capacity(2); + buffer.push(length as u8 - 3 + ENCODE_REPEAT); + buffer.push(byte.clone()); + + buffers.push(buffer.into_boxed_slice()); + + // Skip the repeated bytes + i = repeat_to; + } else { + // Should not be repeated + let mut buffer_to = i + 2; + // ^^ Minimum length is 2 (that's why we check if we're at the last 2 bytes) + let mut repeats = 1; + let mut repeat_index = buffer_to; + + while buffer_to + 1 < raw.len() && buffer_to - i + 1 < ENCODE_REPEAT as usize { + if &raw[buffer_to] == &raw[repeat_index] { + repeats += 1; + // If we have 2 repeats, we can stop + // It would be better to check to compress + if repeats > 2 { + break; + } + } else { + repeats = 1; + repeat_index = buffer_to; + } + + buffer_to += 1; + } + buffer_to += 1; + if repeats > 2 { + buffer_to -= 3; + } + + let length = buffer_to - i; + let mut buffer = Vec::with_capacity(length + 1); + buffer.push(length as u8 - 1); + buffer.extend_from_slice(&raw[i..buffer_to]); + + buffers.push(buffer.into_boxed_slice()); + + i = buffer_to; + } + } + + // Compact the buffers into a single buffer + let mut buffer = Vec::with_capacity(buffers.iter().map(|b| b.len()).sum()); + for b in buffers { + buffer.extend_from_slice(&b); + } + + buffer.into_boxed_slice() +} + +/// # ICNS PackBits(like) decompression +/// Apple uses a format simular to PackBits to compress the image data. +/// PackBits is a lossless compression format that is used in TIFF files +/// since system 6.0.5. +/// This implementation is based on the javascript implementation by +/// @fiahfy/packbits https://github.com/fiahfy/packbits +/// +/// The implementation was slightly modified to work because unlike the +/// PackBits format, the image format does not have an escape byte of +/// 255 / 0xFF. I think the author of the javascript implementation +/// forgot to remove the escape byte in the icns version. +/// +/// ```rust +/// let data = vec![0x02, 0x01, 0x02, 0x02, 0x80, 0x03, 0x81, 0x04, 0x82, 0x05]; +/// +/// let decompressed = icns_rs::packbits::decompress(data.into_boxed_slice()); +/// +/// assert_eq!( +/// decompressed, +/// vec![ +/// 0x01, 0x02, 0x02, 0x03, 0x03, 0x03, 0x04, 0x04, 0x04, 0x04, 0x05, 0x05, 0x05, 0x05, +/// 0x05 +/// ] +/// .into_boxed_slice() +/// ); +/// ``` +pub fn decompress(data: Box<[u8]>) -> Box<[u8]> { + let mut buffers: Vec> = vec![]; + + // FIXME: Don't use a loop + let mut i = 0; + while i < data.len() { + // We know it's compressed if the first byte is greater or equal to 128 + if data[i] >= ENCODE_REPEAT { + // How many times the byte is repeated + let repeats = data[i] - ENCODE_REPEAT + 3; + // ^^ + 3 because the first byte is also included + let byte = data[i + 1]; + + let mut buffer = Vec::with_capacity(repeats as usize); + for _ in 0..repeats { + buffer.push(byte); + } + + buffers.push(buffer.into_boxed_slice()); + + i += 2; // Compressed bytes are always 2 bytes long + } else { + // Not compressed + let length = data[i] as usize + 1; + let mut buffer = Vec::with_capacity(length); + buffer.extend_from_slice(&data[i + 1..i + length + 1]); + + buffers.push(buffer.into_boxed_slice()); + + i += length + 1; + } + } + + // Compact the buffers into a single buffer + let mut buffer = Vec::with_capacity(buffers.iter().map(|b| b.len()).sum()); + for b in buffers { + buffer.extend_from_slice(&b); + } + + buffer.into_boxed_slice() +} + +#[cfg(test)] +mod tests { + use super::*; + + const BASIC_RAW: [u8; 15] = [ + 0x01, 0x02, 0x02, 0x03, 0x03, 0x03, 0x04, 0x04, 0x04, 0x04, 0x05, 0x05, 0x05, 0x05, 0x05, + ]; + const BASIC_COMPRESSED: [u8; 10] = [0x02, 0x01, 0x02, 0x02, 0x80, 0x03, 0x81, 0x04, 0x82, 0x05]; + + const STRESS_REPEAT_RAW: [u8; 131] = [0x01; 131]; + const STRESS_REPEAT_COMPRESSED: [u8; 4] = [0xFF, 0x01, 0x00, 0x01]; + + const STRESS_NO_REPEAT_RAW: [u8; 131] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, + 0x1e, 0x1f, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, + 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, + 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, + 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, + 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, + 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, + 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f, 0x80, 0x81, 0x82, + ]; + const STRESS_NO_REPEAT_COMPRESSED: [u8; 133] = [ + 0x7f, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, + 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, + 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, + 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, + 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, + 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, + 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, + 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f, 0x02, 0x80, 0x81, 0x82, + ]; + + #[test] + fn compress_basic() { + assert_eq!( + compress(BASIC_RAW.to_vec().into_boxed_slice()), + BASIC_COMPRESSED.to_vec().into_boxed_slice() + ); + } + + #[test] + fn compress_stress_repeat() { + assert_eq!( + compress(STRESS_REPEAT_RAW.to_vec().into_boxed_slice()), + STRESS_REPEAT_COMPRESSED.to_vec().into_boxed_slice() + ); + } + + #[test] + fn compress_stress_no_repeat() { + assert_eq!( + compress(STRESS_NO_REPEAT_RAW.to_vec().into_boxed_slice()), + STRESS_NO_REPEAT_COMPRESSED.to_vec().into_boxed_slice() + ); + } + + #[test] + fn decompress_basic() { + assert_eq!( + decompress(BASIC_COMPRESSED.to_vec().into_boxed_slice()), + BASIC_RAW.to_vec().into_boxed_slice() + ); + } + + #[test] + fn decompress_stress_repeat() { + assert_eq!( + decompress(STRESS_REPEAT_COMPRESSED.to_vec().into_boxed_slice()), + STRESS_REPEAT_RAW.to_vec().into_boxed_slice() + ); + } + + #[test] + fn decompress_stress_no_repeat() { + assert_eq!( + decompress(STRESS_NO_REPEAT_COMPRESSED.to_vec().into_boxed_slice()), + STRESS_NO_REPEAT_RAW.to_vec().into_boxed_slice() + ); + } +}