diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f21422f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 saharNooby (https://github.com/saharNooby)
+
+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.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..158cb43
--- /dev/null
+++ b/README.md
@@ -0,0 +1,81 @@
+# qoi-java
+
+A pure Java 8 implementation of [Quite OK Image Format](https://github.com/phoboslab/qoi).
+
+This library has no runtime dependencies, including Java AWT. `BufferedImage` support is provided using separate module [qoi-java-awt](https://github.com/saharNooby/qoi-java-awt).
+
+**Production use warning**: QOI file format is not finalized yet. You can track progress [here in the original repository](https://github.com/phoboslab/qoi/issues/48).
+
+## How to Use
+
+### Build and Install
+
+You will need Git, Maven and JDK 8 or higher.
+
+```shell
+git clone https://github.com/saharNooby/qoi-java.git
+cd qoi-java
+mvn clean install
+```
+
+Add this library as a dependency to your build system. Maven example:
+
+```xml
+
+ me.saharnooby
+ qoi-java
+ 0.0.1
+
+```
+
+### Usage
+
+Use methods in class `me.saharnooby.qoi.QOIUtil`.
+
+Usage example:
+
+```java
+// Read image from a file
+QOIImage image = QOIUtil.readFile(new File("image.qoi"));
+
+System.out.println("Image size: " + image.getWidth() + " x " + image.getHeight());
+
+// Access pixel data
+System.out.println("Red channel is " + image.getPixelData()[0]);
+
+// Create new 1x1 RGBA image from raw pixel data
+byte[] pixelData = {(byte) 255, 127, 0, (byte) 255};
+
+QOIImage orangeImage = QOIUtil.createFromPixelData(pixelData, 1, 1, 4);
+
+// Write image to a file
+QOIUtil.writeImage(orangeImage, new File("orange.qoi"));
+```
+
+### Use with `BufferedImage`
+
+To convert QOI images to BufferedImages and back, you need to also add [qoi-java-awt](https://github.com/saharNooby/qoi-java-awt) dependency.
+
+Use methods in class `me.saharnooby.qoi.QOIUtilAWT`.
+
+Usage example:
+
+```java
+// Convert PNG to QOI
+BufferedImage image = ImageIO.read(new File("image.png"));
+QOIImage qoi = QOIUtilAWT.createFromBufferedImage(image);
+QOIUtil.writeImage(qoi, new File("image.qoi"));
+
+// Convert QOI to PNG
+QOIImage secondImage = QOIUtil.readFile(new File("second-image.qoi"));
+BufferedImage convertedImage = QOIUtilAWT.convertToBufferedImage(secondImage);
+ImageIO.write(convertedImage, "PNG", new File("second-image.png"));
+```
+
+## Compatibility
+
+No AWT classes are used, so it should be compatible with Android. Please report compatibility issues, if they arise.
+
+## Versioning
+
+This project uses semantic versioning. Until QOI file format is finalized, major version will remain 0.
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..a241b1d
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,96 @@
+
+
+ 4.0.0
+
+ me.saharnooby
+ qoi-java
+ 0.0.1
+
+ qoi-java
+ A pure Java 8 implementation of Quite OK Image Format
+ https://github.com/saharNooby/qoi-java
+
+
+
+ MIT License
+ http://www.opensource.org/licenses/mit-license.php
+
+
+
+
+
+ saharNooby
+ https://saharnooby.me
+
+
+
+
+ UTF-8
+
+ 8
+ 8
+
+
+
+
+ org.projectlombok
+ lombok
+ 1.18.20
+ provided
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ 5.0.0
+ test
+
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ 5.0.0
+ test
+
+
+
+
+ clean install
+
+
+
+ maven-surefire-plugin
+ 2.22.2
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+ 3.2.0
+
+
+ attach-javadocs
+
+ jar
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-source-plugin
+ 3.2.1
+
+
+ attach-sources
+
+ jar
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/java/me/saharnooby/qoi/InvalidQOIStreamException.java b/src/main/java/me/saharnooby/qoi/InvalidQOIStreamException.java
new file mode 100644
index 0000000..50d476c
--- /dev/null
+++ b/src/main/java/me/saharnooby/qoi/InvalidQOIStreamException.java
@@ -0,0 +1,16 @@
+package me.saharnooby.qoi;
+
+import lombok.NonNull;
+
+import java.io.IOException;
+
+/**
+ * This exception is thrown when decoder detects invalid data in the input stream.
+ */
+public final class InvalidQOIStreamException extends IOException {
+
+ InvalidQOIStreamException(@NonNull String message) {
+ super(message);
+ }
+
+}
diff --git a/src/main/java/me/saharnooby/qoi/QOICodec.java b/src/main/java/me/saharnooby/qoi/QOICodec.java
new file mode 100644
index 0000000..3fdab70
--- /dev/null
+++ b/src/main/java/me/saharnooby/qoi/QOICodec.java
@@ -0,0 +1,47 @@
+package me.saharnooby.qoi;
+
+/**
+ * Contains constants and utility methods for decoder and encoder.
+ */
+final class QOICodec {
+
+ static final int QOI_SRGB = 0x00;
+ static final int QOI_SRGB_LINEAR_ALPHA = 0x01;
+ static final int QOI_LINEAR = 0x0F;
+
+ static final int QOI_INDEX = 0x00;
+ static final int QOI_RUN_8 = 0x40;
+ static final int QOI_RUN_16 = 0x60;
+ static final int QOI_DIFF_8 = 0x80;
+ static final int QOI_DIFF_16 = 0xC0;
+ static final int QOI_DIFF_24 = 0xE0;
+ static final int QOI_COLOR = 0xF0;
+
+ static final int QOI_MASK_2 = 0xC0;
+ static final int QOI_MASK_3 = 0xE0;
+ static final int QOI_MASK_4 = 0xF0;
+
+ static final int QOI_MAGIC = 'q' << 24 | 'o' << 16 | 'i' << 8 | 'f';
+
+ static final int QOI_PADDING = 4;
+
+ private static final int HASH_TABLE_SIZE = 64;
+
+ static byte[] createHashTable() {
+ byte[] index = new byte[HASH_TABLE_SIZE * 4];
+
+ // Fill alpha with default value of 255
+ for (int i = 3; i < index.length; i += 4) {
+ index[i] = (byte) 0xFF;
+ }
+
+ return index;
+ }
+
+ static int getHashTableIndex(int r, int g, int b, int a) {
+ int hash = r ^ g ^ b ^ a;
+
+ return hash & (HASH_TABLE_SIZE - 1);
+ }
+
+}
diff --git a/src/main/java/me/saharnooby/qoi/QOIColorSpace.java b/src/main/java/me/saharnooby/qoi/QOIColorSpace.java
new file mode 100644
index 0000000..3d2f163
--- /dev/null
+++ b/src/main/java/me/saharnooby/qoi/QOIColorSpace.java
@@ -0,0 +1,21 @@
+package me.saharnooby.qoi;
+
+/**
+ * A color space that can be specified in a QOI file.
+ */
+public enum QOIColorSpace {
+
+ /**
+ * sRGB color space.
+ */
+ SRGB,
+ /**
+ * sRGB color space with linear alpha channel.
+ */
+ SRGB_LINEAR_ALPHA,
+ /**
+ * All channels are linear.
+ */
+ LINEAR
+
+}
diff --git a/src/main/java/me/saharnooby/qoi/QOIDecoder.java b/src/main/java/me/saharnooby/qoi/QOIDecoder.java
new file mode 100644
index 0000000..1893542
--- /dev/null
+++ b/src/main/java/me/saharnooby/qoi/QOIDecoder.java
@@ -0,0 +1,183 @@
+package me.saharnooby.qoi;
+
+import lombok.NonNull;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import static me.saharnooby.qoi.QOICodec.*;
+
+/**
+ * Contains method that decodes data stream into raw pixel data.
+ */
+public final class QOIDecoder {
+
+ /**
+ * Decodes data in the input stream into raw pixel data.
+ * @param in Input stream, should be buffered for optimal performance.
+ * @param channels Channel count. Allowed values are 3, 4 and 0 (read as many channels as actually stored).
+ * @return QOI image.
+ * @throws IllegalArgumentException If channel count is invalid.
+ * @throws InvalidQOIStreamException If provided data does not represent a valid QOI image.
+ * @throws IOException On any IO error.
+ */
+ public static QOIImage decode(@NonNull InputStream in, int channels) throws IOException {
+ if (channels != 0 && channels != 3 && channels != 4) {
+ throw new IllegalArgumentException("Invalid channel count, must be 0, 3 or 4");
+ }
+
+ int headerMagic = read32(in);
+
+ if (headerMagic != QOI_MAGIC) {
+ throw new InvalidQOIStreamException("Invalid magic value, probably not a QOI image");
+ }
+
+ int width = read32(in);
+
+ if (width < 1) {
+ throw new InvalidQOIStreamException("Invalid image width");
+ }
+
+ int height = read32(in);
+
+ if (height < 1) {
+ throw new InvalidQOIStreamException("Invalid image height");
+ }
+
+ int storedChannels = read(in);
+
+ if (storedChannels != 3 && storedChannels != 4) {
+ throw new InvalidQOIStreamException("Invalid stored channel count");
+ }
+
+ if (channels == 0) {
+ channels = storedChannels;
+ }
+
+ QOIColorSpace colorSpace = readColorSpace(in);
+
+ int pixelDataLength = width * height * channels;
+
+ byte[] pixelData = new byte[pixelDataLength];
+
+ byte[] index = createHashTable();
+
+ int pixelR = 0;
+ int pixelG = 0;
+ int pixelB = 0;
+ int pixelA = 0xFF;
+
+ int run = 0;
+
+ for (int pixelPos = 0; pixelPos < pixelDataLength; pixelPos += channels) {
+ if (run > 0) {
+ run--;
+ } else {
+ int b1 = read(in);
+
+ if ((b1 & QOI_MASK_2) == QOI_INDEX) {
+ int indexPos = (b1 ^ QOI_INDEX) * 4;
+
+ pixelR = index[indexPos] & 0xFF;
+ pixelG = index[indexPos + 1] & 0xFF;
+ pixelB = index[indexPos + 2] & 0xFF;
+ pixelA = index[indexPos + 3] & 0xFF;
+ } else if ((b1 & QOI_MASK_3) == QOI_RUN_8) {
+ run = b1 & 0x1F;
+ } else if ((b1 & QOI_MASK_3) == QOI_RUN_16) {
+ int b2 = read(in);
+
+ run = (((b1 & 0x1F) << 8) | (b2)) + 32;
+ } else if ((b1 & QOI_MASK_2) == QOI_DIFF_8) {
+ pixelR += ((b1 >> 4) & 0x03) - 2;
+ pixelG += ((b1 >> 2) & 0x03) - 2;
+ pixelB += (b1 & 0x03) - 2;
+ } else if ((b1 & QOI_MASK_3) == QOI_DIFF_16) {
+ int b2 = read(in);
+
+ pixelR += (b1 & 0x1F) - 16;
+ pixelG += (b2 >> 4) - 8;
+ pixelB += (b2 & 0x0F) - 8;
+ } else if ((b1 & QOI_MASK_4) == QOI_DIFF_24) {
+ int b2 = read(in);
+ int b3 = read(in);
+
+ pixelR += (((b1 & 0x0F) << 1) | (b2 >> 7)) - 16;
+ pixelG += ((b2 & 0x7C) >> 2) - 16;
+ pixelB += (((b2 & 0x03) << 3) | ((b3 & 0xE0) >> 5)) - 16;
+ pixelA += (b3 & 0x1F) - 16;
+ } else if ((b1 & QOI_MASK_4) == QOI_COLOR) {
+ if ((b1 & 8) != 0) {
+ pixelR = read(in);
+ }
+
+ if ((b1 & 4) != 0) {
+ pixelG = read(in);
+ }
+
+ if ((b1 & 2) != 0) {
+ pixelB = read(in);
+ }
+
+ if ((b1 & 1) != 0) {
+ pixelA = read(in);
+ }
+ }
+
+ int indexPos = getHashTableIndex(pixelR, pixelG, pixelB, pixelA) * 4;
+ index[indexPos] = (byte) pixelR;
+ index[indexPos + 1] = (byte) pixelG;
+ index[indexPos + 2] = (byte) pixelB;
+ index[indexPos + 3] = (byte) pixelA;
+ }
+
+ pixelData[pixelPos] = (byte) pixelR;
+ pixelData[pixelPos + 1] = (byte) pixelG;
+ pixelData[pixelPos + 2] = (byte) pixelB;
+
+ if (channels == 4) {
+ pixelData[pixelPos + 3] = (byte) pixelA;
+ }
+ }
+
+ for (int i = 0; i < QOI_PADDING; i++) {
+ read(in);
+ }
+
+ return new QOIImage(width, height, channels, colorSpace, pixelData);
+ }
+
+ private static int read(@NonNull InputStream in) throws IOException {
+ int read = in.read();
+
+ if (read < 0) {
+ throw new InvalidQOIStreamException("Unexpected end of stream");
+ }
+
+ return read & 0xFF;
+ }
+
+ private static int read32(@NonNull InputStream in) throws IOException {
+ int a = read(in);
+ int b = read(in);
+ int c = read(in);
+ int d = read(in);
+ return (a << 24) | (b << 16) | (c << 8) | d;
+ }
+
+ private static QOIColorSpace readColorSpace(@NonNull InputStream in) throws IOException {
+ int value = read(in);
+
+ switch (value) {
+ case QOI_SRGB:
+ return QOIColorSpace.SRGB;
+ case QOI_SRGB_LINEAR_ALPHA:
+ return QOIColorSpace.SRGB_LINEAR_ALPHA;
+ case QOI_LINEAR:
+ return QOIColorSpace.LINEAR;
+ }
+
+ throw new InvalidQOIStreamException("Invalid color space value " + value);
+ }
+
+}
diff --git a/src/main/java/me/saharnooby/qoi/QOIEncoder.java b/src/main/java/me/saharnooby/qoi/QOIEncoder.java
new file mode 100644
index 0000000..aeb184d
--- /dev/null
+++ b/src/main/java/me/saharnooby/qoi/QOIEncoder.java
@@ -0,0 +1,170 @@
+package me.saharnooby.qoi;
+
+import lombok.NonNull;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import static me.saharnooby.qoi.QOICodec.*;
+
+/**
+ * Contains method that encodes raw pixel data into bytes.
+ */
+public final class QOIEncoder {
+
+ /**
+ * Encodes raw pixel data into QOI image, which then is written into the provided output stream.
+ * @param image QOI image.
+ * @param out Output stream, should be buffered for optimal performance.
+ * @throws IOException On any IO error.
+ */
+ public static void encode(@NonNull QOIImage image, @NonNull OutputStream out) throws IOException {
+ int channels = image.getChannels();
+
+ byte[] pixelData = image.getPixelData();
+
+ write32(out, QOI_MAGIC);
+ write32(out, image.getWidth());
+ write32(out, image.getHeight());
+ out.write(image.getChannels());
+
+ switch (image.getColorSpace()) {
+ case SRGB:
+ out.write(QOI_SRGB);
+ break;
+ case SRGB_LINEAR_ALPHA:
+ out.write(QOI_SRGB_LINEAR_ALPHA);
+ break;
+ case LINEAR:
+ out.write(QOI_LINEAR);
+ break;
+ default:
+ throw new IllegalStateException("Unsupported color space");
+ }
+
+ byte[] index = createHashTable();
+
+ int run = 0;
+
+ int prevR = 0;
+ int prevG = 0;
+ int prevB = 0;
+ int prevA = 0xFF;
+
+ int pixelR;
+ int pixelG;
+ int pixelB;
+ int pixelA = 0xFF;
+
+ for (int pixelPos = 0; pixelPos < pixelData.length; pixelPos += channels) {
+ pixelR = pixelData[pixelPos] & 0xFF;
+ pixelG = pixelData[pixelPos + 1] & 0xFF;
+ pixelB = pixelData[pixelPos + 2] & 0xFF;
+
+ if (channels == 4) {
+ pixelA = pixelData[pixelPos + 3] & 0xFF;
+ }
+
+ boolean prevEqualsCurrent = equals(prevR, prevG, prevB, prevA, pixelR, pixelG, pixelB, pixelA);
+
+ if (prevEqualsCurrent) {
+ run++;
+ }
+
+ if (run > 0 && (run == 0x2020 || !prevEqualsCurrent || pixelPos + channels == pixelData.length)) {
+ if (run < 33) {
+ run -= 1;
+ out.write(QOI_RUN_8 | run);
+ } else {
+ run -= 33;
+ out.write(QOI_RUN_16 | run >> 8);
+ out.write(run);
+ }
+
+ run = 0;
+ }
+
+ if (!prevEqualsCurrent) {
+ int indexPos = getHashTableIndex(pixelR, pixelG, pixelB, pixelA) * 4;
+
+ if (equals(pixelR, pixelG, pixelB, pixelA, index[indexPos], index[indexPos + 1], index[indexPos + 2], index[indexPos + 3])) {
+ out.write(QOI_INDEX | indexPos);
+ } else {
+ index[indexPos] = (byte) pixelR;
+ index[indexPos + 1] = (byte) pixelG;
+ index[indexPos + 2] = (byte) pixelB;
+ index[indexPos + 3] = (byte) pixelA;
+
+ int dr = pixelR - prevR;
+ int dg = pixelG - prevG;
+ int db = pixelB - prevB;
+ int da = pixelA - prevA;
+
+ if (smallDiff(dr) && smallDiff(dg) && smallDiff(db) && smallDiff(da)) {
+ if (da == 0 && smallestDiff(dr) && smallestDiff(dg) && smallestDiff(db)) {
+ out.write(QOI_DIFF_8 | ((dr + 2) << 4) | (dg + 2) << 2 | (db + 2));
+ } else if (da == 0 && smallerDiff(dg) && smallerDiff(db)) {
+ out.write(QOI_DIFF_16 | (dr + 16));
+ out.write((dg + 8) << 4 | (db + 8));
+ } else {
+ out.write(QOI_DIFF_24 | (dr + 16) >> 1);
+ out.write((dr + 16) << 7 | (dg + 16) << 2 | (db + 16) >> 3);
+ out.write((db + 16) << 5 | (da + 16));
+ }
+ } else {
+ out.write(QOI_COLOR | (dr != 0 ? 8 : 0) | (dg != 0 ? 4 : 0) | (db != 0 ? 2 : 0) | (da != 0 ? 1 : 0));
+
+ if (dr != 0) {
+ out.write(pixelR);
+ }
+
+ if (dg != 0) {
+ out.write(pixelG);
+ }
+
+ if (db != 0) {
+ out.write(pixelB);
+ }
+
+ if (da != 0) {
+ out.write(pixelA);
+ }
+ }
+ }
+ }
+
+ prevR = pixelR;
+ prevG = pixelG;
+ prevB = pixelB;
+ prevA = pixelA;
+ }
+
+ for (int i = 0; i < QOI_PADDING; i++) {
+ out.write(0);
+ }
+ }
+
+ private static void write32(@NonNull OutputStream out, int value) throws IOException {
+ out.write(value >> 24);
+ out.write(value >> 16);
+ out.write(value >> 8);
+ out.write(value);
+ }
+
+ private static boolean smallDiff(int i) {
+ return i > -17 && i < 16;
+ }
+
+ private static boolean smallerDiff(int i) {
+ return i > -9 && i < 8;
+ }
+
+ private static boolean smallestDiff(int i) {
+ return i > -3 && i < 2;
+ }
+
+ private static boolean equals(int r1, int g1, int b1, int a1, int r2, int g2, int b2, int a2) {
+ return r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2;
+ }
+
+}
diff --git a/src/main/java/me/saharnooby/qoi/QOIImage.java b/src/main/java/me/saharnooby/qoi/QOIImage.java
new file mode 100644
index 0000000..b93084a
--- /dev/null
+++ b/src/main/java/me/saharnooby/qoi/QOIImage.java
@@ -0,0 +1,40 @@
+package me.saharnooby.qoi;
+
+import lombok.AccessLevel;
+import lombok.Data;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * A bundle of QOI image metadata and raw pixel data.
+ * Use methods in {@link QOIUtil} to create instances of this class.
+ */
+@Data
+@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
+public final class QOIImage {
+
+ /**
+ * Image width. Positive value.
+ */
+ private final int width;
+ /**
+ * Image height. Positive value.
+ */
+ private final int height;
+ /**
+ * Channel count. Supported values are 3 (no alpha) and 4 (with alpha).
+ */
+ private final int channels;
+ /**
+ * Color space of the image.
+ */
+ @NonNull
+ private final QOIColorSpace colorSpace;
+ /**
+ * Raw pixel data in the form of [R, G, B, (A,) ...].
+ * The array has (width * height * channels) elements.
+ * Alpha is present when channel count is 4.
+ */
+ private final byte @NonNull [] pixelData;
+
+}
diff --git a/src/main/java/me/saharnooby/qoi/QOIUtil.java b/src/main/java/me/saharnooby/qoi/QOIUtil.java
new file mode 100644
index 0000000..09f351c
--- /dev/null
+++ b/src/main/java/me/saharnooby/qoi/QOIUtil.java
@@ -0,0 +1,142 @@
+package me.saharnooby.qoi;
+
+import lombok.NonNull;
+
+import java.io.*;
+
+/**
+ * Contains public API methods of the library.
+ */
+@SuppressWarnings("unused")
+public final class QOIUtil {
+
+ /**
+ * Creates a QOI image from raw pixel data.
+ * Channel count is detected automatically.
+ * Data array is not copied.
+ * @param pixelData Pixel data array in the form of [R, G, B, (A,) ...].
+ * @param width Image width, must be positive.
+ * @param height Image height, must be positive.
+ * @return QOI image.
+ * @throws IllegalArgumentException If any arguments are invalid.
+ */
+ public static QOIImage createFromPixelData(byte @NonNull [] pixelData, int width, int height) {
+ return createFromPixelData(pixelData, width, height, pixelData.length / width / height);
+ }
+
+ /**
+ * Creates a QOI image from raw pixel data.
+ * Data array is not copied.
+ * @param pixelData Pixel data array in the form of [R, G, B, (A,) ...]. Alpha must be present only if channel count is 4.
+ * @param width Image width, must be positive.
+ * @param height Image height, must be positive.
+ * @param channels Channel count, must be 3 of 4.
+ * @return QOI image.
+ * @throws IllegalArgumentException If any arguments are invalid.
+ */
+ public static QOIImage createFromPixelData(byte @NonNull [] pixelData, int width, int height, int channels) {
+ return createFromPixelData(pixelData, width, height, channels, QOIColorSpace.SRGB);
+ }
+
+ /**
+ * Creates a QOI image from raw pixel data.
+ * Data array is not copied.
+ * @param pixelData Pixel data array in the form of [R, G, B, (A,) ...]. Alpha must be present only if channel count is 4.
+ * @param width Image width, must be positive.
+ * @param height Image height, must be positive.
+ * @param channels Channel count, must be 3 or 4.
+ * @param colorSpace Color space.
+ * @return QOI image.
+ * @throws IllegalArgumentException If any arguments are invalid.
+ */
+ public static QOIImage createFromPixelData(byte @NonNull [] pixelData, int width, int height, int channels, @NonNull QOIColorSpace colorSpace) {
+ if (width < 1) {
+ throw new IllegalArgumentException("Width must be positive");
+ }
+
+ if (height < 1) {
+ throw new IllegalArgumentException("Height must be positive");
+ }
+
+ if (channels != 3 && channels != 4) {
+ throw new IllegalArgumentException("3 or 4 channels are supported");
+ }
+
+ if (pixelData.length != width * height * channels) {
+ throw new IllegalArgumentException("Unexpected pixel data array length, must match width * height * channels");
+ }
+
+ return new QOIImage(width, height, channels, colorSpace, pixelData);
+ }
+
+ /**
+ * Reads a QOI image from an input stream.
+ * @param in Input stream.
+ * @return QOI image.
+ * @throws InvalidQOIStreamException If provided data does not represent a valid QOI image.
+ * @throws IOException On any IO error.
+ */
+ public static QOIImage readImage(@NonNull InputStream in) throws IOException {
+ return readImage(in, 0);
+ }
+
+ /**
+ * Reads a QOI image from an input stream.
+ * @param in Input stream.
+ * @param channels Channel count, must be 0 (auto), 3 or 4.
+ * @return QOI image.
+ * @throws InvalidQOIStreamException If provided data does not represent a valid QOI image.
+ * @throws IOException On any IO error.
+ */
+ public static QOIImage readImage(@NonNull InputStream in, int channels) throws IOException {
+ return QOIDecoder.decode(in, channels);
+ }
+
+ /**
+ * Reads a QOI image from a file.
+ * @param file File.
+ * @return QOI image.
+ * @throws InvalidQOIStreamException If provided file does not represent a valid QOI image.
+ * @throws IOException On any IO error.
+ */
+ public static QOIImage readFile(@NonNull File file) throws IOException {
+ return readFile(file, 0);
+ }
+
+ /**
+ * Reads a QOI image from a file.
+ * @param file File.
+ * @param channels Channel count, must be 0 (auto), 3 or 4.
+ * @return QOI image.
+ * @throws InvalidQOIStreamException If provided file does not represent a valid QOI image.
+ * @throws IOException On any IO error.
+ */
+ public static QOIImage readFile(@NonNull File file, int channels) throws IOException {
+ try (InputStream in = new BufferedInputStream(new FileInputStream(file))) {
+ return readImage(in, channels);
+ }
+ }
+
+ /**
+ * Writes a QOI image into an output stream.
+ * @param image Image.
+ * @param out Output stream.
+ * @throws IOException On any IO error.
+ */
+ public static void writeImage(@NonNull QOIImage image, @NonNull OutputStream out) throws IOException {
+ QOIEncoder.encode(image, out);
+ }
+
+ /**
+ * Writes a QOI image into a file.
+ * @param image Image.
+ * @param file File.
+ * @throws IOException On any IO error.
+ */
+ public static void writeImage(@NonNull QOIImage image, @NonNull File file) throws IOException {
+ try (OutputStream out = new BufferedOutputStream(new FileOutputStream(file))) {
+ writeImage(image, out);
+ }
+ }
+
+}
diff --git a/src/test/java/me/saharnooby/qoi/QOITest.java b/src/test/java/me/saharnooby/qoi/QOITest.java
new file mode 100644
index 0000000..43a2e9d
--- /dev/null
+++ b/src/test/java/me/saharnooby/qoi/QOITest.java
@@ -0,0 +1,104 @@
+package me.saharnooby.qoi;
+
+import lombok.NonNull;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.io.*;
+import java.util.Objects;
+import java.util.Random;
+
+/**
+ * @author saharNooby
+ * @since 16:38 01.12.2021
+ */
+class QOITest {
+
+ private final ByteArrayOutputStream out = new ByteArrayOutputStream();
+
+ @Test
+ void testEmptyImageRGB() throws Exception {
+ int size = 128;
+
+ testEncodingDecoding(new byte[3 * size * size], size, size, 3);
+ }
+
+ @Test
+ void testEmptyImageRGBA() throws Exception {
+ int size = 128;
+
+ testEncodingDecoding(new byte[4 * size * size], size, size, 4);
+ }
+
+ @Test
+ void testRandomImage() throws Exception {
+ int width = 800;
+ int height = 600;
+
+ Random random = new Random("seed".hashCode());
+
+ for (int channels : new int[] {3, 4}) {
+ byte[] data = new byte[width * height * channels];
+
+ random.nextBytes(data);
+
+ testEncodingDecoding(data, width, height, channels);
+ }
+ }
+
+ @Test
+ void testNormalImage() throws Exception {
+ InputStream in = Objects.requireNonNull(getClass().getResourceAsStream("/orange-cross.qoi"), "Test image not found");
+
+ QOIImage rgb = QOIDecoder.decode(new BufferedInputStream(in), 3);
+
+ in = Objects.requireNonNull(getClass().getResourceAsStream("/orange-cross.qoi"), "Test image not found");
+
+ QOIImage rgba = QOIDecoder.decode(new BufferedInputStream(in), 4);
+
+ testEncodingDecoding(rgb.getPixelData(), rgb.getWidth(), rgb.getHeight(), 3);
+ testEncodingDecoding(rgba.getPixelData(), rgba.getWidth(), rgba.getHeight(), 4);
+ }
+
+ /**
+ * Tests that after encoding and decoding back data stays the same.
+ */
+ private void testEncodingDecoding(byte @NonNull [] pixelData, int width, int height, int channels) throws Exception {
+ this.out.reset();
+
+ QOIEncoder.encode(new QOIImage(width, height, channels, QOIColorSpace.SRGB, pixelData), this.out);
+
+ QOIImage decoded = decode(channels);
+
+ Assertions.assertArrayEquals(pixelData, decoded.getPixelData());
+
+ // Try read with different channel count
+ int differentChannels = channels == 3 ? 4 : 3;
+
+ decoded = decode(differentChannels);
+
+ for (int i = 0; i < width * height; i++) {
+ Assertions.assertEquals(pixelData[i * channels], decoded.getPixelData()[i * differentChannels]);
+ Assertions.assertEquals(pixelData[i * channels + 1], decoded.getPixelData()[i * differentChannels + 1]);
+ Assertions.assertEquals(pixelData[i * channels + 2], decoded.getPixelData()[i * differentChannels + 2]);
+
+ if (differentChannels == 4) {
+ Assertions.assertEquals(0xFF, decoded.getPixelData()[i * differentChannels + 3] & 0xFF);
+ }
+ }
+ }
+
+ /**
+ * Decodes current buffer into raw pixel data.
+ */
+ private QOIImage decode(int channels) throws IOException {
+ ByteArrayInputStream in = new ByteArrayInputStream(this.out.toByteArray());
+
+ QOIImage decoded = QOIDecoder.decode(in, channels);
+
+ Assertions.assertEquals(0, in.available(), "Expected input stream to be read fully");
+
+ return decoded;
+ }
+
+}
\ No newline at end of file
diff --git a/src/test/resources/orange-cross.qoi b/src/test/resources/orange-cross.qoi
new file mode 100644
index 0000000..5eb0666
Binary files /dev/null and b/src/test/resources/orange-cross.qoi differ